From 9a3f54a8198379b402a8abe414ab5727ccec45cf Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 1 Oct 2020 21:29:29 +0200 Subject: [PATCH] feat(client): Optional `generateID` to provide subscription IDs (#22) Closes #21 * feat: take in a custom ID generator * test: basic --- src/client.ts | 65 ++++++++++++++++++++++++++------------------- src/server.ts | 4 +-- src/tests/client.ts | 26 ++++++++++++++++++ src/types.ts | 7 ++--- 4 files changed, 69 insertions(+), 33 deletions(-) diff --git a/src/client.ts b/src/client.ts index 8a978830..2799735e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -6,7 +6,7 @@ * */ -import { Sink, UUID, Disposable } from './types'; +import { Sink, ID, Disposable } from './types'; import { GRAPHQL_TRANSPORT_WS_PROTOCOL } from './protocol'; import { Message, @@ -67,6 +67,13 @@ export interface ClientOptions { * using the client outside of the browser environment. */ webSocketImpl?: unknown; + /** + * A custom ID generator for identifying subscriptions. + * The default uses the `crypto` module in the global window + * object, suitable for the browser environment. However, if + * it can't be found, `Math.random` would be used instead. + */ + generateID?: () => ID; } export interface Client extends Disposable { @@ -92,6 +99,29 @@ export function createClient(options: ClientOptions): Client { retryTimeout = 3 * 1000, // 3 seconds on, webSocketImpl, + /** + * Generates a v4 UUID to be used as the ID. + * Reference: https://stackoverflow.com/a/2117523/709884 + */ + generateID = function generateUUID() { + if (window && window.crypto) { + 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); + }); + } + + // use Math.random when crypto is not available + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0, + v = c == 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); + }, } = options; let WebSocketImpl = WebSocket; @@ -393,26 +423,26 @@ export function createClient(options: ClientOptions): Client { return { on: emitter.on, subscribe(payload, sink) { - const uuid = generateUUID(); + const id = generateID(); const messageHandler = ({ data }: MessageEvent) => { const message = memoParseMessage(data); switch (message.type) { case MessageType.Next: { - if (message.id === uuid) { + if (message.id === id) { // eslint-disable-next-line @typescript-eslint/no-explicit-any sink.next(message.payload as any); } return; } case MessageType.Error: { - if (message.id === uuid) { + if (message.id === id) { sink.error(message.payload); } return; } case MessageType.Complete: { - if (message.id === uuid) { + if (message.id === id) { sink.complete(); } return; @@ -431,7 +461,7 @@ export function createClient(options: ClientOptions): Client { socket.send( stringifyMessage({ - id: uuid, + id: id, type: MessageType.Subscribe, payload, }), @@ -443,7 +473,7 @@ export function createClient(options: ClientOptions): Client { // send complete message to server on cancel socket.send( stringifyMessage({ - id: uuid, + id: id, type: MessageType.Complete, }), ); @@ -511,24 +541,3 @@ function isWebSocket(val: unknown): val is typeof WebSocket { 'OPEN' in val ); } - -/** Generates a new v4 UUID. Reference: https://stackoverflow.com/a/2117523/709884 */ -function generateUUID(): UUID { - 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); - }); -} diff --git a/src/server.ts b/src/server.ts index 8b3c14d3..f19a064e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -36,7 +36,7 @@ import { hasOwnStringProperty, noop, } from './utils'; -import { UUID } from './types'; +import { ID } from './types'; export type ExecutionResultFormatter = ( ctx: Context, @@ -199,7 +199,7 @@ export interface Context { * Subscriptions are for `subscription` operations **only**, * other operations (`query`/`mutation`) are resolved immediately. */ - subscriptions: Record>; + subscriptions: Record>; } export interface Server extends Disposable { diff --git a/src/tests/client.ts b/src/tests/client.ts index 9d96bd42..c5d001fb 100644 --- a/src/tests/client.ts +++ b/src/tests/client.ts @@ -255,6 +255,32 @@ describe('subscription operation', () => { expect(nextFnForBananas).toHaveBeenCalledTimes(2); expect(completeFnForBananas).toBeCalled(); }); + + it('should use the provided `generateID` for subscription IDs', async () => { + const generateIDFn = jest.fn(() => 'not unique'); + + const client = createClient({ url, generateID: generateIDFn }); + + client.subscribe( + { + query: `subscription { + boughtBananas { + name + } + }`, + }, + { + next: noop, + error: () => { + fail(`Unexpected error call`); + }, + complete: noop, + }, + ); + await wait(10); + + expect(generateIDFn).toBeCalled(); + }); }); describe('"concurrency"', () => { diff --git a/src/types.ts b/src/types.ts index bf3ab66a..43fbc351 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,10 +7,11 @@ import { GraphQLError } from 'graphql'; /** - * UUID v4 string type alias generated through the - * `generateUUID` function from the client. + * ID is a string type alias representing + * the globally unique ID used for identifying + * subscriptions established by the client. */ -export type UUID = string; +export type ID = string; export interface Disposable { /** Dispose of the instance and clear up resources. */