Skip to content

Commit

Permalink
feat(client): Support providing custom WebSocket implementations (eni…
Browse files Browse the repository at this point in the history
…sdenjo#18)

* feat: begin

* test: write some

* fix: set global WebSocket before each test
  • Loading branch information
enisdenjo committed Sep 28, 2020
1 parent 4b1fc8d commit 1515fe2
Show file tree
Hide file tree
Showing 2 changed files with 68 additions and 13 deletions.
43 changes: 34 additions & 9 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ export interface ClientOptions {
* to get the emitted event before other registered listeners.
*/
on?: Partial<{ [event in Event]: EventListener<event> }>;
/**
* A custom WebSocket implementation to use instead of the
* one provided by the global scope. Mostly useful for when
* using the client outside of the browser environment.
*/
webSocketImpl?: unknown;
}

export interface Client extends Disposable {
Expand All @@ -85,8 +91,17 @@ export function createClient(options: ClientOptions): Client {
retryAttempts = 5,
retryTimeout = 3 * 1000, // 3 seconds
on,
webSocketImpl,
} = options;

let WebSocketImpl = WebSocket;
if (webSocketImpl) {
if (!isWebSocket(webSocketImpl)) {
throw new Error('Invalid WebSocket implementation provided');
}
WebSocketImpl = webSocketImpl;
}

const emitter = (() => {
const listeners: { [event in Event]: EventListener<event>[] } = {
connecting: on?.connecting ? [on.connecting] : [],
Expand Down Expand Up @@ -134,7 +149,7 @@ export function createClient(options: ClientOptions): Client {
> {
if (state.socket) {
switch (state.socket.readyState) {
case WebSocket.OPEN: {
case WebSocketImpl.OPEN: {
// if the socket is not acknowledged, wait a bit and reavaluate
// TODO-db-200908 can you guarantee finite recursive calls?
if (!state.acknowledged) {
Expand All @@ -149,7 +164,7 @@ export function createClient(options: ClientOptions): Client {
if (!state.socket) {
return reject(new Error('Socket closed unexpectedly'));
}
if (state.socket.readyState === WebSocket.CLOSED) {
if (state.socket.readyState === WebSocketImpl.CLOSED) {
return reject(new Error('Socket has already been closed'));
}

Expand All @@ -176,11 +191,11 @@ export function createClient(options: ClientOptions): Client {
}),
];
}
case WebSocket.CONNECTING: {
case WebSocketImpl.CONNECTING: {
let waitedTimes = 0;
while (
state.socket && // the socket can be deleted in the meantime
state.socket.readyState === WebSocket.CONNECTING
state.socket.readyState === WebSocketImpl.CONNECTING
) {
await new Promise((resolve) => setTimeout(resolve, 100));
// 100ms * 50 = 5sec
Expand All @@ -193,13 +208,13 @@ export function createClient(options: ClientOptions): Client {
}
return connect(cancellerRef); // reavaluate
}
case WebSocket.CLOSED:
case WebSocketImpl.CLOSED:
break; // just continue, we'll make a new one
case WebSocket.CLOSING: {
case WebSocketImpl.CLOSING: {
let waitedTimes = 0;
while (
state.socket && // the socket can be deleted in the meantime
state.socket.readyState === WebSocket.CLOSING
state.socket.readyState === WebSocketImpl.CLOSING
) {
await new Promise((resolve) => setTimeout(resolve, 100));
// 100ms * 50 = 5sec
Expand All @@ -218,7 +233,7 @@ export function createClient(options: ClientOptions): Client {
}

// establish connection and assign to singleton
const socket = new WebSocket(url, GRAPHQL_TRANSPORT_WS_PROTOCOL);
const socket = new WebSocketImpl(url, GRAPHQL_TRANSPORT_WS_PROTOCOL);
state = {
...state,
acknowledged: false,
Expand Down Expand Up @@ -302,7 +317,7 @@ export function createClient(options: ClientOptions): Client {
socket,
(cleanup) =>
new Promise((resolve, reject) => {
if (socket.readyState === WebSocket.CLOSED) {
if (socket.readyState === WebSocketImpl.CLOSED) {
return reject(new Error('Socket has already been closed'));
}

Expand Down Expand Up @@ -487,6 +502,16 @@ function isCloseEvent(val: unknown): val is CloseEvent {
return isObject(val) && 'code' in val && 'reason' in val && 'wasClean' in val;
}

function isWebSocket(val: unknown): val is typeof WebSocket {
return (
typeof val === 'function' &&
'CLOSED' in val &&
'CLOSING' in val &&
'CONNECTING' in val &&
'OPEN' in val
);
}

/** Generates a new v4 UUID. Reference: https://stackoverflow.com/a/2117523/709884 */
function generateUUID(): UUID {
if (!window.crypto) {
Expand Down
38 changes: 34 additions & 4 deletions src/tests/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ import { noop } from '../utils';
const wait = (timeout: number) =>
new Promise((resolve) => setTimeout(resolve, timeout));

Object.assign(global, {
WebSocket: WebSocket,
});

let server: Server, dispose: (() => Promise<void>) | undefined;
beforeEach(async () => {
Object.assign(global, {
WebSocket: WebSocket,
});

[server, dispose] = await startServer();
});
afterEach(async () => {
Expand All @@ -27,6 +27,36 @@ afterEach(async () => {
dispose = undefined;
});

it('should use the provided WebSocket implementation', async () => {
Object.assign(global, {
WebSocket: null,
});

createClient({
url,
lazy: false,
webSocketImpl: WebSocket,
});

await wait(10);

expect(server.webSocketServer.clients.size).toBe(1);
});

it('should not accept invalid WebSocket implementations', async () => {
Object.assign(global, {
WebSocket: null,
});

expect(() =>
createClient({
url,
lazy: false,
webSocketImpl: {},
}),
).toThrow();
});

describe('query operation', () => {
it('should execute the query, "next" the result and then complete', (done) => {
const client = createClient({ url });
Expand Down

0 comments on commit 1515fe2

Please sign in to comment.