Skip to content

Commit

Permalink
feat(client): Optional generateID to provide subscription IDs (enis…
Browse files Browse the repository at this point in the history
…denjo#22)

Closes enisdenjo#21

* feat: take in a custom ID generator

* test: basic
  • Loading branch information
enisdenjo committed Oct 1, 2020
1 parent b7b4470 commit 9a3f54a
Show file tree
Hide file tree
Showing 4 changed files with 69 additions and 33 deletions.
65 changes: 37 additions & 28 deletions src/client.ts
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -431,7 +461,7 @@ export function createClient(options: ClientOptions): Client {

socket.send(
stringifyMessage<MessageType.Subscribe>({
id: uuid,
id: id,
type: MessageType.Subscribe,
payload,
}),
Expand All @@ -443,7 +473,7 @@ export function createClient(options: ClientOptions): Client {
// send complete message to server on cancel
socket.send(
stringifyMessage<MessageType.Complete>({
id: uuid,
id: id,
type: MessageType.Complete,
}),
);
Expand Down Expand Up @@ -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);
});
}
4 changes: 2 additions & 2 deletions src/server.ts
Expand Up @@ -36,7 +36,7 @@ import {
hasOwnStringProperty,
noop,
} from './utils';
import { UUID } from './types';
import { ID } from './types';

export type ExecutionResultFormatter = (
ctx: Context,
Expand Down Expand Up @@ -199,7 +199,7 @@ export interface Context {
* Subscriptions are for `subscription` operations **only**,
* other operations (`query`/`mutation`) are resolved immediately.
*/
subscriptions: Record<UUID, AsyncIterator<unknown>>;
subscriptions: Record<ID, AsyncIterator<unknown>>;
}

export interface Server extends Disposable {
Expand Down
26 changes: 26 additions & 0 deletions src/tests/client.ts
Expand Up @@ -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"', () => {
Expand Down
7 changes: 4 additions & 3 deletions src/types.ts
Expand Up @@ -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. */
Expand Down

0 comments on commit 9a3f54a

Please sign in to comment.