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
JavaScript
/**
*
* 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);
})();
}