diff --git a/CHANGELOG.md b/CHANGELOG.md index 91fe9f95803..9c8c3668f7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The version headers in this history reflect the versions of Apollo Server itself ## vNEXT +- `apollo-server-core`: Previously, only the batteries-included `apollo-server` package supported a graceful shutdown. Now the integrations support it as well, if you tell your `ApolloServer` which HTTP server to drain with the new `ApolloServerPluginDrainHttpServer` plugin. This plugin implements a new `drainServer` plugin hook. For `apollo-server-hapi` you can use `ApolloServerPluginStopHapiServer` instead. [PR #5635](https://github.com/apollographql/apollo-server/pull/5635) - `apollo-server-core`: Fix `experimental_approximateDocumentStoreMiB` option, which seems to have never worked before. [PR #5629](https://github.com/apollographql/apollo-server/pull/5629) - `apollo-server-core`: Only register `SIGINT` and `SIGTERM` handlers once the server successfully starts up; trying to call `stop` on a server that hasn't successfully started had undefined behavior. By default, don't register the handlers in serverless integrations, which don't have the same lifecycle as non-serverless integrations (eg, there's no explicit `start` call); you can still explicitly set `stopOnTerminationSignals` to override this default. [PR #5639](https://github.com/apollographql/apollo-server/pull/5639) diff --git a/docs/gatsby-config.js b/docs/gatsby-config.js index 9b6b6ef377e..75ff8a39da5 100644 --- a/docs/gatsby-config.js +++ b/docs/gatsby-config.js @@ -77,6 +77,7 @@ module.exports = { 'api/plugin/usage-reporting', 'api/plugin/schema-reporting', 'api/plugin/inline-trace', + 'api/plugin/drain-http-server', 'api/plugin/cache-control', 'api/plugin/landing-pages', ], diff --git a/docs/source/api/apollo-server.md b/docs/source/api/apollo-server.md index d687dbbb747..ff96e1b52ea 100644 --- a/docs/source/api/apollo-server.md +++ b/docs/source/api/apollo-server.md @@ -517,22 +517,22 @@ The `start` method triggers the following actions: ## `stop` -`ApolloServer.stop()` is an async method that tells all of Apollo Server's background tasks to complete. It calls and awaits all [`serverWillStop` plugin handlers](../integrations/plugins-event-reference/#serverwillstop) (including the [usage reporting plugin](./plugin/usage-reporting/)'s handler, which sends a final usage report to Apollo Studio). This method takes no arguments. You should only call it after [`start()`](#start) returns successfully (or [`listen()`](#listen) if you're using the batteries-included `apollo-server` package). +`ApolloServer.stop()` is an async method that tells all of Apollo Server's background tasks to complete. Specifically, it: -If your server is a [federated gateway](https://www.apollographql.com/docs/federation/gateway/), `stop` also stops gateway-specific background activities, such as polling for updated service configuration. +- Calls and awaits all [`drainServer` plugin handlers](../integrations/plugins-event-reference/#drainserver). These should generally: + * Stop listening for new connections + * Closes idle connections (i.e., connections with no current HTTP request) + * Closes active connections whenever they become idle + * Waits for all connections to be closed + * After a grace period, if any connections remain active, forcefully close them. + If you're using the batteries-included `apollo-server` package, it does this by default. (You can configure the grace period with the [`stopGracePeriodMillis` constructor option](#stopgraceperiodmillis).) Otherwise, you can use the [drain HTTP server plugin](./plugin/drain-http-server/) to drain your HTTP server. +- Transitions the server to a state where it will not start executing more GraphQL operations. +- Calls and awaits all [`serverWillStop` plugin handlers](../integrations/plugins-event-reference/#serverwillstop) (including the [usage reporting plugin](./plugin/usage-reporting/)'s handler, which sends a final usage report to Apollo Studio). +- If your server is a [federated gateway](https://www.apollographql.com/docs/federation/gateway/), `stop` also stops gateway-specific background activities, such as polling for updated service configuration. -In some circumstances, Apollo Server calls `stop` automatically when the process receives a `SIGINT` or `SIGTERM` signal. See the [`stopOnTerminationSignals` constructor option](#stoponterminationsignals) for details. - -If you're using the `apollo-server` package (which handles setting up an HTTP server for you), this method first stops the HTTP server. Specifically, it: - -* Stops listening for new connections -* Closes idle connections (i.e., connections with no current HTTP request) -* Closes active connections whenever they become idle -* Waits for all connections to be closed - -If any connections remain active after a grace period (10 seconds by default), Apollo Server forcefully closes those connections. You can configure this grace period with the [`stopGracePeriodMillis` constructor option](#stopgraceperiodmillis). +This method takes no arguments. You should only call it after [`start()`](#start) returns successfully (or [`listen()`](#listen) if you're using the batteries-included `apollo-server` package). -If you're using a [middleware package](../integrations/middleware/) instead of `apollo-server`, you should stop your HTTP server before calling `ApolloServer.stop()`. +In some circumstances, Apollo Server calls `stop` automatically when the process receives a `SIGINT` or `SIGTERM` signal. See the [`stopOnTerminationSignals` constructor option](#stoponterminationsignals) for details. ## Framework-specific middleware function @@ -547,23 +547,25 @@ These functions take an `options` object as a parameter. Some supported fields o ```js const express = require('express'); const { ApolloServer } = require('apollo-server-express'); +const { ApolloServerPluginDrainHttpServer } = require('apollo-server-core'); const { typeDefs, resolvers } = require('./schema'); async function startApolloServer() { + const app = express(); + const httpServer = http.createServer(app); const server = new ApolloServer({ typeDefs, resolvers, + plugins: [ApolloServerPluginDrainHttpServer({ httpServer })], }); await server.start(); - const app = express(); - // Additional middleware can be mounted at this point to run before Apollo. app.use('*', jwtCheck, requireAuth, checkScope); // Mount Apollo middleware here. server.applyMiddleware({ app, path: '/specialUrl' }); - await new Promise(resolve => app.listen({ port: 4000 }, resolve)); + await new Promise(resolve => httpServer.listen({ port: 4000 }, resolve)); console.log(`🚀 Server ready at http://localhost:4000${server.graphqlPath}`); return { server, app }; } diff --git a/docs/source/api/plugin/drain-http-server.md b/docs/source/api/plugin/drain-http-server.md new file mode 100644 index 00000000000..fd3a05dbf94 --- /dev/null +++ b/docs/source/api/plugin/drain-http-server.md @@ -0,0 +1,90 @@ +--- +title: "API Reference: Drain HTTP server plugin" +sidebar_title: Drain HTTP server +api_reference: true +--- + +## Using the plugin + +This API reference documents the `ApolloServerPluginDrainHttpServer` plugin. + +This plugin is designed for use with [`apollo-server-express` and other framework-specific packages](../../integrations/middleware/#all-supported-packages) which are built on top of [Node `http.Server`s](https://nodejs.org/api/http.html#http_class_http_server). It is highly recommended that you use this plugin with packages like `apollo-server-express` if you want your server to shut down gracefully. + +You do not need to explicitly use this plugin with the batteries-included `apollo-server` package: that package automatically uses this plugin internally. + +When you use this plugin, Apollo Server will drain your HTTP server when you call the `stop()` method (which is also called for you when the `SIGTERM` and `SIGINT` signals are received, unless disabled with the [`stopOnTerminationSignals` constructor option](./apollo-server/#stoponterminationsignals)). Specifically, it will: + +* Stop listening for new connections +* Close idle connections (i.e., connections with no current HTTP request) +* Close active connections whenever they become idle +* Wait for all connections to be closed +* After a grace period, if any connections remain active, forcefully close them. + +This plugin is exported from the `apollo-server-core` package. It is tested with `apollo-server-express`, `apollo-server-koa`, and `apollo-server-fastify`. (If you're using Hapi, you should instead use the `ApolloServerPluginStopHapiServer` plugin exported from the `apollo-server-hapi` package.) + +Here's a basic example of how to use it with Express. See the [framework integrations docs](../../integrations/middleware/) for examples of how to use it with other frameworks. + +```js +const express = require('express'); +const { ApolloServer } = require('apollo-server-express'); +const { ApolloServerPluginDrainHttpServer } = require('apollo-server-core'); +const { typeDefs, resolvers } = require('./schema'); + +async function startApolloServer() { + const app = express(); + const httpServer = http.createServer(app); + const server = new ApolloServer({ + typeDefs, + resolvers, + plugins: [ApolloServerPluginDrainHttpServer({ httpServer })], + }); + await server.start(); + + // Mount Apollo middleware here. + server.applyMiddleware({ app }); + await new Promise(resolve => httpServer.listen({ port: 4000 }, resolve)); + console.log(`🚀 Server ready at http://localhost:4000${server.graphqlPath}`); + return { server, app }; +} +``` + +## Options + + + + + + + + + + + + + + + + + + + + + + +
Name /
Type
Description
+ +###### `httpServer` + +[`http.Server`](https://nodejs.org/api/http.html#http_class_http_server) + + +The server to drain; required. +
+ +###### `stopGracePeriodMillis` + +`number` + + +How long to wait before forcefully closing non-idle connections. Defaults to `10_000` (ten seconds). +
diff --git a/docs/source/data/subscriptions.mdx b/docs/source/data/subscriptions.mdx index efe3c28065d..9ab6862a3b6 100644 --- a/docs/source/data/subscriptions.mdx +++ b/docs/source/data/subscriptions.mdx @@ -99,16 +99,26 @@ To run both an Express app _and_ a separate subscription server, we'll create an // This `server` is the instance returned from `new ApolloServer`. path: server.graphqlPath, }); + ``` - // Shut down in the case of interrupt and termination signals - // We expect to handle this more cleanly in the future. See (#5074)[https://github.com/apollographql/apollo-server/issues/5074] for reference. - ['SIGINT', 'SIGTERM'].forEach(signal => { - process.on(signal, () => subscriptionServer.close()); - }); +6. Add a plugin to your `ApolloServer` constructor to close the `SubscriptionServer`. + ```javascript:title=index.js + const server = new ApolloServer({ + schema, + plugins: [{ + async serverWillStart() { + return { + async drainServer() { + subscriptionServer.close(); + } + }; + } + }], + }); ``` -6. Finally, we need to adjust our existing `listen` call. +7. Finally, we need to adjust our existing `listen` call. Most Express applications call `app.listen(...)`. **Change this to `httpServer.listen(...)`** with the same arguments. This way, the server starts listening on the HTTP and WebSocket transports simultaneously. @@ -136,17 +146,26 @@ import typeDefs from "./typeDefs"; resolvers, }); + const subscriptionServer = SubscriptionServer.create( + { schema, execute, subscribe }, + { server: httpServer, path: server.graphqlPath } + ); + const server = new ApolloServer({ schema, + plugins: [{ + async serverWillStart() { + return { + async drainServer() { + subscriptionServer.close(); + } + }; + } + }], }); await server.start(); server.applyMiddleware({ app }); - SubscriptionServer.create( - { schema, execute, subscribe }, - { server: httpServer, path: server.graphqlPath } - ); - const PORT = 4000; httpServer.listen(PORT, () => console.log(`Server is now running on http://localhost:${PORT}/graphql`) diff --git a/docs/source/integrations/middleware.md b/docs/source/integrations/middleware.md index 9913d51c658..5398eebaf75 100644 --- a/docs/source/integrations/middleware.md +++ b/docs/source/integrations/middleware.md @@ -138,7 +138,7 @@ async function startApolloServer(typeDefs, resolvers) { To swap this out for `apollo-server-express`, we first install the following required packages: ```bash -npm install apollo-server-express express graphql +npm install apollo-server-express apollo-server-core express graphql ``` We can (and should) also _uninstall_ `apollo-server`. @@ -147,16 +147,24 @@ Next, we can modify our code to match the following: ```javascript:title=index.js import { ApolloServer } from 'apollo-server-express'; +import { ApolloServerPluginDrainHttpServer } from 'apollo-server-core'; import express from 'express'; +import http from 'http'; async function startApolloServer(typeDefs, resolvers) { + // Required logic for integrating with Express + const app = express(); + const httpServer = http.createServer(app); - // Same ApolloServer initialization as before - const server = new ApolloServer({ typeDefs, resolvers }); + // Same ApolloServer initialization as before, plus the drain plugin. + const server = new ApolloServer({ + typeDefs, + resolvers, + plugins: [ApolloServerPluginDrainHttpServer({ httpServer })], + }); - // Required logic for integrating with Express + // More required logic for integrating with Express await server.start(); - const app = express(); server.applyMiddleware({ app, @@ -167,20 +175,12 @@ async function startApolloServer(typeDefs, resolvers) { }); // Modified server startup - await new Promise(resolve => app.listen({ port: 4000 }, resolve)); + await new Promise(resolve => httpServer.listen({ port: 4000 }, resolve)); console.log(`🚀 Server ready at http://localhost:4000${server.graphqlPath}`); } ``` -### Handling server shutdown - -The `apollo-server` package does provide one feature that isn't straightforward to implement with other packages: because `apollo-server` handles making its HTTP server listen for requests, its [`stop`](../api/apollo-server/#stop) method handles _stopping_ the HTTP server. This means it can make sure to stop accepting new requests _before_ it begins to shut down the machinery that processes GraphQL operations. - -If you want this behavior in another package, you need to make sure to stop your web server *before* calling `stop()` on your `ApolloServer` instance. This can be challenging if `stop` is being called due to the [signal handlers](../api/apollo-server/#stoponterminationsignals) that Apollo Server installs by default. - -We intend to improve this behavior discrepancy, as described in [this GitHub issue](https://github.com/apollographql/apollo-server/issues/5074). - ## Package conventions @@ -237,19 +237,26 @@ If you want to do something with your server that isn't supported by `apollo-ser The following example is roughly equivalent to the [`apollo-server` example](#apollo-server) above. ```bash -npm install apollo-server-express express graphql +npm install apollo-server-express apollo-server-core express graphql ``` ```javascript:title=index.js import { ApolloServer } from 'apollo-server-express'; +import { ApolloServerPluginDrainHttpServer } from 'apollo-server-core'; import express from 'express'; +import http from 'http'; async function startApolloServer(typeDefs, resolvers) { - const server = new ApolloServer({ typeDefs, resolvers }); - await server.start(); const app = express(); + const httpServer = http.createServer(app); + const server = new ApolloServer({ + typeDefs, + resolvers, + plugins: [ApolloServerPluginDrainHttpServer({ httpServer })], + }); + await server.start(); server.applyMiddleware({ app }); - await new Promise(resolve => app.listen({ port: 4000 }, resolve)); + await new Promise(resolve => httpServer.listen({ port: 4000 }, resolve)); console.log(`🚀 Server ready at http://localhost:4000${server.graphqlPath}`); } ``` @@ -273,17 +280,37 @@ You can call `server.getMiddleware` instead of `server.applyMiddleware` if you w The following example is roughly equivalent to the [`apollo-server` example](#apollo-server) above. ``` -$ npm install apollo-server-fastify fastify graphql +$ npm install apollo-server-fastify apollo-server-core fastify graphql ``` ```javascript import { ApolloServer } from 'apollo-server-fastify'; +import { ApolloServerPluginDrainHttpServer } from 'apollo-server-core'; import fastify from 'fastify'; +function fastifyAppClosePlugin(app: FastifyInstance): ApolloServerPlugin { + return { + async serverWillStart() { + return { + async drainServer() { + await app.close(); + }, + }; + }, + }; +} + async function startApolloServer(typeDefs, resolvers) { - const server = new ApolloServer({ typeDefs, resolvers }); - await server.start(); const app = fastify(); + const server = new ApolloServer({ + typeDefs, + resolvers, + plugins: [ + fastifyAppClosePlugin(app), + ApolloServerPluginDrainHttpServer({ httpServer: app.server }), + ], + }); + await server.start(); app.register(server.createHandler()); await app.listen(4000); console.log(`🚀 Server ready at http://localhost:4000${server.graphqlPath}`); @@ -299,6 +326,8 @@ You _must_ `await server.start()` before calling `server.createHandler`. You can * `onHealthCheck` * `disableHealthCheck` +> See [this issue](https://github.com/apollographql/apollo-server/issues/5642) for details about draining Fastify servers. The pattern suggested here isn't perfect; we look forward to suggestions from Fastify users. + ### `apollo-server-hapi` `apollo-server-hapi` is the GraphQL server for [hapi](https://hapi.dev/), a Node.js web framework. Apollo Server 3 is only tested with `@hapi/hapi` v20.1.2 and later (the minimum version that supports Node.js 16). @@ -310,18 +339,20 @@ $ npm install apollo-server-hapi @hapi/hapi graphql ``` ```javascript -import { ApolloServer } from 'apollo-server-hapi'; +import { ApolloServer, ApolloServerPluginStopHapiServer } from 'apollo-server-hapi'; import Hapi from '@hapi/hapi'; async function startApolloServer(typeDefs, resolvers) { - const server = new ApolloServer({ typeDefs, resolvers }); - await server.start(); - const app = new Hapi.server({ - port: 4000 - }); - await server.applyMiddleware({ - app, + const app = Hapi.server({ port: 4000 }); + const server = new ApolloServer({ + typeDefs, + resolvers, + plugins: [ + ApolloServerPluginStopHapiServer({ hapiServer: app }), + ], }); + await server.start(); + await server.applyMiddleware({ app }); await app.start(); } ``` @@ -344,19 +375,27 @@ You _must_ `await server.start()` before calling `server.applyMiddleware`. You c The following example is roughly equivalent to the [`apollo-server` example](#apollo-server) above. ``` -$ npm install apollo-server-koa koa graphql +$ npm install apollo-server-koa apollo-server-core koa graphql ``` ```javascript import { ApolloServer } from 'apollo-server-koa'; +import { ApolloServerPluginDrainHttpServer } from 'apollo-server-core'; import Koa from 'koa'; +import http from 'http'; async function startApolloServer(typeDefs, resolvers) { - const server = new ApolloServer({ typeDefs, resolvers }); + const httpServer = http.createServer(); + const server = new ApolloServer({ + typeDefs, + resolvers, + plugins: [ApolloServerPluginDrainHttpServer({ httpServer })], + }); await server.start(); const app = new Koa(); server.applyMiddleware({ app }); - await new Promise(resolve => app.listen({ port: 4000 }, resolve)); + httpServer.on('request', app.callback()); + await new Promise(resolve => httpServer.listen({ port: 4000 }, resolve)); console.log(`🚀 Server ready at http://localhost:4000${server.graphqlPath}`); return { server, app }; } @@ -403,6 +442,8 @@ Then run the web server with `npx micro`. Note that `apollo-server-micro` does _not_ have a built-in way of setting CORS headers. +> We do not have a recommended `drainServer` implementation for `apollo-server-micro`. If you use `apollo-server-micro`, feel free to [contribute one](https://github.com/apollographql/apollo-server/pulls)! + ### `apollo-server-lambda` diff --git a/docs/source/integrations/plugins-event-reference.md b/docs/source/integrations/plugins-event-reference.md index 533ee480caf..310186e8579 100644 --- a/docs/source/integrations/plugins-event-reference.md +++ b/docs/source/integrations/plugins-event-reference.md @@ -44,12 +44,40 @@ const server = new ApolloServer({ }) ``` +### `drainServer` + +The `drainServer` event fires when Apollo Server is starting to shut down because [`ApolloServer.stop()`](../api/apollo-server/#stop) has been invoked (either explicitly by your code, or by one of the [termination signal handlers](../api/apollo-server/#stoponterminationsignals)). While `drainServer` handlers run, GraphQL operations can still execute successfully. This hook is designed to allow you to stop accepting new connections and close existing connections. Apollo Server has a [built-in plugin](../api/plugins/drain-http-server) which uses this event to drain a [Node `http.Server`](https://nodejs.org/api/http.html#http_class_http_server). + +You define your `drainServer` handler in the object returned by your [`serverWillStart`](#serverwillstart) handler, because the two handlers usually interact with the same data. Currently, `drainServer` handlers do not take arguments (this might change in the future). + +#### Example + +```js +const server = new ApolloServer({ + /* ... other necessary configuration ... */ + + plugins: [ + { + async serverWillStart() { + return { + async drainServer() { + await myCustomServer.drain(); + } + } + } + } + ] +}) +``` + ### `serverWillStop` The `serverWillStop` event fires when Apollo Server is starting to shut down because [`ApolloServer.stop()`](../api/apollo-server/#stop) has been invoked (either explicitly by your code, or by one of the [termination signal handlers](../api/apollo-server/#stoponterminationsignals)). If your plugin is running any background tasks, this is a good place to shut them down. You define your `serverWillStop` handler in the object returned by your [`serverWillStart`](#serverwillstart) handler, because the two handlers usually interact with the same data. Currently, `serverWillStop` handlers do not take arguments (this might change in the future). +When your `serverWillStart` handler is called, Apollo Server is in a state where it will no longer start to execute new GraphQL operations, so it's a good place to flush observability data. If you are looking for a hook that runs while operations can still execute, try [`drainServer`](#drainserver). + #### Example ```js diff --git a/docs/source/migration.mdx b/docs/source/migration.mdx index 201120c63b9..e6bb763c77a 100644 --- a/docs/source/migration.mdx +++ b/docs/source/migration.mdx @@ -120,11 +120,22 @@ Then, complete the following: // This `server` is the instance returned from `new ApolloServer`. path: server.graphqlPath, }); + ``` + +1. Add a plugin to your `ApolloServer` constructor to close the `SubscriptionServer`. - // Shut down in the case of interrupt and termination signals - // We expect to handle this more cleanly in the future. See (#5074)[https://github.com/apollographql/apollo-server/issues/5074] for reference. - ['SIGINT', 'SIGTERM'].forEach(signal => { - process.on(signal, () => subscriptionServer.close()); + ```javascript:title=index.js-10 + const server = new ApolloServer({ + schema, + plugins: [{ + async serverWillStart() { + return { + async drainServer() { + subscriptionServer.close(); + } + }; + } + }], }); ``` @@ -155,16 +166,6 @@ import typeDefs from "./typeDefs"; resolvers, }); - const server = new ApolloServer({ - schema, - context() { - // lookup userId by token, etc. - return { userId }; - } - }); - await server.start(); - server.applyMiddleware({ app }); - const subscriptionServer = SubscriptionServer.create({ schema, execute, @@ -178,11 +179,24 @@ import typeDefs from "./typeDefs"; path: server.graphqlPath, }); - // Shut down in the case of interrupt and termination signals - // We expect to handle this more cleanly in the future. See (#5074)[https://github.com/apollographql/apollo-server/issues/5074] for reference. - ['SIGINT', 'SIGTERM'].forEach(signal => { - process.on(signal, () => subscriptionServer.close()); + const server = new ApolloServer({ + schema, + context() { + // lookup userId by token, etc. + return { userId }; + }, + plugins: [{ + async serverWillStart() { + return { + async drainServer() { + subscriptionServer.close(); + } + }; + } + }], }); + await server.start(); + server.applyMiddleware({ app }); const PORT = 4000; httpServer.listen(PORT, () => diff --git a/packages/apollo-server-core/src/ApolloServer.ts b/packages/apollo-server-core/src/ApolloServer.ts index 2978154b5a3..c63ec1e9c4c 100644 --- a/packages/apollo-server-core/src/ApolloServer.ts +++ b/packages/apollo-server-core/src/ApolloServer.ts @@ -80,19 +80,36 @@ export type SchemaDerivedData = { }; type ServerState = - | { phase: 'initialized'; schemaManager: SchemaManager } + | { + phase: 'initialized'; + schemaManager: SchemaManager; + } | { phase: 'starting'; barrier: Resolvable; schemaManager: SchemaManager; } - | { phase: 'failed to start'; error: Error } + | { + phase: 'failed to start'; + error: Error; + } | { phase: 'started'; schemaManager: SchemaManager; } - | { phase: 'stopping'; barrier: Resolvable } - | { phase: 'stopped'; stopError: Error | null }; + | { + phase: 'draining'; + schemaManager: SchemaManager; + barrier: Resolvable; + } + | { + phase: 'stopping'; + barrier: Resolvable; + } + | { + phase: 'stopped'; + stopError: Error | null; + }; // Throw this in places that should be unreachable (because all other cases have // been handled, reducing the type of the argument to `never`). TypeScript will @@ -121,6 +138,7 @@ export class ApolloServerBase< private state: ServerState; private toDispose = new Set<() => Promise>(); private toDisposeLast = new Set<() => Promise>(); + private drainServers: (() => Promise) | null = null; private experimental_approximateDocumentStoreMiB: Config['experimental_approximateDocumentStoreMiB']; private stopOnTerminationSignals: boolean; private landingPage: LandingPage | null = null; @@ -137,13 +155,15 @@ export class ApolloServerBase< typeDefs, parseOptions = {}, introspection, - mocks, - mockEntireSchema, plugins, gateway, - experimental_approximateDocumentStoreMiB, - stopOnTerminationSignals, apollo, + stopOnTerminationSignals, + // These next options aren't used in this function but they don't belong in + // requestOptions. + mocks, + mockEntireSchema, + experimental_approximateDocumentStoreMiB, ...requestOptions } = config; @@ -425,6 +445,17 @@ export class ApolloServerBase< }); } + const drainServerCallbacks = taggedServerListeners.flatMap((l) => + l.serverListener.drainServer ? [l.serverListener.drainServer] : [], + ); + if (drainServerCallbacks.length) { + this.drainServers = async () => { + await Promise.all( + drainServerCallbacks.map((drainServer) => drainServer()), + ); + }; + } + // Find the renderLandingPage callback, if one is provided. If the user // installed ApolloServerPluginLandingPageDisabled then there may be none // found. On the other hand, if the user installed a landingPage plugin, @@ -537,6 +568,7 @@ export class ApolloServerBase< 'This data graph is missing a valid configuration. More details may be available in the server logs.', ); case 'started': + case 'draining': // We continue to run operations while draining. return this.state.schemaManager.getSchemaDerivedData(); case 'stopping': throw new Error( @@ -559,7 +591,7 @@ export class ApolloServerBase< } protected assertStarted(methodName: string) { - if (this.state.phase !== 'started') { + if (this.state.phase !== 'started' && this.state.phase !== 'draining') { throw new Error( 'You must `await server.start()` before calling `server.' + methodName + @@ -648,45 +680,67 @@ export class ApolloServerBase< }; } - /** - * XXX: Note that stop() was designed to be called after start() has finished, - * and should not be called concurrently with start() or before start(), or - * else unexpected behavior may occur (e.g. some dependencies may not be - * stopped). - */ public async stop() { - // Calling stop more than once should have the same result as the first time. - if (this.state.phase === 'stopped') { - if (this.state.stopError) { - throw this.state.stopError; - } - return; - } + switch (this.state.phase) { + case 'initialized': + case 'starting': + case 'failed to start': + throw Error( + 'apolloServer.stop() should only be called after `await apolloServer.start()` has succeeded', + ); - // Two parallel calls to stop; just wait for the other one to finish and - // do whatever it did. - if (this.state.phase === 'stopping') { - await this.state.barrier; - // The cast here is because TS doesn't understand that this.state can - // change during the await - // (https://github.com/microsoft/TypeScript/issues/9998). - const state = this.state as ServerState; - if (state.phase !== 'stopped') { - throw Error(`Surprising post-stopping state ${state.phase}`); - } - if (state.stopError) { - throw state.stopError; + // Calling stop more than once should have the same result as the first time. + case 'stopped': + if (this.state.stopError) { + throw this.state.stopError; + } + return; + + // Two parallel calls to stop; just wait for the other one to finish and + // do whatever it did. + case 'stopping': + case 'draining': { + await this.state.barrier; + // The cast here is because TS doesn't understand that this.state can + // change during the await + // (https://github.com/microsoft/TypeScript/issues/9998). + const state = this.state as ServerState; + if (state.phase !== 'stopped') { + throw Error(`Surprising post-stopping state ${state.phase}`); + } + if (state.stopError) { + throw state.stopError; + } + return; } - return; + + case 'started': + // This is handled by the rest of the function. + break; + + default: + throw new UnreachableCaseError(this.state); } - // Commit to stopping, actually stop, and update the phase. + const barrier = resolvable(); + + // Commit to stopping and start draining servers. + this.state = { + phase: 'draining', + schemaManager: this.state.schemaManager, + barrier, + }; + + await this.drainServers?.(); + + // Servers are drained. Prevent further operations from starting and call + // stop handlers. this.state = { phase: 'stopping', barrier: resolvable() }; try { // We run shutdown handlers in two phases because we don't want to turn - // off our signal listeners until we've done the important parts of shutdown - // like running serverWillStop handlers. (We can make this more generic later - // if it's helpful.) + // off our signal listeners (ie, allow signals to kill the process) until + // we've done the important parts of shutdown like running serverWillStop + // handlers. (We can make this more generic later if it's helpful.) await Promise.all([...this.toDispose].map((dispose) => dispose())); await Promise.all([...this.toDisposeLast].map((dispose) => dispose())); } catch (stopError) { diff --git a/packages/apollo-server-core/src/__tests__/ApolloServerBase.test.ts b/packages/apollo-server-core/src/__tests__/ApolloServerBase.test.ts index 44f2bf2eba5..a092d553649 100644 --- a/packages/apollo-server-core/src/__tests__/ApolloServerBase.test.ts +++ b/packages/apollo-server-core/src/__tests__/ApolloServerBase.test.ts @@ -49,16 +49,17 @@ describe('ApolloServerBase construction', () => { warn, error: jest.fn(), }; - expect(() => - new ApolloServerBase({ - typeDefs, - resolvers, - apollo: { - graphVariant: 'foo', - key: 'service:real:key', - }, - logger, - }).stop(), + expect( + () => + new ApolloServerBase({ + typeDefs, + resolvers, + apollo: { + graphVariant: 'foo', + key: 'service:real:key', + }, + logger, + }), ).not.toThrow(); expect(warn).toHaveBeenCalledTimes(1); expect(warn.mock.calls[0][0]).toMatch( diff --git a/packages/apollo-server/src/__tests__/stoppable.test.ts b/packages/apollo-server-core/src/plugin/drainHttpServer/__tests__/stoppable.test.ts similarity index 100% rename from packages/apollo-server/src/__tests__/stoppable.test.ts rename to packages/apollo-server-core/src/plugin/drainHttpServer/__tests__/stoppable.test.ts diff --git a/packages/apollo-server/src/__tests__/stoppable/fixture.cert b/packages/apollo-server-core/src/plugin/drainHttpServer/__tests__/stoppable/fixture.cert similarity index 100% rename from packages/apollo-server/src/__tests__/stoppable/fixture.cert rename to packages/apollo-server-core/src/plugin/drainHttpServer/__tests__/stoppable/fixture.cert diff --git a/packages/apollo-server/src/__tests__/stoppable/fixture.key b/packages/apollo-server-core/src/plugin/drainHttpServer/__tests__/stoppable/fixture.key similarity index 100% rename from packages/apollo-server/src/__tests__/stoppable/fixture.key rename to packages/apollo-server-core/src/plugin/drainHttpServer/__tests__/stoppable/fixture.key diff --git a/packages/apollo-server/src/__tests__/stoppable/server.js b/packages/apollo-server-core/src/plugin/drainHttpServer/__tests__/stoppable/server.js similarity index 81% rename from packages/apollo-server/src/__tests__/stoppable/server.js rename to packages/apollo-server-core/src/plugin/drainHttpServer/__tests__/stoppable/server.js index eac7e44db41..872620cb841 100644 --- a/packages/apollo-server/src/__tests__/stoppable/server.js +++ b/packages/apollo-server-core/src/plugin/drainHttpServer/__tests__/stoppable/server.js @@ -1,5 +1,7 @@ const http = require('http'); -const { Stopper } = require('../../../dist/stoppable.js'); +const { + Stopper, +} = require('../../../../../dist/plugin/drainHttpServer/stoppable.js'); const grace = Number(process.argv[2] || Infinity); let stopper; diff --git a/packages/apollo-server-core/src/plugin/drainHttpServer/index.ts b/packages/apollo-server-core/src/plugin/drainHttpServer/index.ts new file mode 100644 index 00000000000..29ad4eb78bd --- /dev/null +++ b/packages/apollo-server-core/src/plugin/drainHttpServer/index.ts @@ -0,0 +1,39 @@ +import type http from 'http'; +import type { ApolloServerPlugin } from 'apollo-server-plugin-base'; +import { Stopper } from './stoppable'; + +/** + * Options for ApolloServerPluginDrainHttpServer. + */ +export interface ApolloServerPluginDrainHttpServerOptions { + /** + * The http.Server (or https.Server, etc) to drain. Required. + */ + httpServer: http.Server; + /** + * How long to wait before forcefully closing non-idle connections. + * Defaults to 10_000 (ten seconds). + */ + stopGracePeriodMillis?: number; +} + +/** + * This plugin is used with apollo-server-express and other framework + * integrations to drain your HTTP server on shutdown. + * See https://www.apollographql.com/docs/apollo-server/api/plugin/drain-http-server/ + * for details. + */ +export function ApolloServerPluginDrainHttpServer( + options: ApolloServerPluginDrainHttpServerOptions, +): ApolloServerPlugin { + const stopper = new Stopper(options.httpServer); + return { + async serverWillStart() { + return { + async drainServer() { + await stopper.stop(options.stopGracePeriodMillis ?? 10_000); + }, + }; + }, + }; +} diff --git a/packages/apollo-server/src/stoppable.ts b/packages/apollo-server-core/src/plugin/drainHttpServer/stoppable.ts similarity index 100% rename from packages/apollo-server/src/stoppable.ts rename to packages/apollo-server-core/src/plugin/drainHttpServer/stoppable.ts diff --git a/packages/apollo-server-core/src/plugin/index.ts b/packages/apollo-server-core/src/plugin/index.ts index 585fdf27f9c..b2432282ee2 100644 --- a/packages/apollo-server-core/src/plugin/index.ts +++ b/packages/apollo-server-core/src/plugin/index.ts @@ -80,6 +80,18 @@ export function ApolloServerPluginCacheControlDisabled(): ApolloServerPlugin { } //#endregion +//#region Drain HTTP server +import type { ApolloServerPluginDrainHttpServerOptions } from './drainHttpServer'; +export type { ApolloServerPluginDrainHttpServerOptions } from './drainHttpServer'; +export function ApolloServerPluginDrainHttpServer( + options: ApolloServerPluginDrainHttpServerOptions, +): ApolloServerPlugin { + return require('./drainHttpServer').ApolloServerPluginDrainHttpServer( + options, + ); +} +//#endregion + //#region LandingPage import type { InternalApolloServerPlugin } from '../internalPlugin'; export function ApolloServerPluginLandingPageDisabled(): ApolloServerPlugin { diff --git a/packages/apollo-server-express/src/__tests__/ApolloServer.test.ts b/packages/apollo-server-express/src/__tests__/ApolloServer.test.ts index daaabb5474c..d554a6616e6 100644 --- a/packages/apollo-server-express/src/__tests__/ApolloServer.test.ts +++ b/packages/apollo-server-express/src/__tests__/ApolloServer.test.ts @@ -8,6 +8,7 @@ import { gql, AuthenticationError, ApolloServerPluginCacheControlDisabled, + ApolloServerPluginDrainHttpServer, } from 'apollo-server-core'; import { ApolloServer, @@ -34,24 +35,34 @@ const resolvers = { }; describe('apollo-server-express', () => { - let server: ApolloServer; - let httpServer: http.Server; + let serverToCleanUp: ApolloServer | null = null; testApolloServer( async (config: ApolloServerExpressConfig, options) => { - server = new ApolloServer(config); + serverToCleanUp = null; + const app = express(); + const httpServer = http.createServer(app); + const server = new ApolloServer({ + ...config, + plugins: [ + ...(config.plugins ?? []), + ApolloServerPluginDrainHttpServer({ + httpServer: httpServer, + }), + ], + }); if (!options?.suppressStartCall) { await server.start(); + serverToCleanUp = server; } - const app = express(); server.applyMiddleware({ app, path: options?.graphqlPath }); - httpServer = await new Promise((resolve) => { - const s: http.Server = app.listen({ port: 0 }, () => resolve(s)); + await new Promise((resolve) => { + httpServer.once('listening', resolve); + httpServer.listen({ port: 0 }); }); return createServerInfo(server, httpServer); }, async () => { - if (httpServer?.listening) await httpServer.close(); - if (server) await server.stop(); + await serverToCleanUp?.stop(); }, ); }); diff --git a/packages/apollo-server-express/src/__tests__/expressApollo.test.ts b/packages/apollo-server-express/src/__tests__/expressApollo.test.ts index b154509176d..812cb5d10dd 100644 --- a/packages/apollo-server-express/src/__tests__/expressApollo.test.ts +++ b/packages/apollo-server-express/src/__tests__/expressApollo.test.ts @@ -5,17 +5,6 @@ import testSuite, { CreateAppOptions, } from 'apollo-server-integration-testsuite'; -async function createApp(options: CreateAppOptions = {}) { - const app = express(); - - const server = new ApolloServer( - (options.graphqlOptions as ApolloServerExpressConfig) || { schema: Schema }, - ); - await server.start(); - server.applyMiddleware({ app }); - return app; -} - describe('expressApollo', () => { it('throws error if called without schema', function () { expect(() => new ApolloServer(undefined as any)).toThrow( @@ -25,5 +14,23 @@ describe('expressApollo', () => { }); describe('integration:Express', () => { - testSuite({ createApp }); + let serverToCleanUp: ApolloServer | null = null; + testSuite({ + createApp: async function createApp(options: CreateAppOptions = {}) { + serverToCleanUp = null; + const app = express(); + const server = new ApolloServer( + (options.graphqlOptions as ApolloServerExpressConfig) || { + schema: Schema, + }, + ); + await server.start(); + serverToCleanUp = server; + server.applyMiddleware({ app }); + return app; + }, + destroyApp: async function () { + await serverToCleanUp?.stop(); + }, + }); }); diff --git a/packages/apollo-server-fastify/src/__tests__/ApolloServer.test.ts b/packages/apollo-server-fastify/src/__tests__/ApolloServer.test.ts index 0255b123bd2..3b5dae5f8d0 100644 --- a/packages/apollo-server-fastify/src/__tests__/ApolloServer.test.ts +++ b/packages/apollo-server-fastify/src/__tests__/ApolloServer.test.ts @@ -1,8 +1,5 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import fastify from 'fastify'; - -import http from 'http'; - import request from 'supertest'; import { @@ -10,6 +7,7 @@ import { AuthenticationError, Config, ApolloServerPluginCacheControlDisabled, + ApolloServerPluginDrainHttpServer, } from 'apollo-server-core'; import { ApolloServer, ServerRegistration } from '../ApolloServer'; @@ -18,6 +16,7 @@ import { createServerInfo, createApolloFetch, } from 'apollo-server-integration-testsuite'; +import type { ApolloServerPlugin } from 'apollo-server-plugin-base'; const typeDefs = gql` type Query { @@ -33,34 +32,60 @@ const resolvers = { const port = 9999; +function fastifyAppClosePlugin(app: FastifyInstance): ApolloServerPlugin { + return { + async serverWillStart() { + return { + async drainServer() { + await app.close(); + }, + }; + }, + }; +} + describe('apollo-server-fastify', () => { - let server: ApolloServer; - let httpServer: http.Server; - let app: FastifyInstance; + let serverToCleanUp: ApolloServer | null = null; testApolloServer( async (config: any, options) => { - server = new ApolloServer(config); + serverToCleanUp = null; + const app = fastify(); + const server = new ApolloServer({ + ...config, + plugins: [ + ...(config.plugins ?? []), + // I *think* racing these two plugins against each other works. + // They will both end up calling server.close, and one will + // get ERR_SERVER_NOT_RUNNING, but our stoppable implementation + // ignores errors from close. An alternative would be to use + // Fastify's serverFactory to return a server whose 'close' actually + // does the stoppable stuff. (The tests do seem to pass without this + // close call, but it seems like a good idea to invoke app.close in + // case the user registered any onClose hooks?) + // See https://github.com/apollographql/apollo-server/issues/5642 + fastifyAppClosePlugin(app), + ApolloServerPluginDrainHttpServer({ + httpServer: app.server, + }), + ], + }); if (!options?.suppressStartCall) { await server.start(); + serverToCleanUp = server; } - app = fastify(); app.register(server.createHandler({ path: options?.graphqlPath })); await app.listen(port); return createServerInfo(server, app.server); }, async () => { - if (server) await server.stop(); - if (app) await new Promise((resolve) => app.close(() => resolve())); - if (httpServer?.listening) await httpServer.close(); + await serverToCleanUp?.stop(); }, ); }); describe('apollo-server-fastify', () => { let server: ApolloServer; - let app: FastifyInstance; - let httpServer: http.Server; let replyDecorator: jest.Mock | undefined; let requestDecorator: jest.Mock | undefined; @@ -69,12 +94,19 @@ describe('apollo-server-fastify', () => { options: Partial = {}, mockDecorators: boolean = false, ) { + const app = fastify(); server = new ApolloServer({ stopOnTerminationSignals: false, ...serverOptions, + plugins: [ + ...(serverOptions.plugins ?? []), + fastifyAppClosePlugin(app), + ApolloServerPluginDrainHttpServer({ + httpServer: app.server, + }), + ], }); await server.start(); - app = fastify(); if (mockDecorators) { replyDecorator = jest.fn(); @@ -91,9 +123,7 @@ describe('apollo-server-fastify', () => { } afterEach(async () => { - if (server) await server.stop(); - if (app) await new Promise((resolve) => app.close(() => resolve())); - if (httpServer) await httpServer.close(); + await server?.stop(); }); describe('constructor', () => { diff --git a/packages/apollo-server-fastify/src/__tests__/fastifyApollo.test.ts b/packages/apollo-server-fastify/src/__tests__/fastifyApollo.test.ts index 7cb688dbeb5..751fa9e5baa 100644 --- a/packages/apollo-server-fastify/src/__tests__/fastifyApollo.test.ts +++ b/packages/apollo-server-fastify/src/__tests__/fastifyApollo.test.ts @@ -1,32 +1,11 @@ -import fastify from 'fastify'; -import { Server } from 'http'; +import fastify, { FastifyInstance } from 'fastify'; import { ApolloServer } from '../ApolloServer'; import testSuite, { schema as Schema, CreateAppOptions, } from 'apollo-server-integration-testsuite'; import { Config } from 'apollo-server-core'; - -async function createApp(options: CreateAppOptions = {}) { - const app = fastify(); - - const server = new ApolloServer( - (options.graphqlOptions as Config) || { schema: Schema }, - ); - await server.start(); - - app.register(server.createHandler()); - await app.listen(0); - - return app.server; -} - -async function destroyApp(app: Server) { - if (!app || !app.close) { - return; - } - await new Promise((resolve) => app.close(resolve)); -} +import type { ApolloServerPlugin } from 'apollo-server-plugin-base'; describe('fastifyApollo', () => { it('throws error if called without schema', function () { @@ -36,6 +15,40 @@ describe('fastifyApollo', () => { }); }); +function fastifyAppClosePlugin(app: FastifyInstance): ApolloServerPlugin { + return { + async serverWillStart() { + return { + async drainServer() { + await app.close(); + }, + }; + }, + }; +} + describe('integration:Fastify', () => { - testSuite({ createApp, destroyApp, integrationName: 'fastify' }); + let serverToCleanUp: ApolloServer | null = null; + let app: FastifyInstance | null = null; + testSuite({ + async createApp(options: CreateAppOptions = {}) { + serverToCleanUp = null; + app = fastify(); + const config = (options.graphqlOptions as Config) || { schema: Schema }; + const server = new ApolloServer({ + ...config, + plugins: [...(config.plugins ?? []), fastifyAppClosePlugin(app)], + }); + await server.start(); + serverToCleanUp = server; + app.register(server.createHandler()); + await app.listen(0); + + return app.server; + }, + async destroyApp() { + await serverToCleanUp?.stop(); + }, + integrationName: 'fastify', + }); }); diff --git a/packages/apollo-server-hapi/src/ApolloServer.ts b/packages/apollo-server-hapi/src/ApolloServer.ts index 6ee235b73f5..f41e91b9fb1 100644 --- a/packages/apollo-server-hapi/src/ApolloServer.ts +++ b/packages/apollo-server-hapi/src/ApolloServer.ts @@ -10,6 +10,7 @@ import { runHttpQuery, } from 'apollo-server-core'; import Boom from '@hapi/boom'; +import type { ApolloServerPlugin } from 'apollo-server-plugin-base'; export class ApolloServer extends ApolloServerBase { // This translates the arguments from the middleware into graphQL options It @@ -164,3 +165,20 @@ export interface ServerRegistration { onHealthCheck?: (request: hapi.Request) => Promise; disableHealthCheck?: boolean; } + +// hapi's app.stop() works similarly to ApolloServerPluginDrainHttpServer by +// default (as long as cleanStop has its default value of true) so we just use +// it instead of our own HTTP-server-draining implementation. +export function ApolloServerPluginStopHapiServer(options: { + hapiServer: hapi.Server; +}): ApolloServerPlugin { + return { + async serverWillStart() { + return { + async drainServer() { + await options.hapiServer.stop(); + }, + }; + }, + }; +} diff --git a/packages/apollo-server-hapi/src/__tests__/ApolloServer.test.ts b/packages/apollo-server-hapi/src/__tests__/ApolloServer.test.ts index 2d9970ec984..4ab69bfa0e1 100644 --- a/packages/apollo-server-hapi/src/__tests__/ApolloServer.test.ts +++ b/packages/apollo-server-hapi/src/__tests__/ApolloServer.test.ts @@ -4,42 +4,60 @@ import { createApolloFetch, } from 'apollo-server-integration-testsuite'; -import http = require('http'); import request from 'supertest'; -import { Server } from '@hapi/hapi'; +import Hapi from '@hapi/hapi'; import { gql, AuthenticationError, Config } from 'apollo-server-core'; -import { ApolloServer, ServerRegistration } from '../ApolloServer'; +import { + ApolloServer, + ApolloServerPluginStopHapiServer, + ServerRegistration, +} from '../ApolloServer'; const port = 0; describe('apollo-server-hapi', () => { - let server: ApolloServer; - - let app: Server; - let httpServer: http.Server; + let serverToCleanUp: ApolloServer | null = null; + + testApolloServer( + async (config: any, options) => { + serverToCleanUp = null; + const app = Hapi.server({ host: 'localhost', port }); + const server = new ApolloServer({ + ...config, + plugins: [ + ...(config.plugins ?? []), + ApolloServerPluginStopHapiServer({ + hapiServer: app, + }), + ], + }); + if (!options?.suppressStartCall) { + await server.start(); + serverToCleanUp = server; + } + await server.applyMiddleware({ app, path: options?.graphqlPath }); + await app.start(); + const httpServer = app.listener; + return createServerInfo(server, httpServer); + }, + async () => { + await serverToCleanUp?.stop(); + }, + ); +}); - async function cleanup() { - if (server) await server.stop(); - if (app) await app.stop(); - if (httpServer?.listening) httpServer.close(); - } - afterEach(cleanup); +describe('non-integration tests', () => { + let serverToCleanUp: ApolloServer | null = null; - testApolloServer(async (config: any, options) => { - server = new ApolloServer(config); - app = new Server({ host: 'localhost', port }); - if (!options?.suppressStartCall) { - await server.start(); - } - await server.applyMiddleware({ app, path: options?.graphqlPath }); - await app.start(); - const httpServer = app.listener; - return createServerInfo(server, httpServer); - }, cleanup); + beforeEach(() => { + serverToCleanUp = null; + }); + afterEach(async () => { + await serverToCleanUp?.stop(); + }); - //Non-integration tests const typeDefs = gql` type Query { hello: String @@ -56,23 +74,25 @@ describe('apollo-server-hapi', () => { serverOptions: Config, options: Partial = {}, ) { - server = new ApolloServer({ + const app = Hapi.server({ port: 0 }); + const server = new ApolloServer({ stopOnTerminationSignals: false, ...serverOptions, + plugins: [ + ...(serverOptions.plugins ?? []), + ApolloServerPluginStopHapiServer({ + hapiServer: app, + }), + ], }); await server.start(); - app = new Server({ port: 0 }); + serverToCleanUp = server; await server.applyMiddleware({ ...options, app }); await app.start(); return createServerInfo(server, app.listener); } - afterEach(async () => { - if (server) await server.stop(); - if (httpServer) await httpServer.close(); - }); - describe('constructor', () => { it('accepts typeDefs and resolvers', async () => { return createServer({ typeDefs, resolvers }); @@ -201,10 +221,6 @@ describe('apollo-server-hapi', () => { }); describe('healthchecks', () => { - afterEach(async () => { - await server.stop(); - }); - it('creates a healthcheck endpoint', async () => { const { httpServer } = await createServer({ typeDefs, diff --git a/packages/apollo-server-hapi/src/index.ts b/packages/apollo-server-hapi/src/index.ts index 12f384f069d..843b247bab3 100644 --- a/packages/apollo-server-hapi/src/index.ts +++ b/packages/apollo-server-hapi/src/index.ts @@ -13,4 +13,8 @@ export { } from 'apollo-server-core'; // ApolloServer integration. -export { ApolloServer, ServerRegistration } from './ApolloServer'; +export { + ApolloServer, + ServerRegistration, + ApolloServerPluginStopHapiServer, +} from './ApolloServer'; diff --git a/packages/apollo-server-integration-testsuite/src/ApolloServer.ts b/packages/apollo-server-integration-testsuite/src/ApolloServer.ts index 3667d5e8dc3..137ed8016a3 100644 --- a/packages/apollo-server-integration-testsuite/src/ApolloServer.ts +++ b/packages/apollo-server-integration-testsuite/src/ApolloServer.ts @@ -176,7 +176,11 @@ export interface ServerInfo { export interface CreateServerFunc { ( config: Config, - options?: { suppressStartCall?: boolean; graphqlPath?: string }, + options?: { + suppressStartCall?: boolean; + graphqlPath?: string; + noRequestsMade?: boolean; + }, ): Promise>; } @@ -2174,7 +2178,12 @@ export function testApolloServer( describe('Response caching', () => { let clock: FakeTimers.Clock; beforeAll(() => { - clock = FakeTimers.install(); + // These tests use the default InMemoryLRUCache, which is backed by the + // lru-cache npm module, whose maxAge feature is based on `Date.now()` + // (no setTimeout or anything like that). So we want to use fake timers + // just for Date. (Faking all the timer methods messes up things like a + // setImmediate in ApolloServerPluginDrainHttpServer.) + clock = FakeTimers.install({ toFake: ['Date'] }); }); afterAll(() => { @@ -2726,11 +2735,17 @@ export function testApolloServer( const { gateway, triggers } = makeGatewayMock({ optionsSpy }); triggers.resolveLoad({ schema, executor: async () => ({}) }); - await createApolloServer({ - gateway, - apollo: { key: 'service:tester:1234abc', graphRef: 'tester@staging' }, - logger: quietLogger, - }); + await createApolloServer( + { + gateway, + apollo: { + key: 'service:tester:1234abc', + graphRef: 'tester@staging', + }, + logger: quietLogger, + }, + { noRequestsMade: true }, + ); expect(optionsSpy).toHaveBeenLastCalledWith({ apollo: { diff --git a/packages/apollo-server-integration-testsuite/src/index.ts b/packages/apollo-server-integration-testsuite/src/index.ts index 330dc1a6976..2abd00c242a 100644 --- a/packages/apollo-server-integration-testsuite/src/index.ts +++ b/packages/apollo-server-integration-testsuite/src/index.ts @@ -1310,7 +1310,7 @@ export default ({ describe('status code', () => { it('allows setting a custom status code', async () => { - const app = await createApp({ + app = await createApp({ graphqlOptions: { schema, plugins: [ diff --git a/packages/apollo-server-koa/src/__tests__/ApolloServer.test.ts b/packages/apollo-server-koa/src/__tests__/ApolloServer.test.ts index 35b0033e3c6..300a5c093e7 100644 --- a/packages/apollo-server-koa/src/__tests__/ApolloServer.test.ts +++ b/packages/apollo-server-koa/src/__tests__/ApolloServer.test.ts @@ -1,5 +1,5 @@ import http from 'http'; - +import Koa from 'koa'; import request from 'supertest'; import { @@ -7,6 +7,7 @@ import { AuthenticationError, Config, ApolloServerPluginCacheControlDisabled, + ApolloServerPluginDrainHttpServer, } from 'apollo-server-core'; import { @@ -30,25 +31,34 @@ const resolvers = { describe('apollo-server-koa', () => { const { ApolloServer } = require('../ApolloServer'); - const Koa = require('koa'); - let server: ApolloServer; - let httpServer: http.Server; + let serverToCleanUp: ApolloServer | null = null; testApolloServer( async (config: any, options) => { - server = new ApolloServer(config); + serverToCleanUp = null; + const httpServer = http.createServer(); + const server = new ApolloServer({ + ...config, + plugins: [ + ...(config.plugins ?? []), + ApolloServerPluginDrainHttpServer({ + httpServer: httpServer, + }), + ], + }); if (!options?.suppressStartCall) { await server.start(); + serverToCleanUp = server; } const app = new Koa(); server.applyMiddleware({ app, path: options?.graphqlPath }); - httpServer = await new Promise((resolve) => { - const s = app.listen({ port: 0 }, () => resolve(s)); + httpServer.on('request', app.callback()); + await new Promise((resolve) => { + httpServer.listen({ port: 0 }, () => resolve()); }); return createServerInfo(server, httpServer); }, async () => { - if (server) await server.stop(); - if (httpServer?.listening) await httpServer.close(); + await serverToCleanUp?.stop(); }, ); }); diff --git a/packages/apollo-server-lambda/src/__tests__/ApolloServer.test.ts b/packages/apollo-server-lambda/src/__tests__/ApolloServer.test.ts index cc9c8950227..d3b1afa5cb6 100644 --- a/packages/apollo-server-lambda/src/__tests__/ApolloServer.test.ts +++ b/packages/apollo-server-lambda/src/__tests__/ApolloServer.test.ts @@ -2,7 +2,11 @@ import http from 'http'; import request from 'supertest'; import express from 'express'; import { createMockServer } from './mockAPIGatewayServer'; -import { Config, gql } from 'apollo-server-core'; +import { + ApolloServerPluginDrainHttpServer, + Config, + gql, +} from 'apollo-server-core'; import { ApolloServer, CreateHandlerOptions, @@ -26,30 +30,49 @@ const resolvers = { }; describe('apollo-server-lambda', () => { - let server: ApolloServer; - let httpServer: http.Server; + let serverToCleanUp: ApolloServer | null = null; testApolloServer( async (config: Config, options) => { - server = new ApolloServer(config); + serverToCleanUp = null; + const httpServer = http.createServer(); + const server = new ApolloServer({ + ...config, + plugins: [ + ...(config.plugins ?? []), + // You don't typically need to use this plugin in a real Lambda + // environment, but our test harness sets up an http.Server, so we use + // it here. + ApolloServerPluginDrainHttpServer({ + httpServer: httpServer, + }), + ], + }); // Ignore suppressStartCall because serverless ApolloServers don't - // get `start`ed. + // get manually `start`ed. However, if no requests will be made then + // it won't get `start`ed and that's the condition we need to use to + // decide whether or not to `stop`. + if (!options?.noRequestsMade) { + serverToCleanUp = server; + } const lambdaHandler = server.createHandler({ expressGetMiddlewareOptions: { path: options?.graphqlPath }, }); - const httpHandler = createMockServer(lambdaHandler); - httpServer = new http.Server(httpHandler); + httpServer.on('request', createMockServer(lambdaHandler)); await new Promise((resolve) => { httpServer.listen({ port: 0 }, () => resolve()); }); - return createServerInfo(server, httpServer); + const serverInfo = createServerInfo(server, httpServer); + if (options?.noRequestsMade) { + // Since no requests will be made (and the server won't even start), we + // immediately close the HTTP server. (We made it at all so we can have + // a typesafe return value from this function, though the only test that + // uses this mode ignores the return value from this function.) + await new Promise((resolve) => httpServer.close(() => resolve())); + } + return serverInfo; }, async () => { - if (httpServer?.listening) { - await new Promise((resolve) => { - httpServer.close(() => resolve()); - }); - } - if (server) await server.stop(); + await serverToCleanUp?.stop(); }, { serverlessFramework: true }, ); diff --git a/packages/apollo-server-plugin-base/src/index.ts b/packages/apollo-server-plugin-base/src/index.ts index 5c7774a5693..5b1df8ce8b3 100644 --- a/packages/apollo-server-plugin-base/src/index.ts +++ b/packages/apollo-server-plugin-base/src/index.ts @@ -60,6 +60,19 @@ export interface ApolloServerPlugin< export interface GraphQLServerListener { schemaDidLoadOrUpdate?(schemaContext: GraphQLSchemaContext): void; + // When your server is stopped (by calling `stop()` or via the + // `SIGINT`/`SIGTERM` handlers), Apollo Server first awaits all `drainServer` + // hooks in parallel. GraphQL operations can still execute while `drainServer` + // is in progress. A typical use is to stop listening for new connections and + // wait until all current connections are idle. The built-in + // ApolloServerPluginDrainHttpServer implements this method. + drainServer?(): Promise; + + // When your server is stopped (by calling `stop()` or via the + // `SIGINT`/`SIGTERM` handlers) then (after the `drainServer` phase finishes) + // Apollo Server transitions into a state where no new operations will run and + // then awaits all `drainServer` hooks in parallel. A typical use is to flush + // outstanding observability data. serverWillStop?(): Promise; // At most one plugin's serverWillStart may return a GraphQLServerListener diff --git a/packages/apollo-server/src/index.ts b/packages/apollo-server/src/index.ts index a47e0683d12..f5717ec1769 100644 --- a/packages/apollo-server/src/index.ts +++ b/packages/apollo-server/src/index.ts @@ -12,7 +12,7 @@ import { } from 'apollo-server-express'; import type { AddressInfo } from 'net'; import { format as urlFormat } from 'url'; -import { Stopper } from './stoppable'; +import { ApolloServerPluginDrainHttpServer } from 'apollo-server-core'; export * from './exports'; @@ -25,10 +25,9 @@ export interface ServerInfo { } export class ApolloServer extends ApolloServerExpress { - private stopper?: Stopper; private cors?: CorsOptions | boolean; private onHealthCheck?: (req: express.Request) => Promise; - private stopGracePeriodMillis: number; + private httpServer: http.Server; constructor( config: ApolloServerExpressConfig & { @@ -37,14 +36,25 @@ export class ApolloServer extends ApolloServerExpress { stopGracePeriodMillis?: number; }, ) { - super(config); - this.cors = config?.cors; - this.onHealthCheck = config?.onHealthCheck; - this.stopGracePeriodMillis = config?.stopGracePeriodMillis ?? 10_000; + const httpServer = http.createServer(); + super({ + ...config, + plugins: [ + ...(config.plugins ?? []), + ApolloServerPluginDrainHttpServer({ + httpServer: httpServer, + stopGracePeriodMillis: config.stopGracePeriodMillis, + }), + ], + }); + + this.httpServer = httpServer; + this.cors = config.cors; + this.onHealthCheck = config.onHealthCheck; } - private createServerInfo(server: http.Server): ServerInfo { - const addressInfo = server.address() as AddressInfo; + private createServerInfo(): ServerInfo { + const addressInfo = this.httpServer.address() as AddressInfo; // Convert IPs which mean "any address" (IPv4 or IPv6) into localhost // corresponding loopback ip. If this heuristic is wrong for your use case, @@ -64,7 +74,7 @@ export class ApolloServer extends ApolloServerExpress { return { ...addressInfo, - server, + server: this.httpServer, url, }; } @@ -90,12 +100,13 @@ export class ApolloServer extends ApolloServerExpress { // This class is the easy mode for people who don't create their own express // object, so we have to create it. const app = express(); + this.httpServer.on('request', app); app.disable('x-powered-by'); // provide generous values for the getting started experience super.applyMiddleware({ - app, + app: app, path: '/', bodyParserConfig: { limit: '50mb' }, onHealthCheck: this.onHealthCheck, @@ -107,41 +118,14 @@ export class ApolloServer extends ApolloServerExpress { }, }); - const httpServer = http.createServer(app); - - this.stopper = new Stopper(httpServer); - await new Promise((resolve) => { - httpServer.once('listening', resolve); + this.httpServer.once('listening', resolve); // If the user passed a callback to listen, it'll get called in addition // to our resolver. They won't have the ability to get the ServerInfo // object unless they use our Promise, though. - httpServer.listen(...(opts.length ? opts : [{ port: 4000 }])); + this.httpServer.listen(...(opts.length ? opts : [{ port: 4000 }])); }); - return this.createServerInfo(httpServer); - } - - public override async stop() { - // First drain the HTTP server. (See #5074 for a plan to generalize this to - // the web framework integrations.) - // - // `Stopper.stop` is an async function which: - // - closes the server (ie, stops listening) - // - closes all connections with no active requests - // - continues to close connections when their active request count drops to - // zero - // - in 10 seconds (configurable), closes all remaining active connections - // - returns (async) once there are no remaining active connections - // - // If you don't like this behavior, use apollo-server-express instead of - // apollo-server. - const { stopper } = this; - if (stopper) { - this.stopper = undefined; - await stopper.stop(this.stopGracePeriodMillis); - } - - await super.stop(); + return this.createServerInfo(); } }