graphql-ws
Version:
Coherent, zero-dependency, lazy, simple, GraphQL over WebSocket Protocol compliant server and client
341 lines (340 loc) • 14.4 kB
JavaScript
;
/**
*
* client
*
*/
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" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
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");
// this file is the entry point for browsers, re-export relevant elements
__exportStar(require("./message"), exports);
__exportStar(require("./protocol"), exports);
/** Creates a disposable GraphQL over WebSocket client. */
function createClient(options) {
const { url, connectionParams, lazy = true, onNonLazyError = console.error, keepAlive = 0, retryAttempts = 5, retryWait = async function randomisedExponentialBackoff(retries) {
let retryDelay = 1000; // start with 1s delay
for (let i = 0; i < retries; i++) {
retryDelay *= 2;
}
await new Promise((resolve) => setTimeout(resolve, retryDelay +
// add random timeout from 300ms to 3s
Math.floor(Math.random() * (3000 - 300) + 300)));
}, isFatalConnectionProblem = (errOrCloseEvent) =>
// non `CloseEvent`s are fatal by default
!isLikeCloseEvent(errOrCloseEvent), on, webSocketImpl,
/**
* Generates a v4 UUID to be used as the ID using `Math`
* as the random number generator. Supply your own generator
* in case you need more uniqueness.
*
* Reference: https://stackoverflow.com/a/2117523/709884
*/
generateID = function generateUUID() {
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 ws;
if (webSocketImpl) {
if (!isWebSocket(webSocketImpl)) {
throw new Error('Invalid WebSocket implementation provided');
}
ws = webSocketImpl;
}
else if (typeof WebSocket !== 'undefined') {
ws = WebSocket;
}
else if (typeof global !== 'undefined') {
ws =
global.WebSocket ||
// @ts-expect-error: Support more browsers
global.MozWebSocket;
}
else if (typeof window !== 'undefined') {
ws =
window.WebSocket ||
// @ts-expect-error: Support more browsers
window.MozWebSocket;
}
if (!ws) {
throw new Error('WebSocket implementation missing');
}
const WebSocketImpl = ws;
// websocket status emitter, subscriptions are handled differently
const emitter = (() => {
const listeners = {
connecting: (on === null || on === void 0 ? void 0 : on.connecting) ? [on.connecting] : [],
connected: (on === null || on === void 0 ? void 0 : on.connected) ? [on.connected] : [],
closed: (on === null || on === void 0 ? void 0 : on.closed) ? [on.closed] : [],
};
return {
on(event, listener) {
const l = listeners[event];
l.push(listener);
return () => {
l.splice(l.indexOf(listener), 1);
};
},
emit(event, ...args) {
for (const listener of listeners[event]) {
// @ts-expect-error: The args should fit
listener(...args);
}
},
};
})();
let connecting, locks = 0, retrying = false, retries = 0, disposed = false;
async function connect() {
locks++;
const socket = await (connecting !== null && connecting !== void 0 ? connecting : (connecting = new Promise((resolve, reject) => (async () => {
if (retrying) {
await retryWait(retries);
retries++;
}
emitter.emit('connecting');
const socket = new WebSocketImpl(url, protocol_1.GRAPHQL_TRANSPORT_WS_PROTOCOL);
socket.onclose = (event) => {
connecting = undefined;
emitter.emit('closed', event);
reject(event);
};
socket.onopen = async () => {
try {
socket.send(message_1.stringifyMessage({
type: message_1.MessageType.ConnectionInit,
payload: typeof connectionParams === 'function'
? await connectionParams()
: connectionParams,
}));
}
catch (err) {
socket.close(4400, err instanceof Error ? err.message : new Error(err).message);
}
};
socket.onmessage = ({ data }) => {
socket.onmessage = null; // interested only in the first message
try {
const message = message_1.parseMessage(data);
if (message.type !== message_1.MessageType.ConnectionAck) {
throw new Error(`First message cannot be of type ${message.type}`);
}
emitter.emit('connected', socket, message.payload); // connected = socket opened + acknowledged
retries = 0; // reset the retries on connect
resolve(socket);
}
catch (err) {
socket.close(4400, err instanceof Error ? err.message : new Error(err).message);
}
};
})())));
let release = () => {
// releases this connection lock
};
const released = new Promise((resolve) => (release = resolve));
return [
socket,
release,
Promise.race([
released.then(() => {
if (--locks === 0) {
// if no more connection locks are present, complete the connection
const complete = () => socket.close(1000, 'Normal Closure');
if (isFinite(keepAlive) && keepAlive > 0) {
// if the keepalive is set, allow for the specified calmdown time and
// then complete. but only if no lock got created in the meantime and
// if the socket is still open
setTimeout(() => {
if (!locks && socket.readyState === WebSocketImpl.OPEN)
complete();
}, keepAlive);
}
else {
// otherwise complete immediately
complete();
}
}
}),
new Promise((_resolve, reject) => socket.addEventListener('close', reject, { once: true })),
]),
];
}
/**
* Checks the `connect` problem and evaluates if the client should retry.
*/
function shouldRetryConnectOrThrow(errOrCloseEvent) {
// some close codes are worth reporting immediately
if (isLikeCloseEvent(errOrCloseEvent) &&
[
1002,
1011,
4400,
4401,
4409,
4429,
].includes(errOrCloseEvent.code)) {
throw errOrCloseEvent;
}
// disposed or normal closure (completed), shouldnt try again
if (disposed ||
(isLikeCloseEvent(errOrCloseEvent) && errOrCloseEvent.code === 1000)) {
return false;
}
// retries are not allowed or we tried to many times, report error
if (!retryAttempts || retries >= retryAttempts) {
throw errOrCloseEvent;
}
// throw fatal connection problems immediately
if (isFatalConnectionProblem(errOrCloseEvent)) {
throw errOrCloseEvent;
}
// looks good, start retrying
retrying = true;
return true;
}
// in non-lazy (hot?) mode always hold one connection lock to persist the socket
if (!lazy) {
(async () => {
for (;;) {
try {
const [, , waitForReleaseOrThrowOnClose] = await connect();
await waitForReleaseOrThrowOnClose;
return; // completed, shouldnt try again
}
catch (errOrCloseEvent) {
try {
if (!shouldRetryConnectOrThrow(errOrCloseEvent))
return onNonLazyError === null || onNonLazyError === void 0 ? void 0 : onNonLazyError(errOrCloseEvent);
}
catch (_a) {
// report thrown error, no further retries
return onNonLazyError === null || onNonLazyError === void 0 ? void 0 : onNonLazyError(errOrCloseEvent);
}
}
}
})();
}
// to avoid parsing the same message in each
// subscriber, we memo one on the last received data
let lastData, lastMessage;
function memoParseMessage(data) {
if (data !== lastData) {
lastMessage = message_1.parseMessage(data);
lastData = data;
}
return lastMessage;
}
return {
on: emitter.on,
subscribe(payload, sink) {
const id = generateID();
let completed = false;
const releaserRef = {
current: () => {
// for handling completions before connect
completed = true;
},
};
function messageHandler({ data }) {
const message = memoParseMessage(data);
switch (message.type) {
case message_1.MessageType.Next: {
if (message.id === id) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sink.next(message.payload);
}
return;
}
case message_1.MessageType.Error: {
if (message.id === id) {
completed = true;
sink.error(message.payload);
releaserRef.current();
// TODO-db-201025 calling releaser will complete the sink, meaning that both the `error` and `complete` will be
// called. neither promises or observables care; once they settle, additional calls to the resolvers will be ignored
}
return;
}
case message_1.MessageType.Complete: {
if (message.id === id) {
completed = true;
releaserRef.current(); // release completes the sink
}
return;
}
}
}
(async () => {
for (;;) {
try {
const [socket, release, waitForReleaseOrThrowOnClose,] = await connect();
// if completed while waiting for connect, release the connection lock right away
if (completed)
return release();
socket.addEventListener('message', messageHandler);
socket.send(message_1.stringifyMessage({
id: id,
type: message_1.MessageType.Subscribe,
payload,
}));
releaserRef.current = () => {
if (!completed && socket.readyState === WebSocketImpl.OPEN) {
// if not completed already and socket is open, send complete message to server on release
socket.send(message_1.stringifyMessage({
id: id,
type: message_1.MessageType.Complete,
}));
}
release();
};
// either the releaser will be called, connection completed and
// the promise resolved or the socket closed and the promise rejected
await waitForReleaseOrThrowOnClose;
socket.removeEventListener('message', messageHandler);
return; // completed, shouldnt try again
}
catch (errOrCloseEvent) {
if (!shouldRetryConnectOrThrow(errOrCloseEvent))
return;
}
}
})()
.catch(sink.error)
.then(sink.complete); // resolves on release or normal closure
return () => releaserRef.current();
},
async dispose() {
disposed = true;
if (connecting) {
// if there is a connection, close it
const socket = await connecting;
socket.close(1000, 'Normal Closure');
}
},
};
}
exports.createClient = createClient;
function isLikeCloseEvent(val) {
return utils_1.isObject(val) && 'code' in val && 'reason' in val;
}
function isWebSocket(val) {
return (typeof val === 'function' &&
'constructor' in val &&
'CLOSED' in val &&
'CLOSING' in val &&
'CONNECTING' in val &&
'OPEN' in val);
}