UNPKG

grafserv

Version:

A highly optimized server for GraphQL, powered by Grafast

690 lines 28.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.convertErrorToErrorResult = exports.GrafservBase = void 0; exports.convertHandlerResultToResult = convertHandlerResultToResult; const tslib_1 = require("tslib"); const eventemitter3_1 = tslib_1.__importDefault(require("eventemitter3")); const grafast_1 = require("grafast"); const graphql = tslib_1.__importStar(require("grafast/graphql")); const graphile_config_1 = require("graphile-config"); const hooks_js_1 = require("../hooks.js"); const mapIterator_js_1 = require("../mapIterator.js"); const graphiql_js_1 = require("../middleware/graphiql.js"); const graphql_js_1 = require("../middleware/graphql.js"); const options_js_1 = require("../options.js"); const utils_js_1 = require("../utils.js"); const buffer404 = Buffer.from(`<!doctype html><html><head><title>Not found</title></head><body><h1>Not found</h1><p>Please try again with a different URL</p></body></html>`, "utf8"); const buffer503 = Buffer.from("Service unavailable", "utf8"); const failedToBuildHandlersError = new graphql.GraphQLError("Unknown error occurred."); const { isSchema, validateSchema } = graphql; class GrafservBase { getExecutionConfig(_ctx) { throw new Error("Overwritten in constructor"); } constructor(config) { this.releaseHandlers = []; this.releasing = false; this.initialized = false; this._settingPreset = false; this.waitForGraphqlHandler = function (...args) { const [request] = args; const deferred = (0, grafast_1.defer)(); const { dynamicOptions } = this; const onReady = () => { this.eventEmitter.off("dynamicOptions:ready", onReady); this.eventEmitter.off("dynamicOptions:error", onError); Promise.resolve() .then(() => this.graphqlHandler(...args)) .then(deferred.resolve, deferred.reject); }; const onError = (e) => { this.eventEmitter.off("dynamicOptions:ready", onReady); this.eventEmitter.off("dynamicOptions:error", onError); const graphqlError = new graphql.GraphQLError("Unknown error occurred", null, null, null, null, e); deferred.resolve({ type: "graphql", request, dynamicOptions, payload: { errors: [graphqlError] }, statusCode: graphqlError.extensions?.statusCode ?? 503, // Fall back to application/json; this is when an unexpected error happens // so it shouldn't be hit. contentType: graphql_js_1.APPLICATION_JSON, }); }; this.eventEmitter.on("dynamicOptions:ready", onReady); this.eventEmitter.on("dynamicOptions:error", onError); setTimeout(onError, 5000, new Error("Server initialization timed out")); return Promise.resolve(deferred); }; this.waitForGraphiqlHandler = function (...args) { const [request] = args; const { dynamicOptions } = this; const deferred = (0, grafast_1.defer)(); const onReady = () => { this.eventEmitter.off("dynamicOptions:ready", onReady); this.eventEmitter.off("dynamicOptions:error", onError); Promise.resolve() .then(() => this.graphiqlHandler(...args)) .then(deferred.resolve, deferred.reject); }; const onError = (e) => { this.eventEmitter.off("dynamicOptions:ready", onReady); this.eventEmitter.off("dynamicOptions:error", onError); const graphqlError = new graphql.GraphQLError("Unknown error occurred", null, null, null, null, e); // TODO: this should be an HTML response deferred.resolve({ type: "graphql", request, dynamicOptions, payload: { errors: [graphqlError] }, statusCode: graphqlError.extensions?.statusCode ?? 503, // Fall back to application/json; this is when an unexpected error happens // so it shouldn't be hit. contentType: graphql_js_1.APPLICATION_JSON, }); }; this.eventEmitter.on("dynamicOptions:ready", onReady); this.eventEmitter.on("dynamicOptions:error", onError); setTimeout(onError, 5000, new Error("Server initialization timed out")); return Promise.resolve(deferred); }; this.waitForGraphiqlStaticHandler = function (...args) { const [request] = args; const { dynamicOptions } = this; const deferred = (0, grafast_1.defer)(); const onReady = () => { this.eventEmitter.off("dynamicOptions:ready", onReady); this.eventEmitter.off("dynamicOptions:error", onError); Promise.resolve() .then(() => this.graphiqlStaticHandler(...args)) .then(deferred.resolve, deferred.reject); }; const onError = (e) => { this.eventEmitter.off("dynamicOptions:ready", onReady); this.eventEmitter.off("dynamicOptions:error", onError); deferred.resolve({ type: "raw", request, dynamicOptions, statusCode: e.statusCode ?? 503, headers: { "content-type": "text/plain" }, payload: Buffer.from("Service unavailable", "utf8"), }); }; this.eventEmitter.on("dynamicOptions:ready", onReady); this.eventEmitter.on("dynamicOptions:error", onError); setTimeout(onError, 5000, new Error("Server initialization timed out")); return Promise.resolve(deferred); }; this.failedGraphqlHandler = function (...args) { const [request] = args; const { dynamicOptions } = this; return { type: "graphql", request, dynamicOptions, payload: { errors: [failedToBuildHandlersError] }, statusCode: 503, // Fall back to application/json; this is when an unexpected error happens // so it shouldn't be hit. contentType: graphql_js_1.APPLICATION_JSON, }; }; this.failedGraphiqlHandler = function (...args) { const [request] = args; const { dynamicOptions } = this; return { type: "graphql", request, dynamicOptions, payload: { errors: [failedToBuildHandlersError] }, statusCode: 503, // Fall back to application/json; this is when an unexpected error happens // so it shouldn't be hit. contentType: graphql_js_1.APPLICATION_JSON, }; }; this.failedGraphiqlStaticHandler = function (...args) { const [request] = args; const { dynamicOptions } = this; return { type: "text", request, dynamicOptions, statusCode: 503, payload: buffer503, }; }; this.eventEmitter = new eventemitter3_1.default(); this.resolvedPreset = (0, graphile_config_1.resolvePreset)(config.preset ? config.preset : {}); this.dynamicOptions = { validationRules: [...graphql.specifiedRules], getExecutionConfig: defaultMakeGetExecutionConfig(), ...(0, options_js_1.optionsFromConfig)(this.resolvedPreset), }; this.getExecutionConfig = this.dynamicOptions.getExecutionConfig; this.middleware = (0, hooks_js_1.getGrafservMiddleware)(this.resolvedPreset); this.grafastMiddleware = (0, grafast_1.getGrafastMiddleware)(this.resolvedPreset); this.schemaError = null; this.schema = config.schema; if ((0, grafast_1.isPromiseLike)(config.schema)) { const promise = config.schema; promise.then((schema) => { this.setSchema(schema); }, (error) => { this.schemaError = promise; this.schema = null; this.eventEmitter.emit("schema:error", error); }); } else { this.eventEmitter.emit("schema:ready", config.schema); } // These are overwritten by setPreset() resolving this.graphqlHandler = this.waitForGraphqlHandler; this.graphiqlHandler = this.waitForGraphiqlHandler; this.graphiqlStaticHandler = this.waitForGraphiqlStaticHandler; this.setPreset(this.resolvedPreset); } /** @internal */ _processRequest(inRequest) { const request = (0, utils_js_1.normalizeRequest)(inRequest); if (!this.dynamicOptions) { throw new Error(`GrafservInternalError<1377f225-31b7-4a81-a56e-a28e18a19899>: Attempted to process request prematurely`); } const dynamicOptions = this.dynamicOptions; const forceCORS = !!this.resolvedPreset.grafserv?.dangerouslyAllowAllCORSRequests && request.method === "OPTIONS"; try { if (request.path === dynamicOptions.graphqlPath) { if (forceCORS) return optionsResponse(request, dynamicOptions); return this.graphqlHandler(request, this.graphiqlHandler); } if (dynamicOptions.graphiql && request.method === "GET" && request.path === dynamicOptions.graphiqlPath) { if (forceCORS) return optionsResponse(request, dynamicOptions); return this.graphiqlHandler(request); } if (dynamicOptions.watch && request.method === "GET" && request.path === dynamicOptions.eventStreamPath) { if (forceCORS) return optionsResponse(request, dynamicOptions); const stream = this.makeStream(); return { type: "event-stream", request, dynamicOptions, payload: stream, statusCode: 200, }; } if (dynamicOptions.graphiql && request.method === "GET" && request.path.startsWith(dynamicOptions.graphiqlStaticPath)) { if (forceCORS) return optionsResponse(request, dynamicOptions); return this.graphiqlStaticHandler(request); } // Unhandled return null; } catch (e) { console.error("Unexpected error occurred in _processRequest", e); return { type: "html", request, dynamicOptions, status: 500, payload: Buffer.from("ERROR", "utf8"), }; } } processRequest(requestDigest) { const { resolvedPreset } = this; const event = { resolvedPreset, requestDigest, instance: this, }; return this.middleware != null ? this.middleware.run("processRequest", event, processRequestWithEvent) : processRequestWithEvent(event); } getPreset() { return this.resolvedPreset; } getSchema() { return this.schema ?? this.schemaError; } async release() { if (this.releasing) { throw new Error("Release has already been called"); } this.releasing = true; for (let i = this.releaseHandlers.length - 1; i >= 0; i--) { const handler = this.releaseHandlers[i]; try { await handler(); } catch (e) { /* nom nom nom */ } } } onRelease(cb) { if (this.releasing) { throw new Error("Release has already been called; cannot add more onRelease callbacks"); } this.releaseHandlers.push(cb); } setPreset(newPreset) { if (this._settingPreset) { throw new Error(`Setting a preset is currently in progress; please wait for it to complete.`); } this._settingPreset = true; const resolvedPreset = (0, graphile_config_1.resolvePreset)(newPreset); const middleware = (0, hooks_js_1.getGrafservMiddleware)(this.resolvedPreset); const grafastMiddleware = (0, grafast_1.getGrafastMiddleware)(this.resolvedPreset); // Note: this gets directly mutated const dynamicOptions = { validationRules: [...graphql.specifiedRules], getExecutionConfig: defaultMakeGetExecutionConfig(), ...(0, options_js_1.optionsFromConfig)(resolvedPreset), }; const storeDynamicOptions = (dynamicOptions) => { const { resolvedPreset } = dynamicOptions; // Overwrite all the `this.*` properties at once this.resolvedPreset = resolvedPreset; this.middleware = middleware; this.grafastMiddleware = grafastMiddleware; this.dynamicOptions = dynamicOptions; this.initialized = true; // ENHANCE: this.graphqlHandler?.release()? this.refreshHandlers(); this.getExecutionConfig = dynamicOptions.getExecutionConfig; // MUST come after the handlers have been refreshed, otherwise we'll // get infinite loops this.eventEmitter.emit("dynamicOptions:ready", {}); }; return (new Promise((resolve) => resolve(middleware != null ? middleware.run("setPreset", dynamicOptions, storeDynamicOptions) : storeDynamicOptions(dynamicOptions))) .then(null, (e) => { this.graphqlHandler = this.failedGraphqlHandler; this.graphiqlHandler = this.failedGraphiqlHandler; this.graphiqlStaticHandler = this.failedGraphiqlStaticHandler; this.eventEmitter.emit("dynamicOptions:error", e); }) // Finally: .then(() => { this._settingPreset = false; })); } setSchema(newSchema) { if (!newSchema) { throw new Error(`setSchema must be called with a GraphQL schema`); } if (!isSchema(newSchema)) { throw new Error(`setParams called with invalid schema (is there more than one 'graphql' module loaded?)`); } const errors = validateSchema(newSchema); if (errors.length > 0) { throw new Error(`setParams called with invalid schema; first error: ${errors[0]}`); } if (this.schema !== newSchema) { this.schemaError = null; this.schema = newSchema; this.eventEmitter.emit("schema:ready", newSchema); this.refreshHandlers(); } } refreshHandlers() { if (!this.initialized) { // This will be handled once `setPreset` completes return; } this.graphqlHandler = (0, graphql_js_1.makeGraphQLHandler)(this); this.graphiqlHandler = (0, graphiql_js_1.makeGraphiQLHandler)(this.resolvedPreset, this.middleware, this.dynamicOptions); this.graphiqlStaticHandler = (0, graphiql_js_1.makeGraphiQLStaticHandler)(this.resolvedPreset, this.middleware, this.dynamicOptions); } // TODO: Rename this, or make it a middleware, or something makeStream() { const queue = []; let finished = false; const bump = () => { const next = queue.shift(); if (next !== undefined) { next.resolve({ done: false, value: { event: "change", data: "schema" }, }); } }; const flushQueue = (e) => { const entries = queue.splice(0, queue.length); for (const entry of entries) { if (e != null) { entry.reject(e); } else { entry.resolve({ done: true }); } } }; this.eventEmitter.on("schema:ready", bump); return { [Symbol.asyncIterator]() { return this; }, next() { if (finished) { return Promise.resolve({ done: true, }); } return new Promise((resolve, reject) => { queue.push({ resolve, reject }); }); }, return() { finished = true; if (queue.length !== 0) { flushQueue(); } return Promise.resolve({ done: true, }); }, throw(e) { if (queue.length !== 0) { flushQueue(e); } return Promise.reject(e); }, }; } } exports.GrafservBase = GrafservBase; function defaultMakeGetExecutionConfig() { let latestSchema; let latestSchemaOrPromise; let latestParseAndValidate; let schemaPrepare = null; return function getExecutionConfig() { // Get up to date schema, in case we're in watch mode const schemaOrPromise = this.getSchema(); const { resolvedPreset, dynamicOptions } = this; if (schemaOrPromise !== latestSchemaOrPromise) { latestSchemaOrPromise = schemaOrPromise; if ("then" in schemaOrPromise) { schemaPrepare = (async () => { latestSchema = await schemaOrPromise; latestSchemaOrPromise = schemaOrPromise; latestParseAndValidate = (0, graphql_js_1.makeParseAndValidateFunction)(latestSchema, resolvedPreset, dynamicOptions); schemaPrepare = null; return true; })(); } else { if (latestSchema === schemaOrPromise) { // No action necessary } else { latestSchema = schemaOrPromise; latestParseAndValidate = (0, graphql_js_1.makeParseAndValidateFunction)(latestSchema, resolvedPreset, dynamicOptions); } } } if (schemaPrepare !== null) { const sleeper = (0, utils_js_1.sleep)(dynamicOptions.schemaWaitTime); const schemaReadyPromise = Promise.race([schemaPrepare, sleeper.promise]); return schemaReadyPromise.then((schemaReady) => { sleeper.release(); if (schemaReady !== true) { // Handle missing schema throw new Error(`Schema isn't ready`); } return { schema: latestSchema, parseAndValidate: latestParseAndValidate, resolvedPreset, execute: grafast_1.execute, subscribe: grafast_1.subscribe, contextValue: Object.create(null), }; }); } /* if (schemaOrPromise == null) { const err = Promise.reject( new GraphQLError( "The schema is currently unavailable", null, null, null, null, null, { statusCode: 503, }, ), ); return () => err; } */ return { schema: latestSchema, parseAndValidate: latestParseAndValidate, resolvedPreset, execute: grafast_1.execute, subscribe: grafast_1.subscribe, contextValue: Object.create(null), }; }; } const END = Buffer.from("\r\n-----\r\n", "utf8"); const DIVIDE = Buffer.from(`\r\n---\r\nContent-Type: application/json\r\n\r\n`, "utf8"); function eventStreamHeaders(dynamicOptions) { if (!dynamicOptions.watch) return null; return { ["x-graphql-event-stream"]: dynamicOptions.eventStreamPath }; } const CONTENT_TYPE_HEADERS = { raw: null, html: { "content-type": "text/html; charset=utf-8" }, text: { "content-type": "text/plain; charset=utf-8" }, }; function convertHandlerResultToResult(handlerResult) { if (handlerResult === null) { return null; } switch (handlerResult.type) { case "graphql": { const { payload, statusCode = 200, contentType, outputDataAsString, dynamicOptions, request: { preferJSON }, } = handlerResult; (0, utils_js_1.handleErrors)(payload); const headers = { __proto__: null, ...eventStreamHeaders(dynamicOptions), "content-type": contentType, ...handlerResult.headers, }; if (preferJSON && !outputDataAsString) { return { type: "json", statusCode, headers, json: payload }; } else { const buffer = Buffer.from((0, grafast_1.stringifyPayload)(payload, outputDataAsString), "utf8"); headers["content-length"] = String(buffer.length); return { type: "buffer", statusCode, headers, buffer }; } } case "graphqlIncremental": { const { iterator, statusCode = 200, outputDataAsString, dynamicOptions, } = handlerResult; const headers = { __proto__: null, ...eventStreamHeaders(dynamicOptions), "content-type": 'multipart/mixed; boundary="-"', "transfer-encoding": "chunked", ...handlerResult.headers, }; const bufferIterator = (0, mapIterator_js_1.mapIterator)(iterator, (payload) => { (0, utils_js_1.handleErrors)(payload); const payloadBuffer = Buffer.from((0, grafast_1.stringifyPayload)(payload, outputDataAsString), "utf8"); return Buffer.concat([DIVIDE, payloadBuffer]); }, () => { return END; }); return { type: "bufferStream", headers, statusCode, lowLatency: true, bufferIterator, }; } case "text": case "html": case "raw": { const { payload: buffer, statusCode = 200 } = handlerResult; const headers = { __proto__: null, ...CONTENT_TYPE_HEADERS[handlerResult.type], "content-length": String(buffer.length), ...handlerResult.headers, }; return { type: "buffer", statusCode, headers, buffer }; } case "noContent": { const { statusCode = 204 } = handlerResult; const headers = { __proto__: null, ...handlerResult.headers, }; return { type: "noContent", statusCode, headers }; } case "notFound": { const { statusCode = 404, payload: buffer = buffer404 } = handlerResult; const headers = { __proto__: null, ...CONTENT_TYPE_HEADERS.html, ...handlerResult.headers, }; return { type: "buffer", statusCode, headers, buffer }; } case "event-stream": { const { payload: stream, statusCode = 200, request: { httpVersionMajor }, } = handlerResult; // Making sure these options are set. // Set headers for Server-Sent Events. const headers = { __proto__: null, // Don't buffer EventStream in nginx "x-accel-buffering": "no", "content-type": "text/event-stream", "cache-control": "no-cache, no-transform", ...(httpVersionMajor >= 2 ? null : { connection: "keep-alive" }), ...handlerResult.headers, }; // Creates a stream for the response const event2buffer = (event) => { let payload = ""; if (event.event !== undefined) { payload += `event: ${event.event}\n`; } if (event.id !== undefined) { payload += `id: ${event.id}\n`; } if (event.retry !== undefined) { payload += `retry: ${event.retry}\n`; } if (event.data != null) { payload += `data: ${event.data.replace(/\n/g, "\ndata: ")}\n`; } payload += "\n"; return Buffer.from(payload, "utf8"); }; const bufferIterator = (0, mapIterator_js_1.mapIterator)(stream, event2buffer, undefined, () => event2buffer({ event: "open" })); return { type: "bufferStream", statusCode, headers, lowLatency: true, bufferIterator, }; } default: { const never = handlerResult; console.error(`Did not understand '${never}' passed to convertHandlerResultToResult`); const statusCode = 500; const buffer = Buffer.from("Unexpected input to convertHandlerResultToResult", "utf8"); const headers = { __proto__: null, "content-type": "text/plain; charset=utf-8", "content-length": String(buffer.length), }; return { type: "buffer", statusCode, headers, buffer }; } } } const convertErrorToErrorResult = (error) => { // TODO: need to assert `error` is not a GraphQLError, that should be handled elsewhere. const statusCode = error.statusCode ?? 500; return { type: "error", statusCode, headers: Object.create(null), error, }; }; exports.convertErrorToErrorResult = convertErrorToErrorResult; function dangerousCorsWrap(result) { if (result === null) { return result; } if (!result.headers["access-control-allow-origin"]) { result.headers["access-control-allow-origin"] = "*"; } if (!result.headers["access-control-allow-headers"]) { result.headers["access-control-allow-headers"] = "*"; } return result; } function optionsResponse(request, dynamicOptions) { return { type: "noContent", request, dynamicOptions, statusCode: 204, }; } function processRequestWithEvent(event) { const { requestDigest: request, instance } = event; let returnValue; try { const result = instance._processRequest(request); if ((0, grafast_1.isPromiseLike)(result)) { returnValue = result.then(convertHandlerResultToResult, exports.convertErrorToErrorResult); } else { returnValue = convertHandlerResultToResult(result); } } catch (e) { returnValue = (0, exports.convertErrorToErrorResult)(e); } if (instance.resolvedPreset.grafserv?.dangerouslyAllowAllCORSRequests) { if ((0, grafast_1.isPromiseLike)(returnValue)) { return returnValue.then(dangerousCorsWrap); } else { return dangerousCorsWrap(returnValue); } } else { return returnValue; } } //# sourceMappingURL=base.js.map