graphql-sse
Version:
Zero-dependency, HTTP/1 safe, simple, GraphQL over Server-Sent Events Protocol server and client
666 lines (665 loc) • 26.8 kB
JavaScript
;
/**
*
* handler
*
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.isExecutionResult = exports.createHandler = void 0;
const graphql_1 = require("graphql");
const utils_1 = require("./utils");
const common_1 = require("./common");
/**
* 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
*/
function createHandler(options) {
const { validate = graphql_1.validate, execute = graphql_1.execute, subscribe = graphql_1.subscribe, schema, authenticate = function extractOrCreateStreamToken(req) {
var _a;
const headerToken = req.headers.get(common_1.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(common_1.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 ((0, common_1.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 ((0, common_1.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((0, common_1.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((0, common_1.print)({
event: 'next',
data: opId
? {
id: opId,
payload: result,
}
: result,
}));
}
msgs.next((0, common_1.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) ||
(0, common_1.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 = (0, graphql_1.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 = (0, graphql_1.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 ((0, common_1.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 ((0, common_1.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 ((0, common_1.isAsyncGenerator)(result))
result.return(undefined);
if (!(opId in stream.ops)) {
return [
null,
{
status: 204,
statusText: 'No Content',
},
];
}
}
if ((0, common_1.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' }];
};
}
exports.createHandler = createHandler;
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 (!(0, utils_1.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);
}
function isExecutionResult(val) {
return ((0, utils_1.isObject)(val) &&
('data' in val || ('data' in val && val.data == null && 'errors' in val)));
}
exports.isExecutionResult = isExecutionResult;