graphql-yoga
Version:
528 lines (527 loc) • 25.9 kB
JavaScript
"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,
});
}