Skip to content

Commit

Permalink
chore(release): 🎉 1.0.0 [skip ci]
Browse files Browse the repository at this point in the history
# [1.0.0](v0.0.2...v1.0.0) (2020-08-17)

### Features

* **client:** Re-implement following the new transport protocol ([#6](#6)) ([5191a35](5191a35))
* **server:** Implement following the new transport protocol ([#1](#1)) ([a412d25](a412d25))
* Rewrite GraphQL over WebSocket Protocol ([#2](#2)) ([42045c5](42045c5))

### BREAKING CHANGES

* This lib is no longer compatible with [`subscriptions-transport-ws`](https://github.com/apollographql/subscriptions-transport-ws). It follows a redesigned transport protocol aiming to improve security, stability and reduce ambiguity.
  • Loading branch information
semantic-release-bot committed Aug 17, 2020
1 parent 46a64eb commit 2b8c3f0
Show file tree
Hide file tree
Showing 12 changed files with 919 additions and 0 deletions.
26 changes: 26 additions & 0 deletions lib/client.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
*
* GraphQL over WebSocket Protocol
*
* Check out the `PROTOCOL.md` document for the transport specification.
*
*/
import { Sink, Disposable } from './types';
import { SubscribePayload } from './message';
/** Configuration used for the `create` client function. */
export interface ClientOptions {
/** URL of the GraphQL server to connect. */
url: string;
/** Optional parameters that the client specifies when establishing a connection with the server. */
connectionParams?: Record<string, unknown> | (() => Record<string, unknown>);
}
export interface Client extends Disposable {
/**
* Subscribes through the WebSocket following the config parameters. It
* uses the `sink` to emit received data or errors. Returns a _cleanup_
* function used for dropping the subscription and cleaning stuff up.
*/
subscribe<T = unknown>(payload: SubscribePayload, sink: Sink<T>): () => void;
}
/** Creates a disposable GQL subscriptions client. */
export declare function createClient(options: ClientOptions): Client;
239 changes: 239 additions & 0 deletions lib/client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
"use strict";
/**
*
* GraphQL over WebSocket Protocol
*
* Check out the `PROTOCOL.md` document for the transport specification.
*
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.createClient = void 0;
const protocol_1 = require("./protocol");
const message_1 = require("./message");
const utils_1 = require("./utils");
/** Creates a disposable GQL subscriptions client. */
function createClient(options) {
const { url, connectionParams } = options;
// holds all currently subscribed sinks, will use this map
// to dispatch messages to the correct destination
const subscribedSinks = {};
function errorAllSinks(err) {
Object.entries(subscribedSinks).forEach(([, sink]) => sink.error(err));
}
function completeAllSinks() {
Object.entries(subscribedSinks).forEach(([, sink]) => sink.complete());
}
// Lazily uses the socket singleton to establishes a connection described by the protocol.
let socket = null, connected = false, connecting = false;
async function connect() {
if (connected) {
return;
}
if (connecting) {
let waitedTimes = 0;
while (!connected) {
await new Promise((resolve) => setTimeout(resolve, 100));
// 100ms * 50 = 5sec
if (waitedTimes >= 50) {
throw new Error('Waited 10 seconds but socket never connected');
}
waitedTimes++;
}
// connected === true
return;
}
connected = false;
connecting = true;
return new Promise((resolve, reject) => {
let done = false; // used to avoid resolving/rejecting the promise multiple times
socket = new WebSocket(url, protocol_1.GRAPHQL_TRANSPORT_WS_PROTOCOL);
/**
* `onerror` handler is unnecessary because even if an error occurs, the `onclose` handler will be called
*
* From: https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications
* > If an error occurs while attempting to connect, first a simple event with the name error is sent to the
* > WebSocket object (thereby invoking its onerror handler), and then the CloseEvent is sent to the WebSocket
* > object (thereby invoking its onclose handler) to indicate the reason for the connection's closing.
*/
socket.onclose = ({ code, reason }) => {
const err = new Error(`Socket closed with event ${code}` + !reason ? '' : `: ${reason}`);
if (code === 1000 || code === 1001) {
// close event `1000: Normal Closure` is ok and so is `1001: Going Away` (maybe the server is restarting)
completeAllSinks();
}
else {
// all other close events are considered erroneous
errorAllSinks(err);
}
if (!done) {
done = true;
connecting = false;
connected = false; // the connection is lost
socket = null;
reject(err); // we reject here bacause the close is not supposed to be called during the connect phase
}
};
socket.onopen = () => {
try {
if (!socket) {
throw new Error('Opened a socket on nothing');
}
socket.send(message_1.stringifyMessage({
type: message_1.MessageType.ConnectionInit,
payload: typeof connectionParams === 'function'
? connectionParams()
: connectionParams,
}));
}
catch (err) {
errorAllSinks(err);
if (!done) {
done = true;
connecting = false;
if (socket) {
socket.close();
socket = null;
}
reject(err);
}
}
};
function handleMessage({ data }) {
try {
if (!socket) {
throw new Error('Received a message on nothing');
}
const message = message_1.parseMessage(data);
if (message.type !== message_1.MessageType.ConnectionAck) {
throw new Error(`First message cannot be of type ${message.type}`);
}
// message.type === MessageType.ConnectionAck
if (!done) {
done = true;
connecting = false;
connected = true; // only now is the connection ready
resolve();
}
}
catch (err) {
errorAllSinks(err);
if (!done) {
done = true;
connecting = false;
if (socket) {
socket.close();
socket = null;
}
reject(err);
}
}
finally {
if (socket) {
// this listener is not necessary anymore
socket.removeEventListener('message', handleMessage);
}
}
}
socket.addEventListener('message', handleMessage);
});
}
return {
subscribe: (payload, sink) => {
const uuid = generateUUID();
if (subscribedSinks[uuid]) {
sink.error(new Error(`Sink with ID ${uuid} already registered`));
return utils_1.noop;
}
subscribedSinks[uuid] = sink;
function handleMessage({ data }) {
const message = message_1.parseMessage(data);
switch (message.type) {
case message_1.MessageType.Next: {
if (message.id === uuid) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sink.next(message.payload);
}
break;
}
case message_1.MessageType.Error: {
if (message.id === uuid) {
sink.error(message.payload);
}
break;
}
case message_1.MessageType.Complete: {
if (message.id === uuid) {
sink.complete();
}
break;
}
}
}
(async () => {
try {
await connect();
if (!socket) {
throw new Error('Socket connected but empty');
}
socket.addEventListener('message', handleMessage);
socket.send(message_1.stringifyMessage({
id: uuid,
type: message_1.MessageType.Subscribe,
payload,
}));
}
catch (err) {
sink.error(err);
}
})();
return () => {
if (socket) {
socket.send(message_1.stringifyMessage({
id: uuid,
type: message_1.MessageType.Complete,
}));
socket.removeEventListener('message', handleMessage);
// equal to 1 because this sink is the last one.
// the deletion from the map happens afterwards
if (Object.entries(subscribedSinks).length === 1) {
socket.close(1000, 'Normal Closure');
socket = null;
}
}
sink.complete();
delete subscribedSinks[uuid];
};
},
dispose: async () => {
// complete all sinks
// TODO-db-200817 complete or error? the sinks should be completed BEFORE the client gets disposed
completeAllSinks();
// delete all sinks
Object.keys(subscribedSinks).forEach((uuid) => {
delete subscribedSinks[uuid];
});
// if there is an active socket, close it with a normal closure
if (socket && socket.readyState === WebSocket.OPEN) {
// TODO-db-200817 decide if `1001: Going Away` should be used instead
socket.close(1000, 'Normal Closure');
socket = null;
}
},
};
}
exports.createClient = createClient;
/** Generates a new v4 UUID. Reference: https://stackoverflow.com/a/2117523/709884 */
function generateUUID() {
if (!window.crypto) {
// fallback to Math.random when crypto is not available
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0, v = c == 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (s) => {
const c = Number.parseInt(s, 10);
return (c ^
(window.crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16);
});
}
1 change: 1 addition & 0 deletions lib/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './client';
13 changes: 13 additions & 0 deletions lib/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !exports.hasOwnProperty(p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
__exportStar(require("./client"), exports);
56 changes: 56 additions & 0 deletions lib/message.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
*
* message
*
*/
import { GraphQLError, ExecutionResult, DocumentNode } from 'graphql';
/** Types of messages allowed to be sent by the client/server over the WS protocol. */
export declare enum MessageType {
ConnectionInit = "connection_init",
ConnectionAck = "connection_ack",
Subscribe = "subscribe",
Next = "next",
Error = "error",
Complete = "complete"
}
export interface ConnectionInitMessage {
readonly type: MessageType.ConnectionInit;
readonly payload?: Record<string, unknown>;
}
export interface ConnectionAckMessage {
readonly type: MessageType.ConnectionAck;
}
export interface SubscribeMessage {
readonly id: string;
readonly type: MessageType.Subscribe;
readonly payload: SubscribePayload;
}
export interface SubscribePayload {
readonly operationName: string;
readonly query: string | DocumentNode;
readonly variables: Record<string, unknown>;
}
export interface NextMessage {
readonly id: string;
readonly type: MessageType.Next;
readonly payload: ExecutionResult;
}
export interface ErrorMessage {
readonly id: string;
readonly type: MessageType.Error;
readonly payload: readonly GraphQLError[];
}
export interface CompleteMessage {
readonly id: string;
readonly type: MessageType.Complete;
}
export declare type Message<T extends MessageType = MessageType> = T extends MessageType.ConnectionAck ? ConnectionAckMessage : T extends MessageType.ConnectionInit ? ConnectionInitMessage : T extends MessageType.Subscribe ? SubscribeMessage : T extends MessageType.Next ? NextMessage : T extends MessageType.Error ? ErrorMessage : T extends MessageType.Complete ? CompleteMessage : never;
/** @ignore */
export declare function isMessage(val: unknown): val is Message;
/** @ignore */
export declare function parseMessage(data: unknown): Message;
/**
* @ignore
* Helps stringifying a valid message ready to be sent through the socket.
*/
export declare function stringifyMessage<T extends MessageType>(msg: Message<T>): string;
Loading

0 comments on commit 2b8c3f0

Please sign in to comment.