UNPKG

graphql-yoga

Version:
528 lines (527 loc) • 25.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.YogaServer = void 0; exports.createYoga = createYoga; const tslib_1 = require("tslib"); const graphql_1 = require("graphql"); const core_1 = require("@envelop/core"); const instrumentation_1 = require("@envelop/instrumentation"); const executor_1 = require("@graphql-tools/executor"); const defaultFetchAPI = tslib_1.__importStar(require("@whatwg-node/fetch")); const promise_helpers_1 = require("@whatwg-node/promise-helpers"); const server_1 = require("@whatwg-node/server"); const error_js_1 = require("./error.js"); const logger_js_1 = require("./logger.js"); const allowed_headers_js_1 = require("./plugins/allowed-headers.js"); const get_js_1 = require("./plugins/request-parser/get.js"); const post_form_url_encoded_js_1 = require("./plugins/request-parser/post-form-url-encoded.js"); const post_graphql_string_js_1 = require("./plugins/request-parser/post-graphql-string.js"); const post_json_js_1 = require("./plugins/request-parser/post-json.js"); const post_multipart_js_1 = require("./plugins/request-parser/post-multipart.js"); const use_check_graphql_query_params_js_1 = require("./plugins/request-validation/use-check-graphql-query-params.js"); const use_check_method_for_graphql_js_1 = require("./plugins/request-validation/use-check-method-for-graphql.js"); const use_http_validation_error_js_1 = require("./plugins/request-validation/use-http-validation-error.js"); const use_limit_batching_js_1 = require("./plugins/request-validation/use-limit-batching.js"); const use_limit_request_body_size_js_1 = require("./plugins/request-validation/use-limit-request-body-size.js"); const use_prevent_mutation_via_get_js_1 = require("./plugins/request-validation/use-prevent-mutation-via-get.js"); const use_config_in_server_context_js_1 = require("./plugins/use-config-in-server-context.js"); const use_graphiql_js_1 = require("./plugins/use-graphiql.js"); const use_health_check_js_1 = require("./plugins/use-health-check.js"); const use_parser_and_validation_cache_js_1 = require("./plugins/use-parser-and-validation-cache.js"); const use_request_id_js_1 = require("./plugins/use-request-id.js"); const use_request_parser_js_1 = require("./plugins/use-request-parser.js"); const use_result_processor_js_1 = require("./plugins/use-result-processor.js"); const use_schema_js_1 = require("./plugins/use-schema.js"); const use_unhandled_route_js_1 = require("./plugins/use-unhandled-route.js"); const process_request_js_1 = require("./process-request.js"); const is_response_js_1 = require("./utils/is-response.js"); const mask_error_js_1 = require("./utils/mask-error.js"); /** * Counts the GraphQL errors carried by a result, for the per-request summary log. * Returns `undefined` for a streaming result (subscription/SSE), whose errors (if any) surface * progressively while the response streams rather than being known up front. */ function countErrors(result) { if ((0, core_1.isAsyncIterable)(result)) { return undefined; } const results = Array.isArray(result) ? result : [result]; return results.reduce((count, result) => count + (result.errors?.length ?? 0), 0); } /** * Base class that can be extended to create a GraphQL server with any HTTP server framework. * @internal */ class YogaServer { /** * Instance of envelop */ getEnveloped; log; fetchAPI; plugins; instrumentation; onRequestParseHooks; onParamsHooks; onExecutionResultHooks; onResultProcessHooks; maskedErrorsOpts; id; /** * Operation type resolved for a given request, read back for the per-request summary log. */ operationTypeByContext = new WeakMap(); // @ts-expect-error - This is set by `this.graphqlEndpoint` setter in the constructor, but TypeScript doesn't recognize it. _graphqlEndpoint; _graphqlEndpointURLPattern; version = '6.0.1'; constructor(options) { this.id = options?.id ?? 'yoga'; this.fetchAPI = { ...defaultFetchAPI, }; if (options?.fetchAPI) { for (const key in options.fetchAPI) { if (options.fetchAPI[key]) { this.fetchAPI[key] = options.fetchAPI[key]; } } } this.log = (0, logger_js_1.createLoggerFromLogging)(options?.logging); const configContext = { log: this.log }; const maskErrorFn = (typeof options?.maskedErrors === 'object' && options.maskedErrors.maskError) || mask_error_js_1.maskError; const maskedErrorSet = new WeakSet(); this.maskedErrorsOpts = options?.maskedErrors === false ? null : { errorMessage: 'Unexpected error.', ...(typeof options?.maskedErrors === 'object' ? options.maskedErrors : {}), // `context` is the request's context, to correlate this error with the request that // caused it (and give the user's own `maskError`, if any, more to work with than // just a logger). Passed explicitly by `handleError` (error.ts) for the HTTP path; // for subscription/streaming errors, `useMaskedErrors` calls this via the adapter // below, which gets it from the GraphQL context it's given. maskError: (error, message, _isDev, context) => { if (maskedErrorSet.has(error)) { return error; } const newError = maskErrorFn(error, message, this.maskedErrorsOpts?.isDev, context); if (newError !== error) { (context?.log ?? this.log).error({ err: error }); } maskedErrorSet.add(newError); return newError; }, }; const maskedErrors = this.maskedErrorsOpts == null ? null : this.maskedErrorsOpts; let batchingLimit = 0; if (options?.batching) { if (typeof options.batching === 'boolean') { batchingLimit = 10; } else { batchingLimit = options.batching.limit ?? 10; } } this.graphqlEndpoint = options?.graphqlEndpoint || '/graphql'; const requestIdEnabled = options?.requestId !== false; const requestIdOpts = typeof options?.requestId === 'object' ? options.requestId : undefined; const requestIdHeaderName = (0, use_request_id_js_1.getRequestIdHeaderName)(requestIdOpts); this.plugins = [ (0, core_1.useEngine)({ parse: graphql_1.parse, validate: graphql_1.validate, execute: executor_1.normalizedExecutor, subscribe: executor_1.normalizedExecutor, specifiedRules: graphql_1.specifiedRules, }), // Make the config (and therefore the logger) available in the server context of every // request. Registered first so that everything after it can rely on `log` being there. (0, use_config_in_server_context_js_1.useConfigInServerContext)({ configContext }), // Use the schema provided by the user !!options?.schema && (0, use_schema_js_1.useSchema)(options.schema), options?.allowedHeaders?.request != null && (0, allowed_headers_js_1.useAllowedRequestHeaders)(options.allowedHeaders.request), // Scope the logger of each request to its request id. Registered after the request headers // have been filtered, so that a request id is only taken from an allowed header, but before // the plugins that log while short-circuiting a request (health checks, GraphiQL). requestIdEnabled && (0, use_request_id_js_1.useRequestId)(requestIdOpts), options?.context != null && (0, core_1.useExtendContext)(initialContext => { if (options?.context) { if (typeof options.context === 'function') { return options.context(initialContext); } return options.context; } return {}; }), // Middlewares before processing the incoming HTTP request (0, use_health_check_js_1.useHealthCheck)({ id: this.id, endpoint: options?.healthCheckEndpoint, }), options?.cors !== false && (0, server_1.useCORS)(options?.cors), options?.graphiql !== false && (0, use_graphiql_js_1.useGraphiQL)({ getGraphQLEndpoint: () => this._graphqlEndpoint, getGraphQLEndpointURLPattern: () => this.getUrlPatternForGraphQLEndpoint(), options: options?.graphiql, render: options?.renderGraphiQL, }), // Middlewares before the GraphQL execution (0, use_request_parser_js_1.useRequestParser)({ match: get_js_1.isGETRequest, parse: get_js_1.parseGETRequest, }), (0, use_request_parser_js_1.useRequestParser)({ match: post_json_js_1.isPOSTJsonRequest, parse: post_json_js_1.parsePOSTJsonRequest, }), options?.multipart !== false && (0, use_request_parser_js_1.useRequestParser)({ match: post_multipart_js_1.isPOSTMultipartRequest, parse: post_multipart_js_1.parsePOSTMultipartRequest, }), (0, use_request_parser_js_1.useRequestParser)({ match: post_graphql_string_js_1.isPOSTGraphQLStringRequest, parse: post_graphql_string_js_1.parsePOSTGraphQLStringRequest, }), (0, use_request_parser_js_1.useRequestParser)({ match: post_form_url_encoded_js_1.isPOSTFormUrlEncodedRequest, parse: post_form_url_encoded_js_1.parsePOSTFormUrlEncodedRequest, }), // Middlewares after the GraphQL execution (0, use_result_processor_js_1.useResultProcessors)(), ...(options?.plugins ?? []), // Must run after the request parsers above (including any registered by user plugins) // so it wraps whichever parser ends up selected. options?.maxRequestBodySize !== false && (0, use_limit_request_body_size_js_1.useLimitRequestBodySize)(options?.maxRequestBodySize ?? 25_000_000), options?.parserAndValidationCache !== false && (0, use_parser_and_validation_cache_js_1.useParserAndValidationCache)(!options?.parserAndValidationCache || options?.parserAndValidationCache === true ? {} : options?.parserAndValidationCache), (0, use_limit_batching_js_1.useLimitBatching)(batchingLimit), (0, use_check_graphql_query_params_js_1.useCheckGraphQLQueryParams)(options?.extraParamNames), (0, use_unhandled_route_js_1.useUnhandledRoute)({ getGraphQLEndpoint: () => this.graphqlEndpoint, getGraphQLEndpointURLPattern: () => this.getUrlPatternForGraphQLEndpoint(), showLandingPage: options?.landingPage !== false, landingPageRenderer: typeof options?.landingPage === 'function' ? options.landingPage : undefined, }), // We check the method after user-land plugins because the plugin might support more methods (like graphql-sse). (0, use_check_method_for_graphql_js_1.useCheckMethodForGraphQL)(), // We make sure that the user doesn't send a mutation with GET (0, use_prevent_mutation_via_get_js_1.usePreventMutationViaGET)(), // Always throw AbortError instead of masking it. maskedErrors !== null && { onSubscribe: () => ({ onSubscribeError({ error }) { if ((0, error_js_1.isAbortError)(error)) { throw error; } }, }), }, maskedErrors !== null && (0, core_1.useMaskedErrors)({ errorMessage: maskedErrors.errorMessage, // `useMaskedErrors` gives us the GraphQL context here; forward it to the real // `maskError` (defined above) as an explicit argument. maskError: (error, message, context) => maskedErrors.maskError(error, message, undefined, context), }), options?.allowedHeaders?.response != null && // Registered last (after CORS and user plugins) so it sees every header anything else // added. (0, allowed_headers_js_1.useAllowedResponseHeaders)(requestIdEnabled ? [...options.allowedHeaders.response, requestIdHeaderName] : options.allowedHeaders.response), // We handle validation errors at the end (0, use_http_validation_error_js_1.useHTTPValidationError)(), ]; this.getEnveloped = (0, core_1.envelop)({ plugins: this.plugins, }); this.plugins = this.getEnveloped._plugins; this.onRequestParseHooks = []; this.onParamsHooks = []; this.onExecutionResultHooks = []; this.onResultProcessHooks = []; for (const plugin of this.plugins) { if (plugin) { if (plugin.onYogaInit) { plugin.onYogaInit({ yoga: this, }); } if (plugin.onRequestParse) { this.onRequestParseHooks.push(plugin.onRequestParse); } if (plugin.onParams) { this.onParamsHooks.push(plugin.onParams); } if (plugin.onExecutionResult) { this.onExecutionResultHooks.push(plugin.onExecutionResult); } if (plugin.onResultProcess) { this.onResultProcessHooks.push(plugin.onResultProcess); } if (plugin.instrumentation) { this.instrumentation = this.instrumentation ? (0, instrumentation_1.chain)(this.instrumentation, plugin.instrumentation) : plugin.instrumentation; } } } } get graphqlEndpoint() { return this._graphqlEndpoint; } set graphqlEndpoint(endpoint) { this._graphqlEndpoint = endpoint; this._graphqlEndpointURLPattern = undefined; } getUrlPatternForGraphQLEndpoint() { this._graphqlEndpointURLPattern ??= new this.fetchAPI.URLPattern({ pathname: this._graphqlEndpoint, }); return this._graphqlEndpointURLPattern; } handleParams = ({ request, context, params }) => { const additionalContext = context['request'] === request ? { params, } : { request, params, }; Object.assign(context, additionalContext); const enveloped = this.getEnveloped(context); const log = context.log; log.debug(() => ({ path: new this.fetchAPI.URL(request.url, 'http://localhost').pathname, operationName: params.operationName, variablesCount: params.variables ? Object.keys(params.variables).length : 0, }), 'Processing GraphQL Parameters'); return (0, promise_helpers_1.handleMaybePromise)(() => (0, promise_helpers_1.handleMaybePromise)(() => (0, process_request_js_1.processRequest)({ params, enveloped, request, log, onOperationType: operationType => this.operationTypeByContext.set(context, operationType), }), result => { log.debug(`Processing GraphQL Parameters done.`); return result; }, error => { const errors = (0, error_js_1.handleError)(error, this.maskedErrorsOpts, context); return { errors, }; }), result => { if ((0, core_1.isAsyncIterable)(result)) { result = (0, promise_helpers_1.mapAsyncIterator)(result, v => v, (error) => { if (error.name === 'AbortError') { log.debug(`Request aborted`); throw error; } const errors = (0, error_js_1.handleError)(error, this.maskedErrorsOpts, context); return { errors, }; }); } return result; }); }; getResultForParams = ({ params, request, }, context, fetchAPI = this.fetchAPI) => { let result; let paramsHandler = this.handleParams; return (0, promise_helpers_1.handleMaybePromise)(() => (0, promise_helpers_1.iterateAsync)(this.onParamsHooks, onParamsHook => onParamsHook({ params, request, setParams(newParams) { params = newParams; }, paramsHandler, setParamsHandler(newHandler) { paramsHandler = newHandler; }, setResult(newResult) { result = newResult; }, fetchAPI, context, })), () => (0, promise_helpers_1.handleMaybePromise)(() => result || paramsHandler({ request, params, context: context, }), result => (0, promise_helpers_1.handleMaybePromise)(() => (0, promise_helpers_1.iterateAsync)(this.onExecutionResultHooks, onExecutionResult => onExecutionResult({ result, setResult(newResult) { result = newResult; }, request, context: context, })), () => result))); }; parseRequest = (request, serverContext, fetchAPI = this.fetchAPI) => { let url = new Proxy({}, { get: (_target, prop, _receiver) => { url = new fetchAPI.URL(request.url, 'http://localhost'); return Reflect.get(url, prop, url); }, }); let requestParser; let response; const onRequestParseDoneList = []; return (0, promise_helpers_1.handleMaybePromise)(() => (0, promise_helpers_1.iterateAsync)(this.onRequestParseHooks, (onRequestParse, endEarly) => (0, promise_helpers_1.handleMaybePromise)(() => onRequestParse({ request, url, requestParser, serverContext, setRequestParser(parser) { requestParser = parser; }, // Short-circuit the request parsing if a response is sent in `onRequestParse` endResponse(res) { response = res; endEarly(); }, fetchAPI, }), requestParseHookResult => requestParseHookResult?.onRequestParseDone), onRequestParseDoneList), () => { if (response) { return { response }; } serverContext.log.debug(`Parsing request to extract GraphQL parameters`); if (!requestParser) { return { response: new fetchAPI.Response(null, { status: 415, statusText: 'Unsupported Media Type', }), }; } return (0, promise_helpers_1.handleMaybePromise)(() => requestParser(request), requestParserFnResult => { if ((0, is_response_js_1.isResponse)(requestParserFnResult)) { return { response: requestParserFnResult, }; } let requestParserResult = requestParserFnResult; return (0, promise_helpers_1.handleMaybePromise)(() => (0, promise_helpers_1.iterateAsyncVoid)(onRequestParseDoneList, onRequestParseDone => onRequestParseDone({ requestParserResult, setRequestParserResult(newParams) { requestParserResult = newParams; }, })), () => ({ requestParserResult, })); }); }); }; handle = (request, adapterServerContext, fetchAPI = this.fetchAPI) => { // `useConfigInServerContext` and `useRequestId` have put the request-scoped logger in the // server context by now, so everything logged for this request is correlated by its id const serverContext = adapterServerContext; const log = serverContext.log; const start = performance.now(); const instrumented = this.instrumentation && (0, instrumentation_1.getInstrumented)({ request }); const parseRequest = this.instrumentation?.requestParse ? instrumented.asyncFn(this.instrumentation?.requestParse, this.parseRequest) : this.parseRequest; let requestParserResult; let errorCount; return (0, promise_helpers_1.unfakePromise)((0, promise_helpers_1.fakePromise)() .then(() => parseRequest(request, serverContext, fetchAPI)) .then(parseResult => { requestParserResult = parseResult.requestParserResult; const { response, requestParserResult: parserResult } = parseResult; if (response) { return response; } const getResultForParams = this.instrumentation?.operation ? (payload, context, fetchAPI) => { const instrumented = (0, instrumentation_1.getInstrumented)({ context, request: payload.request }); const tracedHandler = instrumented.asyncFn(this.instrumentation?.operation, this.getResultForParams); return tracedHandler(payload, context, fetchAPI); } : this.getResultForParams; return (0, promise_helpers_1.handleMaybePromise)(() => (Array.isArray(parserResult) ? Promise.all(parserResult.map(params => { const context = Object.create(serverContext); return ((0, promise_helpers_1.fakePromise)() .then(() => getResultForParams({ params, request }, context, fetchAPI)) // eslint-disable-next-line promise/no-nesting .catch(error => { const errors = (0, error_js_1.handleError)(error, this.maskedErrorsOpts, context); return { errors, }; })); })) : getResultForParams({ params: parserResult, request, }, serverContext, fetchAPI)), result => { errorCount = countErrors(result); const tracedProcessResult = this.instrumentation?.resultProcess ? instrumented.asyncFn(this.instrumentation.resultProcess, process_request_js_1.processResult) : process_request_js_1.processResult; return tracedProcessResult({ request, result, fetchAPI, onResultProcessHooks: this.onResultProcessHooks, serverContext, }); }); }) .catch(error => { const errors = (0, error_js_1.handleError)(error, this.maskedErrorsOpts, serverContext); errorCount = errors.length; const result = { errors, }; return (0, process_request_js_1.processResult)({ request, result, fetchAPI, onResultProcessHooks: this.onResultProcessHooks, serverContext, }); }) .then(response => { log.info(() => ({ method: request.method, path: new fetchAPI.URL(request.url, 'http://localhost').pathname, operationName: Array.isArray(requestParserResult) ? undefined : requestParserResult?.operationName, operationType: Array.isArray(requestParserResult) ? undefined : requestParserResult && this.operationTypeByContext.get(serverContext), batchedOperations: Array.isArray(requestParserResult) ? requestParserResult.length : undefined, status: response.status, errorCount, durationMs: Math.round(performance.now() - start), }), 'Request processed'); return response; })); }; } exports.YogaServer = YogaServer; function createYoga(options) { const server = new YogaServer(options); return (0, server_1.createServerAdapter)(server, { fetchAPI: server.fetchAPI, // the plugins expect `log` in the server context, which `useConfigInServerContext` puts there first plugins: server['plugins'], disposeOnProcessTerminate: options.disposeOnProcessTerminate, }); }