Skip to content

Commit 0c2909a

Browse files
authored
Experimental support for incremental delivery (@defer/@stream) (#6827)
Also provides improved compatibility with the graphql-over-http spec by supporting `content-type: application/graphql-response+json` if requested via `accept` header, and adds `; charset=utf-8` to content-types. Executing `@defer` and `@stream` directives requires you to install pre-release of `graphql@17` in your server. This PR does not update `package.json` to install that prerelease (nor does it even broaden peer deps, which would be inadequate as many of its dependencies have peer deps that won't include v17). However, it does add a new CI step that installs a particular v17 pre-release and runs the test suite and smoke test (including running some otherwise-disabled tests that exercise the execution of these directives). We also add a new `__testing_incrementalExecutionResults` option that lets us test transport-level behavior without installing the prerelease. This change reworks `GraphQLResponse` and `HTTPGraphQLResponse` to allow responses to be single- or multi-part. `GraphQLResponse` had previously (in v4) moved most of its fields onto `result`; we now instead of `body` with two `kind`s determining the structure of the rest of the response. `HTTPGraphQLResponse` (new in v4) had tried to anticipate this change, but now the structure is a bit different. A few other changes were made for compatibility with `graphql@17` such as removing some uses of the non-options multi-argument GraphQLError constructor. Add two new plugin APIs: `didEncounterSubsequentErrors` and `willSendSubsequentPayload`. Updates to plugins: - Usage reporting waits until all payloads are ready before it's done with a given operation. - The response cache does not cache incremental responses (although that would likely be quite helpful). - No cache-control HTTP headers are written with incremental responses (since we don't know all the fields that will be executed yet). - Inline traces are not added to incremental delivery responses (though it might make sense to add them to the last payload or something). Fixes #6671.
1 parent 3e077e8 commit 0c2909a

45 files changed

Lines changed: 1962 additions & 451 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/chilled-cows-drum.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@apollo/server-integration-testsuite': patch
3+
'@apollo/server-plugin-response-cache': patch
4+
'@apollo/server': patch
5+
---
6+
7+
Experimental support for incremental delivery (`@defer`/`@stream`) when combined with a prerelease of `graphql-js`.

.changeset/thirty-donkeys-sing.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@apollo/server-integration-testsuite': patch
3+
'@apollo/server-plugin-response-cache': patch
4+
'@apollo/server': patch
5+
---
6+
7+
Support application/graphql-response+json content-type if requested via Accept header, as per graphql-over-http spec.
8+
Include `charset=utf-8` in content-type headers.

.circleci/config.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,22 @@ jobs:
6969
- setup-node
7070
- run: npm run test:smoke
7171

72+
Full incremental delivery tests with graphql-js 17 canary:
73+
docker:
74+
- image: cimg/base:stable
75+
environment:
76+
INCREMENTAL_DELIVERY_TESTS_ENABLED: t
77+
steps:
78+
- setup-node:
79+
node-version: "18"
80+
# Install a prerelease of graphql-js 17 with incremental delivery support.
81+
# --legacy-peer-deps because nothing expects v17 yet.
82+
# --no-engine-strict because Node v18 doesn't match the engines fields
83+
# on some old stuff.
84+
- run: npm i --legacy-peer-deps --no-engine-strict graphql@17.0.0-alpha.1.canary.pr.3361.04ab27334641e170ce0e05bc927b972991953882
85+
- run: npm run test:ci
86+
- run: npm run test:smoke
87+
7288
Prettier:
7389
docker:
7490
- image: cimg/base:stable
@@ -143,4 +159,5 @@ workflows:
143159
- ESLint
144160
- Spell check
145161
- Smoke test built package
162+
- Full incremental delivery tests with graphql-js 17 canary
146163
- Changesets

cspell-dict.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ goofql
7878
graphiql
7979
graphqlcodegenerator
8080
GraphQLJSON
81+
gzipped
8182
hackily
8283
herokuapp
8384
Hofmann

docs/source/api/apollo-server.mdx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -589,7 +589,7 @@ In some circumstances, Apollo Server calls `stop` automatically when the process
589589
The async `executeOperation` method is used primarily for [testing GraphQL operations](../testing/testing/#testing-using-executeoperation) through Apollo Server's request pipeline _without_ sending an HTTP request.
590590

591591
```js
592-
const result = await server.executeOperation({
592+
const response = await server.executeOperation({
593593
query: 'query SayHelloWorld($name: String) { hello(name: $name) }',
594594
variables: { name: 'world' },
595595
});
@@ -601,6 +601,12 @@ The `executeOperation` method takes two arguments:
601601
- Supported fields are listed in the table below.
602602
- The second optional argument is used as the operation's [context value](../data/resolvers/#the-context-argument). Note, this argument is only optional if your server _doesn't_ expect a context value (i.e., your server uses the default context because you didn't explicitly provide another one).
603603

604+
The `response` object returned from `executeOperation` is a `GraphQLResponse`, which has `body` and `http` fields.
605+
606+
Apollo Server 4 supports incremental delivery directives such as `@defer` and `@stream` (when combined with an appropriate version of `graphql-js`), and so the structure of `response.body` can represent either a single result or multiple results. `response.body.kind` is either `'single'` or `'incremental'`. If it is `'single'`, then incremental delivery has not been used, and `response.body.singleResult` is an object with `data`, `errors`, and `extensions` fields. If it is `'incremental'`, then `response.body.initialResult` is the initial result of the operation, and `response.body.subsequentResults` is an async iterator that will yield subsequent results. (The precise structure of `initialResult` and `subsequentResults` is defined by `graphql-js` and may change between the current pre-release of `graphql-js` v17 and its final release; if you write code that processes these values before `graphql-js` v17 has been released you may have to adapt it when the API is finalized.)
607+
608+
The `http` field contains an optional numeric `status` code and a `headers` map specifying any HTTP status code and headers that should be set.
609+
604610
Below are the available fields for the first argument of `executeOperation`:
605611

606612
### Fields

docs/source/data/errors.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -660,7 +660,8 @@ const setHttpPlugin = {
660660
return {
661661
async willSendResponse({ response }) {
662662
response.http.headers.set('custom-header', 'hello');
663-
if (response?.result?.errors?.[0]?.extensions?.code === 'TEAPOT') {
663+
if (response.body.kind === 'single' &&
664+
response.body.singleResult.errors?.[0]?.extensions?.code === 'TEAPOT') {
664665
response.http.status = 418;
665666
}
666667
},

docs/source/integrations/building-integrations.md

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ requests and responses between a web framework's native format to the format use
2020

2121
> For more examples, see these Apollo Server 4 [integrations demos for Fastify and Lambda](https://github.com/apollographql/server-v4-integration-demos/tree/main/packages).
2222
23-
If you are building a serverless integration, we **strongly recommend** prepending your function name with the word `start` (e.g., `startServerAndCreateLambdaHandler(server)`). This naming convention helps maintain Apollo Server's standard that every server uses a function or method whose name contains the word `start` (such as `startStandaloneServer(server)`.
23+
If you are building a serverless integration, we **strongly recommend** prepending your function name with the word `start` (e.g., `startServerAndCreateLambdaHandler(server)`). This naming convention helps maintain Apollo Server's standard that every server uses a function or method whose name contains the word `start` (such as `startStandaloneServer(server)`.
2424

2525
### Main function signature
2626

@@ -225,30 +225,40 @@ interface HTTPGraphQLHead {
225225
headers: Map<string, string>;
226226
}
227227

228-
type HTTPGraphQLResponse = HTTPGraphQLHead &
229-
(
230-
| {
231-
completeBody: string;
232-
bodyChunks: null;
233-
}
234-
| {
235-
completeBody: null;
236-
bodyChunks: AsyncIterableIterator<HTTPGraphQLResponseChunk>;
237-
}
238-
);
228+
type HTTPGraphQLResponseBody =
229+
| { kind: 'complete'; string: string }
230+
| { kind: 'chunked'; asyncIterator: AsyncIterableIterator<string> };
231+
232+
233+
type HTTPGraphQLResponse = HTTPGraphQLHead & {
234+
body: HTTPGraphQLResponseBody;
235+
};
239236
```
240237

241-
The Express implementation uses the `res` object to update the response
242-
with the appropriate status code and headers, and finally sends the body:
238+
Note that a body can either be "complete" (a complete response that can be sent immediately with a `content-length` header), or "chunked", in which case the integration should read from the async iterator and send each chunk one at a time. This typically will use `transfer-encoding: chunked`, though your web framework may handle that for you automatically. If your web environment does not support streaming responses (as in some serverless function environments like AWS Lambda), you can return an error response if a chunked body is received.
239+
240+
The Express implementation uses the `res` object to update the response with the appropriate status code and headers, and finally sends the body. Note that in Express, `res.send` will send a complete body (including calculating the `content-length` header), and `res.write` will use `transfer-encoding: chunked`. Express does not have a built-in "flush" method, but the popular `compression` middleware (which supports `accept-encoding: gzip` and similar headers) adds a `flush` method to the response; since response compression typically buffers output until a certain block size it hit, you should ensure that your integration works with your web framework's response compression feature.
243241

244242
```ts
245243
for (const [key, value] of httpGraphQLResponse.headers) {
246244
res.setHeader(key, value);
247245
}
248246
res.statusCode = httpGraphQLResponse.status || 200;
249-
res.send(httpGraphQLResponse.completeBody);
247+
248+
if (httpGraphQLResponse.body.kind === 'complete') {
249+
res.send(httpGraphQLResponse.body.string);
250+
return;
251+
}
252+
253+
for await (const chunk of httpGraphQLResponse.body.asyncIterator) {
254+
res.write(chunk);
255+
if (typeof (res as any).flush === 'function') {
256+
(res as any).flush();
257+
}
258+
}
259+
res.end();
250260
```
251261

252262
## Additional resources
253263

254-
The [`@apollo/server-integration-testsuite`](https://www.npmjs.com/package/@apollo/server-integration-testsuite) provides a set of Jest tests for authors looking to test their Apollo Server integrations.
264+
The [`@apollo/server-integration-testsuite`](https://www.npmjs.com/package/@apollo/server-integration-testsuite) provides a set of Jest tests for authors looking to test their Apollo Server integrations.

docs/source/integrations/plugins-event-reference.mdx

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,7 @@ executionDidStart?(
370370
): Promise<GraphQLRequestExecutionListener | void>;
371371
```
372372
373-
`executionDidStart` may return an object with one or both of the methods `executionDidEnd` and `willResolveField`. `executionDidEnd` is treated like an end hook: it is called after execution with any errors that occurred. `willResolveField` is documented in the next section.
373+
`executionDidStart` may return an object with one or both of the methods `executionDidEnd` and `willResolveField`. `executionDidEnd` is treated like an end hook: it is called after execution with any errors that occurred. (If the operation uses [incremental delivery](../workflow/requests#incremental-delivery-experimental) directives such as `@defer`, `executionDidEnd` is called when the fields required to fill the *initial* payload have finished executing; you can use `willSendSubsequentPayload` to hook into the end of execution for each subsequent payload.) `willResolveField` is documented in the next section.
374374
375375
### `willResolveField`
376376
@@ -424,7 +424,9 @@ const server = new ApolloServer({
424424
### `didEncounterErrors`
425425
426426
The `didEncounterErrors` event fires when Apollo Server encounters errors while
427-
parsing, validating, or executing a GraphQL operation.
427+
parsing, validating, or executing a GraphQL operation. The errors are available on `requestContext.errors`.
428+
429+
(If the operation uses [incremental delivery](../workflow/requests#incremental-delivery-experimental) directives such as `@defer`, `didEncounterErrors` is only called when errors that will be sent in the *initial* payload are encountered; you can use `didEncounterSubsequentErrors` to find out if more errors are found later.)
428430
429431
```ts
430432
didEncounterErrors?(
@@ -434,16 +436,42 @@ didEncounterErrors?(
434436
): Promise<void>;
435437
```
436438
439+
### `didEncounterSubsequentErrors`
440+
441+
The `didEncounterSubsequentErrors` event only fires for operations that use [incremental delivery](../workflow/requests#incremental-delivery-experimental) directives such as `@defer`. This hook is called when any execution errors are encountered *after* the initial payload is sent; `didEncounterErrors` is *not* called in this case. The errors in question are provided as the second argument to the hook (*not* as `requestContext.errors`, which will continue to be the list of errors sent in the initial payload).
442+
443+
```ts
444+
didEncounterSubsequentErrors?(
445+
requestContext: GraphQLRequestContextDidEncounterSubsequentErrors<TContext>,
446+
errors: ReadonlyArray<GraphQLError>,
447+
): Promise<void>;
448+
```
449+
450+
451+
437452
### `willSendResponse`
438453
439454
The `willSendResponse` event fires whenever Apollo Server is about to send a response
440455
for a GraphQL operation. This event fires (and Apollo Server sends a response) even
441456
if the GraphQL operation encounters one or more errors.
442457
458+
(If the operation uses [incremental delivery](../workflow/requests#incremental-delivery-experimental) directives such as `@defer`, `willSendResponse` is called before the *initial* payload is sent; you can use `willSendSubsequentPayload` to find out when more payloads will be sent.)
459+
443460
```ts
444461
willSendResponse?(
445462
requestContext: WithRequired<
446463
GraphQLRequestContext<TContext>, 'source' | 'queryHash'
447464
>,
448465
): Promise<void>;
449466
```
467+
468+
### `willSendSubsequentPayload`
469+
470+
The `willSendSubsequentPayload` event only fires for operations that use [incremental delivery](../workflow/requests#incremental-delivery-experimental) directives such as `@defer`. This hook is called before each payload after the initial one is sent, similarly to `willSendResponse`. The payload in question is provided as the second argument to the hook (*not* on `requestContext`). If this is the last payload, `payload.hasNext` will be false. Note that the precise format of `payload` is determined by the `graphql-js` project, and incremental delivery support has not yet (as of September 2022) been released in an official release of `graphql-js`. When the official release (expected to be `graphql@17`) is released, the format of this argument may potentially change; in this case, Apollo Server may change the precise details of this hook in a backward-incompatible way in a minor release of Apollo Server. (For now, this hook can only be called if you install a pre-release of `graphql@17`.)
471+
472+
```ts
473+
willSendSubsequentPayload?(
474+
requestContext: GraphQLRequestContextWillSendSubsequentPayload<TContext>,
475+
payload: GraphQLExperimentalFormattedSubsequentIncrementalExecutionResult,
476+
): Promise<void>;
477+
```

docs/source/migration.mdx

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ In Apollo Server 3, the `apollo-server-core` package exports built-in plugins, l
298298

299299
In Apollo Server 4, these built-in plugins are part of the main `@apollo/server` package, which also imports the `ApolloServer` class. The `@apollo/server` package exports these built-in plugins with deep exports. This means you use deep imports for each built-in plugin, enabling you to evaluate only the plugin you use in your app and making it easier for bundlers to eliminate unused code.
300300

301-
There's one exception: the `ApolloServerPluginLandingPageGraphQLPlayground` plugin is now in its own package `@apollo/server-plugin-landing-page-graphql-playground`, which you can install separately.
301+
There's one exception: the `ApolloServerPluginLandingPageGraphQLPlayground` plugin is now in its own package `@apollo/server-plugin-landing-page-graphql-playground`, which you can install separately.
302302

303303
This plugin installs the [unmaintained](https://github.com/graphql/graphql-playground/issues/1143) GraphQL Playground project as a landing page and is provided for compatibility with Apollo Server 2. This package will **not** be supported after Apollo Server 4 is released. We strongly recommend you switch to Apollo Server's 4's [default landing page](./api/plugin/landing-pages), which installs the actively maintained Apollo Sandbox.
304304

@@ -727,11 +727,12 @@ new ApolloServer<MyContext>({
727727
return {
728728
async willSendResponse(requestContext) {
729729
const { response } = requestContext;
730-
// Augment response with an extension, as long
731-
// as the operation actually executed.
732-
if ('data' in response.result) {
733-
response.result.extensions = {
734-
...(response.result.extensions),
730+
// Augment response with an extension, as long as the operation
731+
// actually executed. (The `kind` check allows you to handle
732+
// incremental delivery responses specially.)
733+
if (response.body.kind === 'single' && 'data' in response.body.singleResult) {
734+
response.body.singleResult.extensions = {
735+
...response.body.singleResult.extensions,
735736
hello: 'world',
736737
};
737738
}
@@ -1112,6 +1113,8 @@ In Apollo Server 3, you can indirectly specify an operation's context value by p
11121113

11131114
In Apollo Server 4, the `executeOperation` method optionally receives a context value directly, bypassing your `context` function. If you want to test the behavior of your `context` function, we recommend running actual HTTP requests against your server.
11141115

1116+
Additionally, the [structure of the returned `GraphQLResponse` has changed](#graphqlresponse), as described below.
1117+
11151118
So a test for Apollo Server 3 that looks like this:
11161119

11171120
<MultiCodeBlock>
@@ -1127,7 +1130,7 @@ const server = new ApolloServer({
11271130
context: async ({ req }) => ({ name: req.headers.name }),
11281131
});
11291132

1130-
const { result } = await server.executeOperation({
1133+
const result = await server.executeOperation({
11311134
query: 'query helloContext { hello }',
11321135
}, {
11331136
// A half-hearted attempt at making something vaguely like an express.Request,
@@ -1158,13 +1161,17 @@ const server = new ApolloServer<MyContext>({
11581161
},
11591162
});
11601163

1161-
const { result } = await server.executeOperation({
1164+
const { body } = await server.executeOperation({
11621165
query: 'query helloContext { hello }',
11631166
}, {
11641167
name: 'world',
11651168
});
11661169

1167-
expect(result.data?.hello).toBe('Hello world!'); // -> true
1170+
// Note the use of Node's assert rather than Jest's expect; if using
1171+
// TypeScript, `assert` will appropriately narrow the type of `body`
1172+
// and `expect` will not.
1173+
assert(body.kind === 'single');
1174+
expect(body.singleResult.data?.hello).toBe('Hello world!'); // -> true
11681175
```
11691176

11701177
</MultiCodeBlock>
@@ -1425,23 +1432,16 @@ Specifically, the `http` field is now an `HTTPGraphQLRequest` type instead of a
14251432

14261433
Apollo Server 4 refactors the [`GraphQLResponse` object](https://github.com/apollographql/apollo-server/blob/version-4/packages/server/src/externalTypes/graphql.ts#L25), which is available to plugins as `requestContext.response` and is the type `server.executeOperation` returns.
14271434

1428-
The `data`, `errors`, and `extensions` fields are now nested within an object returned by the `result` field:
1435+
In Apollo Server 3, the `data`, `errors`, and `extensions` fields existed at the top level, right beside `http`.
14291436

1430-
```ts disableCopy
1431-
export interface GraphQLResponse {
1432-
// The below result field contains an object with the
1433-
// data, errors, and extensions fields
1434-
result: FormattedExecutionResult;
1435-
http: HTTPGraphQLHead;
1436-
}
1437-
```
1437+
Because Apollo Server 4 supports incremental delivery directives such as `@defer` and `@stream` (when combined with an appropriate version of `graphql-js`), the structure of the response can now represent either a single result or multiple results, so these fields no longer exist at the top level of `GraphQLResponse`.
1438+
1439+
Instead, there is a `body` field at the top level of `GraphQLResponse`. `response.body.kind` is either `'single'` or `'incremental'`. If it is `'single'`, then incremental delivery has not been used, and `response.body.singleResult` is an object with `data`, `errors`, and `extensions` fields. If it is `'incremental'`, then `response.body.initialResult` is the initial result of the operation, and `response.body.subsequentResults` is an async iterator that will yield subsequent results. (The precise structure of `initialResult` and `subsequentResults` is defined by `graphql-js` and may change between the current pre-release of `graphql-js` v17 and its final release; if you write code that processes these values before `graphql-js` v17 has been released you may have to adapt it when the API is finalized.)
14381440

14391441
Additionally, the `data` and `extensions` fields are both type `Record<string, unknown>`, rather than `Record<string, any>`.
14401442

14411443
The value of `http.headers` is now a `Map` rather than a Fetch API `Headers` object. All keys in this map must be lower-case; if you insert any header name with capital letters, it will throw.
14421444

1443-
> We plan to implement experimental support for incremental delivery (`@defer`/`@stream`) before the v4.0.0 release and expect this to change the structure of `GraphQLResponse` further.
1444-
14451445
### `plugins` constructor argument does not take factory functions
14461446

14471447
In Apollo Server 3, each element of the `plugins` array provided to `new ApolloServer` could either be an `ApolloServerPlugin` (ie, an object with fields like `requestDidStart`) or a zero-argument "factory" function returning an `ApolloServerPlugin`.
@@ -1525,7 +1525,7 @@ Apollo Server supports [batching HTTP requests](./workflow/requests/#batching),
15251525

15261526
In Apollo Server 4, you must explicitly enable this feature by passing `allowBatchedHttpRequests: true` to the `ApolloServer` constructor.
15271527

1528-
Not all GraphQL clients support HTTP batching, and batched requests will not support incremental delivery when Apollo Server implements that feature. HTTP batching can help performance by sharing a `context` object across operations, but it can make it harder to understand the amount of work any given request does.
1528+
Not all GraphQL clients support HTTP batching, and batched requests do not support incremental delivery. HTTP batching can help performance by sharing a `context` object across operations, but it can make it harder to understand the amount of work any given request does.
15291529

15301530

15311531
### Default cache is bounded

0 commit comments

Comments
 (0)