diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e9303b1c24..5fbda2379ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The version headers in this history reflect the versions of Apollo Server itself > The changes noted within this `vNEXT` section have not been released yet. New PRs and commits which introduce changes should include an entry in this `vNEXT` section as part of their development. With few exceptions, the format of the entry should follow convention (i.e., prefix with package name, use markdown `backtick formatting` for package names and code, suffix with a link to the change-set à la `[PR #YYY](https://link/pull/YYY)`, etc.). When a release is being prepared, a new header will be (manually) created below and the appropriate changes within that release will be moved into the new section. - `apollo-server-core`: The `SIGINT` and `SIGTERM` signal handlers installed by default (when not disabled by `stopOnTerminationSignals: false`) now stay active (preventing process termination) while the server shuts down, instead of letting a second signal terminate the process. The handlers still re-signal the process after `this.stop()` concludes. Also, if `this.stop()` throws, the signal handlers will now log and exit 1 instead of throwing an uncaught exception. [Issue #4931](https://github.com/apollographql/apollo-server/issues/4931) +- `apollo-server-lambda`: (**UPDATE THIS MESSAGE BEFORE RELEASE**; we are not sure if this actually helps nodejs14 compatibility or if it's just a nice refactor.) Support the nodejs14 runtime by changing the handler to be an [async handler](https://docs.aws.amazon.com/lambda/latest/dg/nodejs-handler.html#nodejs-handler-async). (For backwards compatibility, if the handler receives a callback, it still acts like a non-async handler.) [Issue #1989](https://github.com/apollographql/apollo-server/issues/1989) [PR #5004](https://github.com/apollographql/apollo-server/pull/5004) ## v2.21.1 diff --git a/packages/apollo-server-lambda/src/ApolloServer.ts b/packages/apollo-server-lambda/src/ApolloServer.ts index 5fc01620558..1798da88b9b 100644 --- a/packages/apollo-server-lambda/src/ApolloServer.ts +++ b/packages/apollo-server-lambda/src/ApolloServer.ts @@ -1,6 +1,7 @@ import { APIGatewayProxyCallback, APIGatewayProxyEvent, + APIGatewayProxyResult, Context as LambdaContext, } from 'aws-lambda'; import { @@ -9,18 +10,15 @@ import { FileUploadOptions, ApolloServerBase, GraphQLOptions, + runHttpQuery, + HttpQueryError, } from 'apollo-server-core'; import { renderPlaygroundPage, RenderPageOptions as PlaygroundRenderPageOptions, } from '@apollographql/graphql-playground-html'; -import { - ServerResponse, - IncomingHttpHeaders, - IncomingMessage, -} from 'http'; +import { ServerResponse, IncomingHttpHeaders, IncomingMessage } from 'http'; -import { graphqlLambda } from './lambdaApollo'; import { Headers } from 'apollo-server-env'; import { Readable, Writable } from 'stream'; @@ -41,6 +39,49 @@ export class FileUploadRequest extends Readable { headers!: IncomingHttpHeaders; } +// Lambda has two ways of defining a handler: as an async Promise-returning +// function, and as a callback-invoking function. +// https://docs.aws.amazon.com/lambda/latest/dg/nodejs-handler.html The async +// variety was introduced with Lambda's Node 8 runtime. Apparently the +// callback-invoking variety was removed with their Node 14 runtime (their docs +// don't mention this anywhere but our users have reported this: +// https://github.com/apollographql/apollo-server/issues/1989#issuecomment-778982945). +// While AWS doesn't directly support pre-Node-8 runtimes any more, it's +// possible some users are using a Custom Runtime that still requires the Node 6 +// version, and Apollo Server still technically supports Node 6. So for now, we +// define an async handler and use this function to convert it to a function +// that can work either as an async or callback handler. +// +// (Apollo Server 3 will drop Node 6 support, at which point we should just make +// this package always return an async handler.) +function maybeCallbackify( + asyncHandler: ( + event: APIGatewayProxyEvent, + context: LambdaContext, + ) => Promise, +): ( + event: APIGatewayProxyEvent, + context: LambdaContext, + callback: APIGatewayProxyCallback | undefined, +) => void | Promise { + return ( + event: APIGatewayProxyEvent, + context: LambdaContext, + callback: APIGatewayProxyCallback | undefined, + ) => { + if (callback) { + context.callbackWaitsForEmptyEventLoop = false; + asyncHandler(event, context).then( + (r: APIGatewayProxyResult) => callback(null, r), + (e) => callback(e), + ); + return; + } else { + return asyncHandler(event, context); + } + }; +} + export class ApolloServer extends ApolloServerBase { protected serverlessFramework(): boolean { return true; @@ -61,7 +102,12 @@ export class ApolloServer extends ApolloServerBase { return super.graphQLServerOptions({ event, context }); } - public createHandler({ cors, onHealthCheck }: CreateHandlerOptions = { cors: undefined, onHealthCheck: undefined }) { + public createHandler( + { cors, onHealthCheck }: CreateHandlerOptions = { + cors: undefined, + onHealthCheck: undefined, + }, + ) { // We will kick off the `willStart` event once for the server, and then // await it before processing any requests by incorporating its `await` into // the GraphQLServerOptions function which is called before each request. @@ -111,179 +157,231 @@ export class ApolloServer extends ApolloServerBase { } } - return ( - event: APIGatewayProxyEvent, - context: LambdaContext, - callback: APIGatewayProxyCallback, - ) => { - // We re-load the headers into a Fetch API-compatible `Headers` - // interface within `graphqlLambda`, but we still need to respect the - // case-insensitivity within this logic here, so we'll need to do it - // twice since it's not accessible to us otherwise, right now. - const eventHeaders = new Headers(event.headers); + return maybeCallbackify( + async ( + event: APIGatewayProxyEvent, + context: LambdaContext, + ): Promise => { + const eventHeaders = new Headers(event.headers); - // Make a request-specific copy of the CORS headers, based on the server - // global CORS headers we've set above. - const requestCorsHeaders = new Headers(corsHeaders); + // Make a request-specific copy of the CORS headers, based on the server + // global CORS headers we've set above. + const requestCorsHeaders = new Headers(corsHeaders); - if (cors && cors.origin) { - const requestOrigin = eventHeaders.get('origin'); - if (typeof cors.origin === 'string') { - requestCorsHeaders.set('access-control-allow-origin', cors.origin); - } else if ( - requestOrigin && - (typeof cors.origin === 'boolean' || - (Array.isArray(cors.origin) && - requestOrigin && - cors.origin.includes(requestOrigin))) - ) { - requestCorsHeaders.set('access-control-allow-origin', requestOrigin); - } + if (cors && cors.origin) { + const requestOrigin = eventHeaders.get('origin'); + if (typeof cors.origin === 'string') { + requestCorsHeaders.set('access-control-allow-origin', cors.origin); + } else if ( + requestOrigin && + (typeof cors.origin === 'boolean' || + (Array.isArray(cors.origin) && + requestOrigin && + cors.origin.includes(requestOrigin))) + ) { + requestCorsHeaders.set( + 'access-control-allow-origin', + requestOrigin, + ); + } - const requestAccessControlRequestHeaders = eventHeaders.get( - 'access-control-request-headers', - ); - if (!cors.allowedHeaders && requestAccessControlRequestHeaders) { - requestCorsHeaders.set( - 'access-control-allow-headers', - requestAccessControlRequestHeaders, + const requestAccessControlRequestHeaders = eventHeaders.get( + 'access-control-request-headers', ); + if (!cors.allowedHeaders && requestAccessControlRequestHeaders) { + requestCorsHeaders.set( + 'access-control-allow-headers', + requestAccessControlRequestHeaders, + ); + } } - } - // Convert the `Headers` into an object which can be spread into the - // various headers objects below. - // Note: while Object.fromEntries simplifies this code, it's only currently - // supported in Node 12 (we support >=6) - const requestCorsHeadersObject = Array.from(requestCorsHeaders).reduce< - Record - >((headersObject, [key, value]) => { - headersObject[key] = value; - return headersObject; - }, {}); + // Convert the `Headers` into an object which can be spread into the + // various headers objects below. + // Note: while Object.fromEntries simplifies this code, it's only currently + // supported in Node 12 (we support >=6) + const requestCorsHeadersObject = Array.from(requestCorsHeaders).reduce< + Record + >((headersObject, [key, value]) => { + headersObject[key] = value; + return headersObject; + }, {}); - if (event.httpMethod === 'OPTIONS') { - context.callbackWaitsForEmptyEventLoop = false; - return callback(null, { - body: '', - statusCode: 204, - headers: { - ...requestCorsHeadersObject, - }, - }); - } + if (event.httpMethod === 'OPTIONS') { + return { + body: '', + statusCode: 204, + headers: { + ...requestCorsHeadersObject, + }, + }; + } - if (event.path.endsWith('/.well-known/apollo/server-health')) { - const successfulResponse = { - body: JSON.stringify({ status: 'pass' }), - statusCode: 200, - headers: { - 'Content-Type': 'application/json', - ...requestCorsHeadersObject, - }, - }; - if (onHealthCheck) { - onHealthCheck(event) - .then(() => { - return callback(null, successfulResponse); - }) - .catch(() => { - return callback(null, { + if (event.path.endsWith('/.well-known/apollo/server-health')) { + if (onHealthCheck) { + try { + await onHealthCheck(event); + } catch (_) { + return { body: JSON.stringify({ status: 'fail' }), statusCode: 503, headers: { 'Content-Type': 'application/json', ...requestCorsHeadersObject, }, - }); - }); - return; - } else { - return callback(null, successfulResponse); - } - } - - if (this.playgroundOptions && event.httpMethod === 'GET') { - const acceptHeader = event.headers['Accept'] || event.headers['accept']; - if (acceptHeader && acceptHeader.includes('text/html')) { - const path = - event.path || - (event.requestContext && event.requestContext.path) || - '/'; - - const playgroundRenderPageOptions: PlaygroundRenderPageOptions = { - endpoint: path, - ...this.playgroundOptions, - }; - - return callback(null, { - body: renderPlaygroundPage(playgroundRenderPageOptions), + }; + } + } + return { + body: JSON.stringify({ status: 'pass' }), statusCode: 200, headers: { - 'Content-Type': 'text/html', + 'Content-Type': 'application/json', ...requestCorsHeadersObject, }, - }); + }; } - } - const response = new Writable() as ServerResponse; - const callbackFilter: APIGatewayProxyCallback = (error, result) => { - response.end(); - callback( - error, - result && { - ...result, - headers: { - ...result.headers, - ...requestCorsHeadersObject, - }, - }, - ); - }; + if (this.playgroundOptions && event.httpMethod === 'GET') { + const acceptHeader = + event.headers['Accept'] || event.headers['accept']; + if (acceptHeader && acceptHeader.includes('text/html')) { + const path = + event.path || + (event.requestContext && event.requestContext.path) || + '/'; - const fileUploadHandler = (next: Function) => { + const playgroundRenderPageOptions: PlaygroundRenderPageOptions = { + endpoint: path, + ...this.playgroundOptions, + }; + + return { + body: renderPlaygroundPage(playgroundRenderPageOptions), + statusCode: 200, + headers: { + 'Content-Type': 'text/html', + ...requestCorsHeadersObject, + }, + }; + } + } + + // graphql-upload uses this response purely as a way of knowing when to + // clean up its temporary files, so we just make a fake response for + // that purpose. + const response = new Writable() as ServerResponse; const contentType = ( - event.headers['content-type'] || event.headers['Content-Type'] || '' + event.headers['content-type'] || + event.headers['Content-Type'] || + '' ).toLowerCase(); - if (contentType.startsWith('multipart/form-data') - && typeof processFileUploads === 'function') { + const isMultipart = contentType.startsWith('multipart/form-data'); + type UnwrapPromise = T extends Promise ? U : T; + let bodyFromFileUploads: + | UnwrapPromise< + ReturnType> + > + | undefined; + if (isMultipart && typeof processFileUploads === 'function') { const request = new FileUploadRequest() as IncomingMessage; request.push( Buffer.from( event.body, - event.isBase64Encoded ? 'base64' : 'ascii' - ) + event.isBase64Encoded ? 'base64' : 'ascii', + ), ); request.push(null); request.headers = event.headers; - processFileUploads(request, response, this.uploadsConfig || {}) - .then(body => { - event.body = body as any; - return next(); - }) - .catch(error => { - throw formatApolloErrors([error], { - formatter: this.requestOptions.formatError, - debug: this.requestOptions.debug, - }); + try { + bodyFromFileUploads = await processFileUploads( + request, + response, + this.uploadsConfig || {}, + ); + } catch (error) { + throw formatApolloErrors([error], { + formatter: this.requestOptions.formatError, + debug: this.requestOptions.debug, }); - } else { - return next(); + } } - }; - fileUploadHandler(() => graphqlLambda(async () => { - // In a world where this `createHandler` was async, we might avoid this - // but since we don't want to introduce a breaking change to this API - // (by switching it to `async`), we'll leverage the - // `GraphQLServerOptions`, which are dynamically built on each request, - // to `await` the `promiseWillStart` which we kicked off at the top of - // this method to ensure that it runs to completion (which is part of - // its contract) prior to processing the request. - await promiseWillStart; - return this.createGraphQLServerOptions(event, context); - })(event, context, callbackFilter)); - }; + try { + let { body, isBase64Encoded } = event; + let query: Record | Record[]; + + if (body && isBase64Encoded && !isMultipart) { + body = Buffer.from(body, 'base64').toString(); + } + + if (event.httpMethod === 'POST' && !body) { + return { + body: 'POST body missing.', + statusCode: 500, + }; + } + + if (bodyFromFileUploads) { + query = bodyFromFileUploads; + } else if (body && event.httpMethod === 'POST' && isMultipart) { + // XXX Not clear if this was only intended to handle the uploads + // case or if it had more general applicability + query = body as any; + } else if (body && event.httpMethod === 'POST') { + query = JSON.parse(body); + } else { + query = event.queryStringParameters || {}; + } + + try { + const { graphqlResponse, responseInit } = await runHttpQuery( + [event, context], + { + method: event.httpMethod, + options: async () => { + // In a world where this `createHandler` was async, we might avoid this + // but since we don't want to introduce a breaking change to this API + // (by switching it to `async`), we'll leverage the + // `GraphQLServerOptions`, which are dynamically built on each request, + // to `await` the `promiseWillStart` which we kicked off at the top of + // this method to ensure that it runs to completion (which is part of + // its contract) prior to processing the request. + await promiseWillStart; + return this.createGraphQLServerOptions(event, context); + }, + query, + request: { + url: event.path, + method: event.httpMethod, + headers: eventHeaders, + }, + }, + ); + return { + body: graphqlResponse, + statusCode: 200, + headers: { + ...responseInit.headers, + ...requestCorsHeadersObject, + }, + }; + } catch (error) { + if (error.name !== 'HttpQueryError') throw Error; + const httpQueryError = error as HttpQueryError; + return { + body: httpQueryError.message, + statusCode: httpQueryError.statusCode, + headers: { + ...httpQueryError.headers, + ...requestCorsHeadersObject, + }, + }; + } + } finally { + response.end(); + } + }, + ); } } diff --git a/packages/apollo-server-lambda/src/lambdaApollo.ts b/packages/apollo-server-lambda/src/lambdaApollo.ts deleted file mode 100644 index dad6cbba53c..00000000000 --- a/packages/apollo-server-lambda/src/lambdaApollo.ts +++ /dev/null @@ -1,90 +0,0 @@ -import lambda from 'aws-lambda'; -import { - GraphQLOptions, - HttpQueryError, - runHttpQuery, -} from 'apollo-server-core'; -import { Headers } from 'apollo-server-env'; -import { ValueOrPromise } from 'apollo-server-types'; - -export interface LambdaGraphQLOptionsFunction { - (event: lambda.APIGatewayProxyEvent, context: lambda.Context): ValueOrPromise< - GraphQLOptions - >; -} - -export function graphqlLambda( - options: GraphQLOptions | LambdaGraphQLOptionsFunction, -): lambda.APIGatewayProxyHandler { - if (!options) { - throw new Error('Apollo Server requires options.'); - } - - if (arguments.length > 1) { - throw new Error( - `Apollo Server expects exactly one argument, got ${arguments.length}`, - ); - } - - const graphqlHandler: lambda.APIGatewayProxyHandler = ( - event, - context, - callback, - ): void => { - context.callbackWaitsForEmptyEventLoop = false; - let { body, headers, isBase64Encoded } = event; - let query: Record | Record[]; - const contentType = ( - headers['content-type'] || headers['Content-Type'] || '' - ).toLowerCase(); - const isMultipart = contentType.startsWith('multipart/form-data'); - - if (body && isBase64Encoded && !isMultipart) { - body = Buffer.from(body, 'base64').toString(); - } - - if (event.httpMethod === 'POST' && !body) { - return callback(null, { - body: 'POST body missing.', - statusCode: 500, - }); - } - - if (body && event.httpMethod === 'POST' && isMultipart) { - query = body as any; - } else if (body && event.httpMethod === 'POST') { - query = JSON.parse(body); - } else { - query = event.queryStringParameters || {}; - } - - runHttpQuery([event, context], { - method: event.httpMethod, - options: options, - query, - request: { - url: event.path, - method: event.httpMethod, - headers: new Headers(event.headers), - }, - }).then( - ({ graphqlResponse, responseInit }) => { - callback(null, { - body: graphqlResponse, - statusCode: 200, - headers: responseInit.headers, - }); - }, - (error: HttpQueryError) => { - if ('HttpQueryError' !== error.name) return callback(error); - callback(null, { - body: error.message, - statusCode: error.statusCode, - headers: error.headers, - }); - }, - ); - }; - - return graphqlHandler; -}