graphql-yoga
Version:
<div align="center"><img src="./website/public/cover.png" width="720" /></div>
386 lines (385 loc) • 16.6 kB
JavaScript
/* eslint-disable @typescript-eslint/no-explicit-any */
import { envelop, useEngine, useExtendContext, useMaskedErrors, } from '@envelop/core';
import { useValidationCache } from '@envelop/validation-cache';
import { normalizedExecutor } from '@graphql-tools/executor';
import * as defaultFetchAPI from '@whatwg-node/fetch';
import { createServerAdapter } from '@whatwg-node/server';
import { parse, specifiedRules, validate } from 'graphql';
import { handleError } from './error.js';
import { createLogger } from './logger.js';
import { isGETRequest, parseGETRequest } from './plugins/requestParser/GET.js';
import { isPOSTFormUrlEncodedRequest, parsePOSTFormUrlEncodedRequest, } from './plugins/requestParser/POSTFormUrlEncoded.js';
import { isPOSTGraphQLStringRequest, parsePOSTGraphQLStringRequest, } from './plugins/requestParser/POSTGraphQLString.js';
import { isPOSTJsonRequest, parsePOSTJsonRequest, } from './plugins/requestParser/POSTJson.js';
import { isPOSTMultipartRequest, parsePOSTMultipartRequest, } from './plugins/requestParser/POSTMultipart.js';
import { useCheckGraphQLQueryParams } from './plugins/requestValidation/useCheckGraphQLQueryParams.js';
import { useCheckMethodForGraphQL } from './plugins/requestValidation/useCheckMethodForGraphQL.js';
import { useHTTPValidationError } from './plugins/requestValidation/useHTTPValidationError.js';
import { useLimitBatching } from './plugins/requestValidation/useLimitBatching.js';
import { usePreventMutationViaGET } from './plugins/requestValidation/usePreventMutationViaGET.js';
import { useCORS } from './plugins/useCORS.js';
import { useGraphiQL, } from './plugins/useGraphiQL.js';
import { useHealthCheck } from './plugins/useHealthCheck.js';
import { useParserAndValidationCache, } from './plugins/useParserAndValidationCache.js';
import { useRequestParser } from './plugins/useRequestParser.js';
import { useResultProcessors } from './plugins/useResultProcessor.js';
import { useSchema } from './plugins/useSchema.js';
import { useUnhandledRoute } from './plugins/useUnhandledRoute.js';
import { processRequest as processGraphQLParams, processResult, } from './process-request.js';
import { maskError } from './utils/mask-error.js';
/**
* Base class that can be extended to create a GraphQL server with any HTTP server framework.
* @internal
*/
export class YogaServer {
constructor(options) {
this.handle = async (request, serverContext) => {
try {
const response = await this.getResponse(request, serverContext);
for (const onResponseHook of this.onResponseHooks) {
await onResponseHook({
request,
response,
serverContext,
});
}
return response;
}
catch (e) {
this.logger.error(e);
return new this.fetchAPI.Response('Internal Server Error', {
status: 500,
});
}
};
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];
}
}
}
const logger = options?.logging == null ? true : options.logging;
this.logger =
typeof logger === 'boolean'
? logger === true
? createLogger()
: createLogger('silent')
: typeof logger === 'string'
? createLogger(logger)
: logger;
const maskErrorFn = (typeof options?.maskedErrors === 'object' &&
options.maskedErrors.maskError) ||
maskError;
const maskedErrorSet = new WeakSet();
this.maskedErrorsOpts =
options?.maskedErrors === false
? null
: {
errorMessage: 'Unexpected error.',
...(typeof options?.maskedErrors === 'object'
? options.maskedErrors
: {}),
maskError: (error, message) => {
if (maskedErrorSet.has(error)) {
return error;
}
const newError = maskErrorFn(error, message, this.maskedErrorsOpts?.isDev);
if (newError !== error) {
this.logger.error(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 graphqlEndpoint = this.graphqlEndpoint;
this.plugins = [
useEngine({
parse,
validate,
execute: normalizedExecutor,
subscribe: normalizedExecutor,
specifiedRules,
}),
// Use the schema provided by the user
!!options?.schema && useSchema(options.schema),
options?.context != null &&
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
useHealthCheck({
id: this.id,
logger: this.logger,
endpoint: options?.healthCheckEndpoint,
}),
options?.cors !== false && useCORS(options?.cors),
options?.graphiql !== false &&
useGraphiQL({
graphqlEndpoint,
options: options?.graphiql,
render: options?.renderGraphiQL,
logger: this.logger,
}),
// Middlewares before the GraphQL execution
useRequestParser({
match: isGETRequest,
parse: parseGETRequest,
}),
useRequestParser({
match: isPOSTJsonRequest,
parse: parsePOSTJsonRequest,
}),
options?.multipart !== false &&
useRequestParser({
match: isPOSTMultipartRequest,
parse: parsePOSTMultipartRequest,
}),
useRequestParser({
match: isPOSTGraphQLStringRequest,
parse: parsePOSTGraphQLStringRequest,
}),
useRequestParser({
match: isPOSTFormUrlEncodedRequest,
parse: parsePOSTFormUrlEncodedRequest,
}),
// Middlewares after the GraphQL execution
useResultProcessors(),
...(options?.plugins ?? []),
// To make sure those are called at the end
{
onPluginInit({ addPlugin }) {
// Performance things
if (options?.parserCache !== false) {
const parserAndValidationCacheOptions = {};
if (typeof options?.parserCache === 'object') {
parserAndValidationCacheOptions.documentCache =
options.parserCache.documentCache;
parserAndValidationCacheOptions.errorCache =
options.parserCache.errorCache;
}
if (options?.validationCache === false) {
parserAndValidationCacheOptions.validationCache = false;
}
else if (typeof options?.validationCache === 'object') {
// TODO: Remove this in the next major version
// Backward compatibility for the old API
parserAndValidationCacheOptions.validationCache = false;
addPlugin(
// @ts-expect-error Add plugins has context but this hook doesn't care
useValidationCache({
cache: options.validationCache,
}));
}
addPlugin(
// @ts-expect-error Add plugins has context but this hook doesn't care
useParserAndValidationCache(parserAndValidationCacheOptions));
}
// @ts-expect-error Add plugins has context but this hook doesn't care
addPlugin(useLimitBatching(batchingLimit));
// @ts-expect-error Add plugins has context but this hook doesn't care
addPlugin(useCheckGraphQLQueryParams());
addPlugin(
// @ts-expect-error Add plugins has context but this hook doesn't care
useUnhandledRoute({
graphqlEndpoint,
showLandingPage: options?.landingPage ?? true,
}));
// We check the method after user-land plugins because the plugin might support more methods (like graphql-sse).
// @ts-expect-error Add plugins has context but this hook doesn't care
addPlugin(useCheckMethodForGraphQL());
// We make sure that the user doesn't send a mutation with GET
// @ts-expect-error Add plugins has context but this hook doesn't care
addPlugin(usePreventMutationViaGET());
if (maskedErrors) {
addPlugin(useMaskedErrors(maskedErrors));
}
addPlugin(
// We handle validation errors at the end
useHTTPValidationError());
},
},
];
this.getEnveloped = envelop({
plugins: this.plugins,
});
this.plugins = this.getEnveloped._plugins;
this.onRequestHooks = [];
this.onRequestParseHooks = [];
this.onParamsHooks = [];
this.onResultProcessHooks = [];
this.onResponseHooks = [];
for (const plugin of this.plugins) {
if (plugin) {
if (plugin.onYogaInit) {
plugin.onYogaInit({
yoga: this,
});
}
if (plugin.onRequest) {
this.onRequestHooks.push(plugin.onRequest);
}
if (plugin.onRequestParse) {
this.onRequestParseHooks.push(plugin.onRequestParse);
}
if (plugin.onParams) {
this.onParamsHooks.push(plugin.onParams);
}
if (plugin.onResultProcess) {
this.onResultProcessHooks.push(plugin.onResultProcess);
}
if (plugin.onResponse) {
this.onResponseHooks.push(plugin.onResponse);
}
}
}
}
async getResultForParams({ params, request, },
// eslint-disable-next-line @typescript-eslint/ban-types
...args) {
try {
let result;
for (const onParamsHook of this.onParamsHooks) {
await onParamsHook({
params,
request,
setParams(newParams) {
params = newParams;
},
setResult(newResult) {
result = newResult;
},
fetchAPI: this.fetchAPI,
});
}
if (result == null) {
const serverContext = args[0];
const initialContext = {
...serverContext,
request,
params,
};
const enveloped = this.getEnveloped(initialContext);
this.logger.debug(`Processing GraphQL Parameters`);
result = await processGraphQLParams({
params,
enveloped,
});
this.logger.debug(`Processing GraphQL Parameters done.`);
}
return result;
}
catch (error) {
const errors = handleError(error, this.maskedErrorsOpts, this.logger);
const result = {
errors,
};
return result;
}
}
async getResponse(request, serverContext) {
let result;
try {
let url = new Proxy({}, {
get: (_target, prop, _receiver) => {
url = new this.fetchAPI.URL(request.url, 'http://localhost');
return Reflect.get(url, prop, url);
},
});
for (const onRequestHook of this.onRequestHooks) {
let response;
await onRequestHook({
request,
serverContext,
fetchAPI: this.fetchAPI,
url,
endResponse(newResponse) {
response = newResponse;
},
});
if (response) {
return response;
}
}
let requestParser;
const onRequestParseDoneList = [];
for (const onRequestParse of this.onRequestParseHooks) {
const onRequestParseResult = await onRequestParse({
request,
url,
requestParser,
serverContext,
setRequestParser(parser) {
requestParser = parser;
},
});
if (onRequestParseResult?.onRequestParseDone != null) {
onRequestParseDoneList.push(onRequestParseResult.onRequestParseDone);
}
}
this.logger.debug(`Parsing request to extract GraphQL parameters`);
if (!requestParser) {
return new this.fetchAPI.Response(null, {
status: 415,
statusText: 'Unsupported Media Type',
});
}
let requestParserResult = await requestParser(request);
for (const onRequestParseDone of onRequestParseDoneList) {
await onRequestParseDone({
requestParserResult,
setRequestParserResult(newParams) {
requestParserResult = newParams;
},
});
}
result = (await (Array.isArray(requestParserResult)
? Promise.all(requestParserResult.map((params) => this.getResultForParams({
params,
request,
}, serverContext)))
: this.getResultForParams({
params: requestParserResult,
request,
}, serverContext)));
}
catch (error) {
const errors = handleError(error, this.maskedErrorsOpts, this.logger);
result = {
errors,
};
}
const response = await processResult({
request,
result,
fetchAPI: this.fetchAPI,
onResultProcessHooks: this.onResultProcessHooks,
});
return response;
}
}
export function createYoga(options) {
const server = new YogaServer(options);
return createServerAdapter(server, server.fetchAPI.Request);
}