UNPKG

graphql-sse

Version:

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

661 lines (660 loc) 26.5 kB
/** * * handler * */ import { getOperationAST, parse, validate as graphqlValidate, execute as graphqlExecute, subscribe as graphqlSubscribe, } from 'graphql'; import { isObject } from './utils.mjs'; import { TOKEN_HEADER_KEY, TOKEN_QUERY_KEY, print, isAsyncGenerator, isAsyncIterable, } from './common.mjs'; /** * Makes a Protocol compliant HTTP GraphQL server handler. The handler can * be used with your favourite server library. * * Read more about the Protocol in the PROTOCOL.md documentation file. * * @category Server */ export function createHandler(options) { const { validate = graphqlValidate, execute = graphqlExecute, subscribe = graphqlSubscribe, schema, authenticate = function extractOrCreateStreamToken(req) { var _a; const headerToken = req.headers.get(TOKEN_HEADER_KEY); if (headerToken) return Array.isArray(headerToken) ? headerToken.join('') : headerToken; const urlToken = new URL((_a = req.url) !== null && _a !== void 0 ? _a : '', 'http://localhost/').searchParams.get(TOKEN_QUERY_KEY); if (urlToken) return urlToken; 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); }); }, onConnect, context, onSubscribe, onOperation, onNext, onComplete, } = options; const streams = {}; function createStream(token) { const ops = {}; let pinger; const msgs = (() => { const pending = []; const deferred = { done: false, error: null, resolve: () => { // noop }, }; async function dispose() { clearInterval(pinger); // make room for another potential stream while this one is being disposed if (typeof token === 'string') delete streams[token]; // complete all operations and flush messages queue before ending the stream for (const op of Object.values(ops)) { if (isAsyncGenerator(op)) { await op.return(undefined); } } } 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(); await dispose(); } return { done: true, value: undefined }; }; iterator.return = async () => { if (!deferred.done) { deferred.done = true; deferred.resolve(); await dispose(); } return { done: true, value: undefined }; }; return { next(msg) { pending.push(msg); deferred.resolve(); }, iterator, }; })(); let subscribed = false; return { get open() { return subscribed; }, ops, subscribe() { subscribed = true; // write an empty message because some browsers (like Firefox and Safari) // dont accept the header flush msgs.next(':\n\n'); // ping client every 12 seconds to keep the connection alive pinger = setInterval(() => msgs.next(':\n\n'), 12000); return msgs.iterator; }, from(ctx, req, result, opId) { (async () => { if (isAsyncIterable(result)) { /** multiple emitted results */ for await (let part of result) { const maybeResult = await (onNext === null || onNext === void 0 ? void 0 : onNext(ctx, req, part)); if (maybeResult) part = maybeResult; msgs.next(print({ event: 'next', data: opId ? { id: opId, payload: part, } : part, })); } } else { /** single emitted result */ const maybeResult = await (onNext === null || onNext === void 0 ? void 0 : onNext(ctx, req, result)); if (maybeResult) result = maybeResult; msgs.next(print({ event: 'next', data: opId ? { id: opId, payload: result, } : result, })); } msgs.next(print({ event: 'complete', data: opId ? { id: opId } : null, })); await (onComplete === null || onComplete === void 0 ? void 0 : onComplete(ctx, req)); if (!opId) { // end on complete when no operation id is present // because distinct event streams are used for each operation await msgs.iterator.return(); } else { delete ops[opId]; } })().catch(msgs.iterator.throw); }, }; } async function prepare(req, params) { let args; const onSubscribeResult = await (onSubscribe === null || onSubscribe === void 0 ? void 0 : onSubscribe(req, params)); if (isResponse(onSubscribeResult)) return onSubscribeResult; else if (isExecutionResult(onSubscribeResult) || isAsyncIterable(onSubscribeResult)) return { // even if the result is already available, use // context because onNext and onComplete needs it ctx: (typeof context === 'function' ? await context(req, params) : context), perform() { return onSubscribeResult; }, }; else if (onSubscribeResult) args = onSubscribeResult; else { // you either provide a schema dynamically through // `onSubscribe` or you set one up during the server setup if (!schema) throw new Error('The GraphQL schema is not provided'); const { operationName, variables } = params; let query; try { query = parse(params.query); } catch (err) { return [ JSON.stringify({ errors: [ err instanceof Error ? { message: err.message, // TODO: stack might leak sensitive information // stack: err.stack, } : err, ], }), { status: 400, statusText: 'Bad Request', headers: { 'content-type': 'application/json; charset=utf-8' }, }, ]; } const argsWithoutSchema = { operationName, document: query, variableValues: variables, contextValue: (typeof context === 'function' ? await context(req, params) : context), }; args = { ...argsWithoutSchema, schema: typeof schema === 'function' ? await schema(req, argsWithoutSchema) : schema, }; } let operation; try { const ast = getOperationAST(args.document, args.operationName); if (!ast) throw null; operation = ast.operation; } catch { return [ JSON.stringify({ errors: [{ message: 'Unable to detect operation AST' }], }), { status: 400, statusText: 'Bad Request', headers: { 'content-type': 'application/json; charset=utf-8' }, }, ]; } // mutations cannot happen over GETs as per the spec // Read more: https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#get if (operation === 'mutation' && req.method === 'GET') { return [ JSON.stringify({ errors: [{ message: 'Cannot perform mutations over GET' }], }), { status: 405, statusText: 'Method Not Allowed', headers: { allow: 'POST', 'content-type': 'application/json; charset=utf-8', }, }, ]; } // we validate after injecting the context because the process of // reporting the validation errors might need the supplied context value const validationErrs = validate(args.schema, args.document); if (validationErrs.length) { if (req.headers.get('accept') === 'text/event-stream') { // accept the request and emit the validation error in event streams, // promoting graceful GraphQL error reporting // Read more: https://www.w3.org/TR/eventsource/#processing-model // Read more: https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#document-validation return { ctx: args.contextValue, perform() { return { errors: validationErrs }; }, }; } return [ JSON.stringify({ errors: validationErrs }), { status: 400, statusText: 'Bad Request', headers: { 'content-type': 'application/json; charset=utf-8' }, }, ]; } return { ctx: args.contextValue, async perform() { const result = await (operation === 'subscription' ? subscribe(args) : execute(args)); const maybeResult = await (onOperation === null || onOperation === void 0 ? void 0 : onOperation(args.contextValue, req, args, result)); if (maybeResult) return maybeResult; return result; }, }; } return async function handler(req) { var _a, _b, _c; const token = await authenticate(req); if (isResponse(token)) return token; // TODO: make accept detection more resilient const accept = req.headers.get('accept') || '*/*'; const stream = typeof token === 'string' ? streams[token] : null; if (accept === 'text/event-stream') { const maybeResponse = await (onConnect === null || onConnect === void 0 ? void 0 : onConnect(req)); if (isResponse(maybeResponse)) return maybeResponse; // if event stream is not registered, process it directly. // this means that distinct connections are used for graphql operations if (!stream) { const paramsOrResponse = await parseReq(req); if (isResponse(paramsOrResponse)) return paramsOrResponse; const params = paramsOrResponse; const distinctStream = createStream(null); // reserve space for the operation distinctStream.ops[''] = null; const prepared = await prepare(req, params); if (isResponse(prepared)) return prepared; const result = await prepared.perform(); if (isAsyncIterable(result)) distinctStream.ops[''] = result; distinctStream.from(prepared.ctx, req, result, null); return [ distinctStream.subscribe(), { status: 200, statusText: 'OK', headers: { connection: 'keep-alive', 'cache-control': 'no-cache', 'content-encoding': 'none', 'content-type': 'text/event-stream; charset=utf-8', }, }, ]; } // open stream cant exist, only one per token is allowed if (stream.open) { return [ JSON.stringify({ errors: [{ message: 'Stream already open' }] }), { status: 409, statusText: 'Conflict', headers: { 'content-type': 'application/json; charset=utf-8', }, }, ]; } return [ stream.subscribe(), { status: 200, statusText: 'OK', headers: { connection: 'keep-alive', 'cache-control': 'no-cache', 'content-encoding': 'none', 'content-type': 'text/event-stream; charset=utf-8', }, }, ]; } // if there us no token supplied, exclusively use the "distinct connection mode" if (typeof token !== 'string') { return [null, { status: 404, statusText: 'Not Found' }]; } // method PUT prepares a stream for future incoming connections if (req.method === 'PUT') { if (!['*/*', 'text/plain'].includes(accept)) { return [null, { status: 406, statusText: 'Not Acceptable' }]; } // streams mustnt exist if putting new one if (stream) { return [ JSON.stringify({ errors: [{ message: 'Stream already registered' }], }), { status: 409, statusText: 'Conflict', headers: { 'content-type': 'application/json; charset=utf-8', }, }, ]; } streams[token] = createStream(token); return [ token, { status: 201, statusText: 'Created', headers: { 'content-type': 'text/plain; charset=utf-8', }, }, ]; } else if (req.method === 'DELETE') { // method DELETE completes an existing operation streaming in streams // streams must exist when completing operations if (!stream) { return [ JSON.stringify({ errors: [{ message: 'Stream not found' }], }), { status: 404, statusText: 'Not Found', headers: { 'content-type': 'application/json; charset=utf-8', }, }, ]; } const opId = new URL((_a = req.url) !== null && _a !== void 0 ? _a : '', 'http://localhost/').searchParams.get('operationId'); if (!opId) { return [ JSON.stringify({ errors: [{ message: 'Operation ID is missing' }], }), { status: 400, statusText: 'Bad Request', headers: { 'content-type': 'application/json; charset=utf-8', }, }, ]; } const op = stream.ops[opId]; if (isAsyncGenerator(op)) op.return(undefined); delete stream.ops[opId]; // deleting the operation means no further activity should take place return [ null, { status: 200, statusText: 'OK', }, ]; } else if (req.method !== 'GET' && req.method !== 'POST') { // only POSTs and GETs are accepted at this point return [ null, { status: 405, statusText: 'Method Not Allowed', headers: { allow: 'GET, POST, PUT, DELETE', }, }, ]; } else if (!stream) { // for all other requests, streams must exist to attach the result onto return [ JSON.stringify({ errors: [{ message: 'Stream not found' }], }), { status: 404, statusText: 'Not Found', headers: { 'content-type': 'application/json; charset=utf-8', }, }, ]; } if (!['*/*', 'application/*', 'application/json'].includes(accept)) { return [ null, { status: 406, statusText: 'Not Acceptable', }, ]; } const paramsOrResponse = await parseReq(req); if (isResponse(paramsOrResponse)) return paramsOrResponse; const params = paramsOrResponse; const opId = String((_c = (_b = params.extensions) === null || _b === void 0 ? void 0 : _b.operationId) !== null && _c !== void 0 ? _c : ''); if (!opId) { return [ JSON.stringify({ errors: [{ message: 'Operation ID is missing' }], }), { status: 400, statusText: 'Bad Request', headers: { 'content-type': 'application/json; charset=utf-8', }, }, ]; } if (opId in stream.ops) { return [ JSON.stringify({ errors: [{ message: 'Operation with ID already exists' }], }), { status: 409, statusText: 'Conflict', headers: { 'content-type': 'application/json; charset=utf-8', }, }, ]; } // reserve space for the operation through ID stream.ops[opId] = null; const prepared = await prepare(req, params); if (isResponse(prepared)) return prepared; // operation might have completed before prepared if (!(opId in stream.ops)) { return [ null, { status: 204, statusText: 'No Content', }, ]; } const result = await prepared.perform(); // operation might have completed before performed if (!(opId in stream.ops)) { if (isAsyncGenerator(result)) result.return(undefined); if (!(opId in stream.ops)) { return [ null, { status: 204, statusText: 'No Content', }, ]; } } if (isAsyncIterable(result)) stream.ops[opId] = result; // streaming to an empty reservation is ok (will be flushed on connect) stream.from(prepared.ctx, req, result, opId); return [null, { status: 202, statusText: 'Accepted' }]; }; } async function parseReq(req) { var _a, _b, _c; const params = {}; try { switch (true) { case req.method === 'GET': { try { const [, search] = req.url.split('?'); const searchParams = new URLSearchParams(search); params.operationName = (_a = searchParams.get('operationName')) !== null && _a !== void 0 ? _a : undefined; params.query = (_b = searchParams.get('query')) !== null && _b !== void 0 ? _b : undefined; const variables = searchParams.get('variables'); if (variables) params.variables = JSON.parse(variables); const extensions = searchParams.get('extensions'); if (extensions) params.extensions = JSON.parse(extensions); } catch { throw new Error('Unparsable URL'); } break; } case req.method === 'POST' && ((_c = req.headers.get('content-type')) === null || _c === void 0 ? void 0 : _c.includes('application/json')): { if (!req.body) { throw new Error('Missing body'); } const body = typeof req.body === 'function' ? await req.body() : req.body; const data = typeof body === 'string' ? JSON.parse(body) : body; if (!isObject(data)) { throw new Error('JSON body must be an object'); } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Any is ok because values will be checked below. params.operationName = data.operationName; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Any is ok because values will be checked below. params.query = data.query; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Any is ok because values will be checked below. params.variables = data.variables; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Any is ok because values will be checked below. params.extensions = data.extensions; break; } default: return [ null, { status: 415, statusText: 'Unsupported Media Type', }, ]; } if (params.query == null) throw new Error('Missing query'); if (typeof params.query !== 'string') throw new Error('Invalid query'); if (params.variables != null && (typeof params.variables !== 'object' || Array.isArray(params.variables))) { throw new Error('Invalid variables'); } if (params.extensions != null && (typeof params.extensions !== 'object' || Array.isArray(params.extensions))) { throw new Error('Invalid extensions'); } // request parameters are checked and now complete return params; } catch (err) { return [ JSON.stringify({ errors: [ err instanceof Error ? { message: err.message, // TODO: stack might leak sensitive information // stack: err.stack, } : err, ], }), { status: 400, statusText: 'Bad Request', headers: { 'content-type': 'application/json; charset=utf-8' }, }, ]; } } function isResponse(val) { // TODO: comprehensive check return Array.isArray(val); } export function isExecutionResult(val) { return (isObject(val) && ('data' in val || ('data' in val && val.data == null && 'errors' in val))); }