UNPKG

graphql-sse

Version:

Zero-dependency, HTTP/1 safe, simple, GraphQL over Server-Sent Events Protocol server and client

651 lines (650 loc) 28.6 kB
/** * * client * */ import { createParser } from './parser.mjs'; import { isObject } from './utils.mjs'; import { TOKEN_HEADER_KEY, } from './common.mjs'; /** This file is the entry point for browsers, re-export common elements. */ export * from './common.mjs'; /** * Creates a disposable GraphQL over SSE client to transmit * GraphQL operation results. * * If you have an HTTP/2 server, it is recommended to use the client * in "distinct connections mode" (`singleConnection = false`) which will * create a new SSE connection for each subscribe. This is the default. * * However, when dealing with HTTP/1 servers from a browser, consider using * the "single connection mode" (`singleConnection = true`) which will * use only one SSE connection. * * @category Client */ export function createClient(options) { const { singleConnection = false, lazy = true, lazyCloseTimeout = 0, onNonLazyError = console.error, /** * 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://gist.github.com/jed/982883 */ 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); }); }, retryAttempts = 5, retry = 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))); }, credentials = 'same-origin', referrer, referrerPolicy, onMessage, on: clientOn, } = options; const fetchFn = (options.fetchFn || fetch); const AbortControllerImpl = (options.abortControllerImpl || AbortController); // we dont use yet another AbortController here because of // node's max EventEmitters listeners being only 10 const client = (() => { let disposed = false; const listeners = []; return { get disposed() { return disposed; }, onDispose(cb) { if (disposed) { // empty the call stack and then call the cb setTimeout(() => cb(), 0); return () => { // noop }; } listeners.push(cb); return () => { listeners.splice(listeners.indexOf(cb), 1); }; }, dispose() { if (disposed) return; disposed = true; // we copy the listeners so that onDispose unlistens dont "pull the rug under our feet" for (const listener of [...listeners]) { listener(); } }, }; })(); let connCtrl, conn, locks = 0, retryingErr = null, retries = 0; async function getOrConnect() { try { if (client.disposed) throw new Error('Client has been disposed'); return await (conn !== null && conn !== void 0 ? conn : (conn = (async () => { var _a, _b, _c; if (retryingErr) { await retry(retries); // connection might've been aborted while waiting for retry if (connCtrl.signal.aborted) throw new Error('Connection aborted by the client'); retries++; } (_a = clientOn === null || clientOn === void 0 ? void 0 : clientOn.connecting) === null || _a === void 0 ? void 0 : _a.call(clientOn, !!retryingErr); // we must create a new controller here because lazy mode aborts currently active ones connCtrl = new AbortControllerImpl(); const unlistenDispose = client.onDispose(() => connCtrl.abort()); connCtrl.signal.addEventListener('abort', () => { unlistenDispose(); conn = undefined; }); const url = typeof options.url === 'function' ? await options.url() : options.url; if (connCtrl.signal.aborted) throw new Error('Connection aborted by the client'); const headers = typeof options.headers === 'function' ? await options.headers() : (_b = options.headers) !== null && _b !== void 0 ? _b : {}; if (connCtrl.signal.aborted) throw new Error('Connection aborted by the client'); let res; try { res = await fetchFn(url, { signal: connCtrl.signal, method: 'PUT', credentials, referrer, referrerPolicy, headers, }); } catch (err) { throw new NetworkError(err); } if (res.status !== 201) throw new NetworkError(res); const token = await res.text(); headers[TOKEN_HEADER_KEY] = token; const connected = await connect({ signal: connCtrl.signal, headers, credentials, referrer, referrerPolicy, url, fetchFn, onMessage: (msg) => { var _a; (_a = clientOn === null || clientOn === void 0 ? void 0 : clientOn.message) === null || _a === void 0 ? void 0 : _a.call(clientOn, msg); onMessage === null || onMessage === void 0 ? void 0 : onMessage(msg); // @deprecated }, }); (_c = clientOn === null || clientOn === void 0 ? void 0 : clientOn.connected) === null || _c === void 0 ? void 0 : _c.call(clientOn, !!retryingErr); connected.waitForThrow().catch(() => (conn = undefined)); return connected; })())); } catch (err) { // whatever problem happens during connect means the connection was not established conn = undefined; throw err; } } // non-lazy mode always holds one lock to persist the connection if (singleConnection && !lazy) { (async () => { locks++; for (;;) { try { const { waitForThrow } = await getOrConnect(); await waitForThrow(); } catch (err) { if (client.disposed) return; // all non-network errors are worth reporting immediately if (!(err instanceof NetworkError)) return onNonLazyError === null || onNonLazyError === void 0 ? void 0 : onNonLazyError(err); // was a network error, get rid of the current connection to ensure retries conn = undefined; // retries are not allowed or we tried to many times, report error if (!retryAttempts || retries >= retryAttempts) return onNonLazyError === null || onNonLazyError === void 0 ? void 0 : onNonLazyError(err); // try again retryingErr = err; } } })(); } function subscribe(request, sink, on) { if (!singleConnection) { // distinct connections mode const control = new AbortControllerImpl(); const unlisten = client.onDispose(() => { unlisten(); control.abort(); }); (async () => { var _a, _b, _c, _d, _e; let retryingErr = null, retries = 0; for (;;) { try { if (retryingErr) { await retry(retries); // connection might've been aborted while waiting for retry if (control.signal.aborted) throw new Error('Connection aborted by the client'); retries++; } (_a = clientOn === null || clientOn === void 0 ? void 0 : clientOn.connecting) === null || _a === void 0 ? void 0 : _a.call(clientOn, !!retryingErr); (_b = on === null || on === void 0 ? void 0 : on.connecting) === null || _b === void 0 ? void 0 : _b.call(on, !!retryingErr); const url = typeof options.url === 'function' ? await options.url() : options.url; if (control.signal.aborted) throw new Error('Connection aborted by the client'); const headers = typeof options.headers === 'function' ? await options.headers() : (_c = options.headers) !== null && _c !== void 0 ? _c : {}; if (control.signal.aborted) throw new Error('Connection aborted by the client'); const { getResults } = await connect({ signal: control.signal, headers: { ...headers, 'content-type': 'application/json; charset=utf-8', }, credentials, referrer, referrerPolicy, url, body: JSON.stringify(request), fetchFn, onMessage: (msg) => { var _a, _b; (_a = clientOn === null || clientOn === void 0 ? void 0 : clientOn.message) === null || _a === void 0 ? void 0 : _a.call(clientOn, msg); (_b = on === null || on === void 0 ? void 0 : on.message) === null || _b === void 0 ? void 0 : _b.call(on, msg); onMessage === null || onMessage === void 0 ? void 0 : onMessage(msg); // @deprecated }, }); (_d = clientOn === null || clientOn === void 0 ? void 0 : clientOn.connected) === null || _d === void 0 ? void 0 : _d.call(clientOn, !!retryingErr); (_e = on === null || on === void 0 ? void 0 : on.connected) === null || _e === void 0 ? void 0 : _e.call(on, !!retryingErr); for await (const result of getResults()) { // only after receiving results are future connects not considered retries. // this is because a client might successfully connect, but the server // ends up terminating the connection afterwards before streaming anything. // of course, if the client completes the subscription, this loop will // break and therefore stop the stream (it wont reconnect) retryingErr = null; retries = 0; // eslint-disable-next-line @typescript-eslint/no-explicit-any sink.next(result); } return control.abort(); } catch (err) { if (control.signal.aborted) return; // all non-network errors are worth reporting immediately if (!(err instanceof NetworkError)) throw err; // retries are not allowed or we tried to many times, report error if (!retryAttempts || retries >= retryAttempts) throw err; // try again retryingErr = err; } } })() .then(() => sink.complete()) .catch((err) => sink.error(err)); return () => control.abort(); } // single connection mode locks++; const control = new AbortControllerImpl(); const unlisten = client.onDispose(() => { unlisten(); control.abort(); }); (async () => { const operationId = generateID(); request = { ...request, extensions: { ...request.extensions, operationId }, }; let complete = null; for (;;) { complete = null; try { const { url, headers, getResults } = await getOrConnect(); let res; try { res = await fetchFn(url, { signal: control.signal, method: 'POST', credentials, referrer, referrerPolicy, headers: { ...headers, 'content-type': 'application/json; charset=utf-8', }, body: JSON.stringify(request), }); } catch (err) { throw new NetworkError(err); } if (res.status !== 202) throw new NetworkError(res); complete = async () => { let res; try { const control = new AbortControllerImpl(); const unlisten = client.onDispose(() => { unlisten(); control.abort(); }); res = await fetchFn(url + '?operationId=' + operationId, { signal: control.signal, method: 'DELETE', credentials, referrer, referrerPolicy, headers, }); } catch (err) { throw new NetworkError(err); } if (res.status !== 200) throw new NetworkError(res); }; for await (const result of getResults({ signal: control.signal, operationId, })) { // only after receiving results are future connects not considered retries. // this is because a client might successfully connect, but the server // ends up terminating the connection afterwards before streaming anything. // of course, if the client completes the subscription, this loop will // break and therefore stop the stream (it wont reconnect) retryingErr = null; retries = 0; // eslint-disable-next-line @typescript-eslint/no-explicit-any sink.next(result); } complete = null; // completed by the server return control.abort(); } catch (err) { if (control.signal.aborted) return await (complete === null || complete === void 0 ? void 0 : complete()); // all non-network errors are worth reporting immediately if (!(err instanceof NetworkError)) { control.abort(); // TODO: tests for making sure the control's aborted throw err; } // was a network error, get rid of the current connection to ensure retries // but only if the client is running in lazy mode (otherwise the non-lazy lock will get rid of the connection) if (lazy) { conn = undefined; } // retries are not allowed or we tried to many times, report error if (!retryAttempts || retries >= retryAttempts) { control.abort(); // TODO: tests for making sure the control's aborted throw err; } // try again retryingErr = err; } finally { // release lock if subscription is aborted if (control.signal.aborted && --locks === 0) { if (isFinite(lazyCloseTimeout) && lazyCloseTimeout > 0) { // allow for the specified calmdown time and then close the // connection, only if no lock got created in the meantime and // if the connection is still open setTimeout(() => { if (!locks) connCtrl.abort(); }, lazyCloseTimeout); } else { // otherwise close immediately connCtrl.abort(); } } } } })() .then(() => sink.complete()) .catch((err) => sink.error(err)); return () => control.abort(); } return { subscribe, iterate(request, on) { const pending = []; const deferred = { done: false, error: null, resolve: () => { // noop }, }; const dispose = subscribe(request, { next(val) { // eslint-disable-next-line @typescript-eslint/no-explicit-any pending.push(val); deferred.resolve(); }, error(err) { deferred.done = true; deferred.error = err; deferred.resolve(); }, complete() { deferred.done = true; deferred.resolve(); }, }, on); const iterator = (async function* iterator() { for (;;) { if (!pending.length) { // only wait if there are no pending messages available await new Promise((resolve) => (deferred.resolve = resolve)); } // first flush while (pending.length) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion yield pending.shift(); } // then error if (deferred.error) { throw deferred.error; } // or complete if (deferred.done) { return; } } })(); iterator.throw = async (err) => { if (!deferred.done) { deferred.done = true; deferred.error = err; deferred.resolve(); } return { done: true, value: undefined }; }; iterator.return = async () => { dispose(); return { done: true, value: undefined }; }; return iterator; }, dispose() { client.dispose(); }, }; } /** * A network error caused by the client or an unexpected response from the server. * * Network errors are considered retryable, all others error types will be reported * immediately. * * To avoid bundling DOM typings (because the client can run in Node env too), * you should supply the `Response` generic depending on your Fetch implementation. * * @category Client */ export class NetworkError extends Error { constructor(msgOrErrOrResponse) { let message, response; if (isResponseLike(msgOrErrOrResponse)) { response = msgOrErrOrResponse; message = 'Server responded with ' + msgOrErrOrResponse.status + ': ' + msgOrErrOrResponse.statusText; } else if (msgOrErrOrResponse instanceof Error) message = msgOrErrOrResponse.message; else message = String(msgOrErrOrResponse); super(message); this.name = this.constructor.name; this.response = response; } } function isResponseLike(val) { return (isObject(val) && typeof val['ok'] === 'boolean' && typeof val['status'] === 'number' && typeof val['statusText'] === 'string'); } async function connect(options) { const { signal, url, credentials, headers, body, referrer, referrerPolicy, fetchFn, onMessage, } = options; const waiting = {}; const queue = {}; let res; try { res = await fetchFn(url, { signal, method: body ? 'POST' : 'GET', credentials, referrer, referrerPolicy, headers: { ...headers, accept: 'text/event-stream', }, body, }); } catch (err) { throw new NetworkError(err); } if (!res.ok) throw new NetworkError(res); if (!res.body) throw new Error('Missing response body'); let error = null; let waitingForThrow; (async () => { var _a; try { const parse = createParser(); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion for await (const chunk of toAsyncIterable(res.body)) { if (typeof chunk === 'string') throw (error = new Error(`Unexpected string chunk "${chunk}"`)); // set error as fatal indicator // read chunk and if messages are ready, yield them let msgs; try { msgs = parse(chunk); } catch (err) { throw (error = err); // set error as fatal indicator } if (!msgs) continue; for (const msg of msgs) { try { onMessage === null || onMessage === void 0 ? void 0 : onMessage(msg); } catch (err) { throw (error = err); // set error as fatal indicator } const operationId = msg.data && 'id' in msg.data ? msg.data.id // StreamDataForID : ''; // StreamData if (!(operationId in queue)) queue[operationId] = []; switch (msg.event) { case 'next': if (operationId) queue[operationId].push(msg.data.payload); else queue[operationId].push(msg.data); break; case 'complete': queue[operationId].push('complete'); break; default: throw (error = new Error(`Unexpected message event "${msg.event}"`)); // set error as fatal indicator } (_a = waiting[operationId]) === null || _a === void 0 ? void 0 : _a.proceed(); } } // some browsers (like Safari) closes the connection without errors even on abrupt server shutdowns, // we therefore make sure that no stream is active and waiting for results (not completed) if (Object.keys(waiting).length) { throw new Error('Connection closed while having active streams'); } } catch (err) { if (!error && Object.keys(waiting).length) { // we assume the error is most likely a NetworkError because there are listeners waiting for events. // additionally, the `error` is another indicator because we set it early if the error is considered fatal error = new NetworkError(err); } else { error = err; } waitingForThrow === null || waitingForThrow === void 0 ? void 0 : waitingForThrow(error); } finally { Object.values(waiting).forEach(({ proceed }) => proceed()); } })(); return { url, headers, waitForThrow: () => new Promise((_, reject) => { if (error) return reject(error); waitingForThrow = reject; }), async *getResults(options) { var _a; const { signal, operationId = '' } = options !== null && options !== void 0 ? options : {}; // operationId === '' ? StreamData : StreamDataForID try { for (;;) { while ((_a = queue[operationId]) === null || _a === void 0 ? void 0 : _a.length) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const result = queue[operationId].shift(); if (result === 'complete') return; yield result; } if (error) throw error; if (signal === null || signal === void 0 ? void 0 : signal.aborted) throw new Error('Getting results aborted by the client'); await new Promise((resolve) => { const proceed = () => { signal === null || signal === void 0 ? void 0 : signal.removeEventListener('abort', proceed); delete waiting[operationId]; resolve(); }; signal === null || signal === void 0 ? void 0 : signal.addEventListener('abort', proceed); waiting[operationId] = { proceed }; }); } } finally { delete queue[operationId]; } }, }; } /** Isomorphic ReadableStream to AsyncIterator converter. */ function toAsyncIterable(val) { // node stream is already async iterable if (typeof Object(val)[Symbol.asyncIterator] === 'function') { val = val; return val; } // convert web stream to async iterable return (async function* () { const reader = val.getReader(); let result; do { result = await reader.read(); if (result.value !== undefined) yield result.value; } while (!result.done); })(); }