-
-
Notifications
You must be signed in to change notification settings - Fork 163
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
# [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
1 parent
46a64eb
commit 2b8c3f0
Showing
12 changed files
with
919 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './client'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
Oops, something went wrong.