diff --git a/.changeset/fruity-ways-tell.md b/.changeset/fruity-ways-tell.md
new file mode 100644
index 00000000000..c1d082ddbb6
--- /dev/null
+++ b/.changeset/fruity-ways-tell.md
@@ -0,0 +1,54 @@
+---
+'@apollo/server': minor
+---
+
+Apollo Server now supports the incremental delivery protocol (`@defer` and `@stream`) that ships with `graphql@17.0.0-alpha.9`. To use the current protocol, clients must send the `Accept` header with a value of `multipart/mixed; incrementalDeliverySpec=3283f8a`.
+
+Upgrading to 5.1 will depend on what version of `graphql` you have installed and whether you already support the incremental delivery protocol.
+
+## I use `graphql@16` without incremental delivery
+
+Continue using `graphql` v16 with no additional changes. Incremental delivery won't be available.
+
+## I use `graphql@16` but would like to add support for incremental delivery
+
+Install `graphql@17.0.0-alpha.9` and follow the ["Incremental delivery" guide](https://www.apollographql.com/docs/apollo-server/workflow/requests#incremental-delivery-experimental) to add the `@defer` and `@stream` directives to your schema. Clients should send the `Accept` header with a value of `multipart/mixed; incrementalDeliverySpec=3283f8a` to get multipart responses.
+
+## I use `graphql@17.0.0-alpha.2` and use incremental delivery
+
+You must upgrade to `graphql@17.0.0-alpha.9` to continue using incremental delivery. If you'd like to continue providing support for the legacy incremental protocol, install the [`@yaacovcr/transform`](https://github.com/yaacovCR/transform) package. Apollo Server will attempt to load this module when the client specifies an `Accept` header with a value of `multipart/mixed; deferSpec=20220824`. If this package is not installed, an error is returned by the server.
+
+Because Apollo Server now supports multiple versions of the incremental delivery types, the existing incremental delivery types have been renamed with an `Alpha2` suffix. If you import these types in your code, you will need to add the `Alpha2` suffix.
+
+```diff
+import type {
+- GraphQLExperimentalFormattedInitialIncrementalExecutionResult,
++ GraphQLExperimentalFormattedInitialIncrementalExecutionResultAlpha2,
+
+- GraphQLExperimentalFormattedSubsequentIncrementalExecutionResult,
++ GraphQLExperimentalFormattedSubsequentIncrementalExecutionResultAlpha2,
+
+- GraphQLExperimentalFormattedIncrementalResult,
++ GraphQLExperimentalFormattedIncrementalResultAlpha2,
+
+- GraphQLExperimentalFormattedIncrementalDeferResult,
++ GraphQLExperimentalFormattedIncrementalDeferResultAlpha2,
+
+- GraphQLExperimentalFormattedIncrementalStreamResult,
++ GraphQLExperimentalFormattedIncrementalStreamResultAlpha2,
+} from '@apollo/server';
+```
+
+Incremental delivery types for the `graphql@17.0.0-alpha.9` version are now available using the `Alpha9` suffix:
+
+```ts
+import type {
+ GraphQLExperimentalFormattedInitialIncrementalExecutionResultAlpha9,
+ GraphQLExperimentalFormattedSubsequentIncrementalExecutionResultAlpha9,
+ GraphQLExperimentalFormattedIncrementalResultAlpha9,
+ GraphQLExperimentalFormattedIncrementalDeferResultAlpha9,
+ GraphQLExperimentalFormattedIncrementalStreamResultAlpha9,
+ GraphQLExperimentalFormattedCompletedResultAlpha9,
+ GraphQLExperimentalPendingResultAlpha9,
+} from '@apollo/server';
+```
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 091e2b22837..f357ed88393 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -60,37 +60,22 @@ jobs:
- setup-node
- run: npm run test:smoke
- Full incremental delivery tests with graphql 17 alpha 2:
+ Full incremental delivery tests with graphql 17 alpha 9:
docker:
- image: cimg/base:stable
environment:
INCREMENTAL_DELIVERY_TESTS_ENABLED: t
- GRAPHQL_JS_VERSION: 17.0.0-alpha.2
+ GRAPHQL_JS_VERSION: 17.0.0-alpha.9
steps:
- setup-node:
node-version: "20"
# Install a prerelease of graphql-js 17 with incremental delivery support.
# --legacy-peer-deps because nothing expects v17 yet.
- - run: npm i --legacy-peer-deps "graphql@${GRAPHQL_JS_VERSION}"
+ - run: npm i --legacy-peer-deps "graphql@${GRAPHQL_JS_VERSION}" @yaacovcr/transform
- run: npm run test:ci
- maybe-upload-coverage
- run: npm run test:smoke
- Test with recent graphql-js alpha:
- docker:
- - image: cimg/base:stable
- environment:
- GRAPHQL_JS_VERSION: 17.0.0-alpha.9
- steps:
- - setup-node:
- node-version: "20"
- # Install a newer prerelease of graphql-js 17; we do not yet support
- # its incremental delivery format.
- # --legacy-peer-deps because nothing expects v17 yet.
- - run: npm i --legacy-peer-deps "graphql@${GRAPHQL_JS_VERSION}"
- - run: npm run test:ci
- - run: npm run test:smoke
-
Prettier:
docker:
- image: cimg/base:stable
@@ -172,8 +157,7 @@ workflows:
- Spell check
- Codegen check
- Smoke test built package
- - Full incremental delivery tests with graphql 17 alpha 2
- - Test with recent graphql-js alpha
+ - Full incremental delivery tests with graphql 17 alpha 9
- Changesets
security-scans:
jobs:
diff --git a/cspell-dict.txt b/cspell-dict.txt
index 312dbf1da1a..e321a69ee67 100644
--- a/cspell-dict.txt
+++ b/cspell-dict.txt
@@ -221,4 +221,5 @@ whatwg
Wheelock's
withrequired
xorby
+yaacovcr
YOURNAME
diff --git a/docs/source/workflow/requests.md b/docs/source/workflow/requests.md
index 54cc7c6ae26..e194f319e95 100644
--- a/docs/source/workflow/requests.md
+++ b/docs/source/workflow/requests.md
@@ -91,7 +91,7 @@ For more details, see [the CSRF prevention documentation](../security/cors#preve
## Incremental delivery (experimental)
-Incremental delivery is a [Stage 2: Draft Proposal](https://github.com/graphql/graphql-spec/pull/742) to the GraphQL specification which adds `@defer` and `@stream` executable directives. These directives allow clients to specify that parts of an operation can be sent after an initial response, so that slower fields do not delay all other fields. As of June 2025, the `graphql` library (also known as `graphql-js`) upon which Apollo Server is built implements incremental delivery only in the unreleased major version 17. If a pre-release of `graphql@17.0.0-alpha.2` is installed in your server, Apollo Server can execute these incremental delivery directives and provide streaming [`multipart/mixed`](https://github.com/graphql/graphql-over-http/blob/main/rfcs/IncrementalDelivery.md) responses.
+Incremental delivery is a [Stage 1: Proposal](https://github.com/graphql/graphql-spec/pull/1110) to the GraphQL specification which adds `@defer` and `@stream` executable directives. These directives allow clients to specify that parts of an operation can be sent after an initial response, so that slower fields do not delay all other fields. As of June 2025, the `graphql` library (also known as `graphql-js`) upon which Apollo Server is built implements incremental delivery only in the unreleased major version 17. If a pre-release of `graphql@17.0.0-alpha.9` is installed in your server, Apollo Server can execute these incremental delivery directives and provide streaming [`multipart/mixed`](https://github.com/graphql/graphql-over-http/blob/main/rfcs/IncrementalDelivery.md) responses.
Support for incremental delivery in graphql version 17 is [opt-in](https://github.com/robrichard/defer-stream-wg/discussions/12), meaning the directives are not defined by default. In order to use `@defer` or `@stream`, you must provide the appropriate definition(s) in your SDL. The definitions below can be pasted into your schema as-is:
@@ -119,8 +119,30 @@ const schema = new GraphQLSchema({
});
```
-Clients sending operations with incremental delivery directives need to explicitly indicate that they are expecting to receive `multipart/mixed` responses in an `accept` header. Moreover, because incremental delivery has not yet been finalized in the GraphQL spec and may change before the final version, they need to specify that they expect the particular response format that Apollo Server produces today via a `deferSpec` parameter. Specifically, clients prepared to accept incremental delivery responses should send an `accept` header like `multipart/mixed; deferSpec=20220824`. Note that this header implies that *only* multipart responses should be accepted; typically, clients will send an accept header like `multipart/mixed; deferSpec=20220824, application/json` indicating that either multipart or single-part responses are acceptable.
+Clients sending operations with incremental delivery directives need to explicitly indicate that they are expecting to receive `multipart/mixed` responses in an `accept` header. Moreover, because incremental delivery has not yet been finalized in the GraphQL spec and may change before the final version, they need to specify that they expect the particular response format that Apollo Server produces today via a `incrementalDeliverySpec` parameter. Specifically, clients prepared to accept incremental delivery responses should send an `accept` header like `multipart/mixed; incrementalDeliverySpec=3283f8a`. Note that this header implies that *only* multipart responses should be accepted; typically, clients will send an accept header like `multipart/mixed; incrementalDeliverySpec=3283f8a, application/json` indicating that either multipart or single-part responses are acceptable.
-> Apollo Server *only* supports the specific pre-release `graphql@17.0.0-alpha.2`. Newer alpha versions of `graphql` v17 support a slightly different format for incremental delivery, which Apollo Server does not yet support. Apollo Server 5 checks the version of `graphql` you have installed and will not attempt to support incremental delivery unless it is precisely `17.0.0-alpha.2`. We hope to support the newer incremental delivery protocol in a future release, using a different `deferSpec` value.
+> Apollo Server *only* supports the specific pre-release `graphql@17.0.0-alpha.9`. Apollo Server 5 checks the version of `graphql` you have installed and will not attempt to support incremental delivery unless it is precisely `17.0.0-alpha.9`.
You cannot combine [batching](#batching) with incremental delivery in the same request.
+
+
+
+Apollo Server 5.1 changed the required pre-release `graphql` version from `17.0.0-alpha.2` to `17.0.0-alpha.9`. If you are using 5.0 or below, use `graphql` version `17.0.0-alpha.2` instead.
+
+
+
+
+
+### Add support for the legacy incremental delivery protocol
+
+
+
+Clients may request the legacy incremental delivery protocol by specifying an `accept` header with a value of `multipart/mixed; deferSpec=20220824`. Apollo Server does not support the legacy incremental delivery protocol by default and an error will be returned to the client.
+
+You may choose to support the legacy incremental delivery protocol by installing the [`@yaacovcr/transform` package](https://github.com/yaacovCR/transform) which provides the needed utilities to format the incremental result using the legacy protocol.
+
+```sh
+npm install @yaacovcr/transform
+```
+
+There is nothing else to configure. Apollo Server will load the necessary utility if this package is installed.
diff --git a/packages/integration-testsuite/src/apolloServerTests.ts b/packages/integration-testsuite/src/apolloServerTests.ts
index 93ec9a34590..d699fafa029 100644
--- a/packages/integration-testsuite/src/apolloServerTests.ts
+++ b/packages/integration-testsuite/src/apolloServerTests.ts
@@ -1179,7 +1179,7 @@ export function defineIntegrationTestSuiteApolloServerTests(
});
(process.env.INCREMENTAL_DELIVERY_TESTS_ENABLED ? it : it.skip)(
- 'includes all fields with defer',
+ 'includes all fields with defer legacy',
async () => {
await setupApolloServerAndFetchPair({}, {}, [], true);
const response = await fetch(uri, {
@@ -1203,11 +1203,58 @@ export function defineIntegrationTestSuiteApolloServerTests(
---
content-type: application/json; charset=utf-8
- {"hasNext":true,"data":{"justAField":"a string"}}
+ {"data":{"justAField":"a string"},"hasNext":true}
---
content-type: application/json; charset=utf-8
- {"hasNext":false,"incremental":[{"path":[],"data":{"delayedFoo":{"bar":"hi"}}}]}
+ {"hasNext":false,"incremental":[{"data":{"delayedFoo":{"bar":"hi"}},"path":[]}]}
+ -----
+ "
+ `);
+ const reports = await reportIngress.promiseOfReports;
+ expect(reports.length).toBe(1);
+ expect(Object.keys(reports[0].tracesPerQuery)).toHaveLength(1);
+ const trace = Object.values(reports[0].tracesPerQuery)[0]
+ .trace?.[0] as Trace;
+ expect(trace).toBeDefined();
+ expect(trace?.root?.child?.[0].responseName).toBe('justAField');
+ expect(trace?.root?.child?.[1].responseName).toBe('delayedFoo');
+ expect(trace?.root?.child?.[1].child?.[0].responseName).toBe(
+ 'bar',
+ );
+ },
+ );
+
+ (process.env.INCREMENTAL_DELIVERY_TESTS_ENABLED ? it : it.skip)(
+ 'includes all fields with defer modern',
+ async () => {
+ await setupApolloServerAndFetchPair({}, {}, [], true);
+ const response = await fetch(uri, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ accept: 'multipart/mixed; incrementalDeliverySpec=3283f8a',
+ },
+ body: JSON.stringify({
+ query: '{ justAField ...@defer { delayedFoo { bar} } }',
+ }),
+ });
+ expect(response.status).toBe(200);
+ expect(
+ response.headers.get('content-type'),
+ ).toMatchInlineSnapshot(
+ `"multipart/mixed; boundary="-"; incrementalDeliverySpec=3283f8a"`,
+ );
+ expect(await response.text()).toMatchInlineSnapshot(`
+ "
+ ---
+ content-type: application/json; charset=utf-8
+
+ {"data":{"justAField":"a string"},"pending":[{"id":"0","path":[]}],"hasNext":true}
+ ---
+ content-type: application/json; charset=utf-8
+
+ {"hasNext":false,"incremental":[{"data":{"delayedFoo":{"bar":"hi"}},"id":"0"}],"completed":[{"id":"0"}]}
-----
"
`);
diff --git a/packages/integration-testsuite/src/httpServerTests.ts b/packages/integration-testsuite/src/httpServerTests.ts
index 75e3177afb0..aa70f54f1d0 100644
--- a/packages/integration-testsuite/src/httpServerTests.ts
+++ b/packages/integration-testsuite/src/httpServerTests.ts
@@ -958,19 +958,19 @@ export function defineIntegrationTestSuiteHttpServerTests(
.send([{ query: '{ten}' }, { query: '{twenty}' }]);
expect(res.status).toEqual(200);
expect(res.body).toMatchInlineSnapshot(`
- [
- {
- "data": {
- "ten": null,
- },
- },
- {
- "data": {
- "twenty": null,
- },
- },
- ]
- `);
+ [
+ {
+ "data": {
+ "ten": null,
+ },
+ },
+ {
+ "data": {
+ "twenty": null,
+ },
+ },
+ ]
+ `);
expect(res.headers['cache-control']).toEqual('max-age=10, private');
}
{
@@ -2166,7 +2166,7 @@ export function defineIntegrationTestSuiteHttpServerTests(
// These tests mock out execution, so that we can test the incremental
// delivery transport even if we're built against graphql@16.
describe('mocked execution', () => {
- it('basic @defer working', async () => {
+ it('basic @defer working legacy incremental', async () => {
const app = await createApp({
typeDefs,
__testing_incrementalExecutionResults: {
@@ -2199,22 +2199,72 @@ export function defineIntegrationTestSuiteHttpServerTests(
expect(res.header['content-type']).toMatchInlineSnapshot(
`"multipart/mixed; boundary="-"; deferSpec=20220824"`,
);
+ expect(res.text).toMatchInlineSnapshot(`
+ "
+ ---
+ content-type: application/json; charset=utf-8
+
+ {"hasNext":true,"data":{"first":"it works"}}
+ ---
+ content-type: application/json; charset=utf-8
+
+ {"hasNext":false,"incremental":[{"path":[],"data":{"testString":"it works"}}]}
+ -----
+ "
+ `);
+ });
+
+ it('basic @defer working graphql@17.0.0-alpha.9', async () => {
+ const app = await createApp({
+ typeDefs,
+ __testing_incrementalExecutionResults: {
+ initialResult: {
+ hasNext: true,
+ pending: [{ id: '0', path: [] }],
+ data: { first: 'it works' },
+ },
+ subsequentResults: (async function* () {
+ yield {
+ hasNext: false,
+ incremental: [
+ { id: '0', data: { testString: 'it works' } },
+ ],
+ completed: [{ id: '0' }],
+ };
+ })(),
+ },
+ });
+ const res = await request(app)
+ .post('/')
+ .set(
+ 'accept',
+ 'multipart/mixed; incrementalDeliverySpec=3283f8a, application/json',
+ )
+ // disables supertest's use of formidable for multipart
+ .parse(superagent.parse.text)
+ .send({
+ query: '{ first: testString ... @defer { testString } }',
+ });
+ expect(res.status).toEqual(200);
+ expect(res.header['content-type']).toMatchInlineSnapshot(
+ `"multipart/mixed; boundary="-"; incrementalDeliverySpec=3283f8a"`,
+ );
expect(res.text).toMatchInlineSnapshot(`
"
---
content-type: application/json; charset=utf-8
- {"hasNext":true,"data":{"first":"it works"}}
+ {"hasNext":true,"pending":[{"id":"0","path":[]}],"data":{"first":"it works"}}
---
content-type: application/json; charset=utf-8
- {"hasNext":false,"incremental":[{"path":[],"data":{"testString":"it works"}}]}
+ {"hasNext":false,"incremental":[{"id":"0","data":{"testString":"it works"}}],"completed":[{"id":"0"}]}
-----
"
`);
});
- it('first payload sent while deferred field is blocking', async () => {
+ it('first payload sent while deferred field is blocking legacy incremental', async () => {
const gotFirstChunkBarrier = resolvable();
const sendSecondChunkBarrier = resolvable();
const app = await createApp({
@@ -2269,16 +2319,88 @@ export function defineIntegrationTestSuiteHttpServerTests(
expect(res.header['content-type']).toMatchInlineSnapshot(
`"multipart/mixed; boundary="-"; deferSpec=20220824"`,
);
+ expect(res.text).toMatchInlineSnapshot(`
+ "
+ ---
+ content-type: application/json; charset=utf-8
+
+ {"hasNext":true,"data":{"testString":"it works"}}
+ ---
+ content-type: application/json; charset=utf-8
+
+ {"hasNext":false,"incremental":[{"path":[],"data":{"barrierString":"we waited"}}]}
+ -----
+ "
+ `);
+ });
+
+ it('first payload sent while deferred field is blocking graphql@17.0.0-alpha.9', async () => {
+ const gotFirstChunkBarrier = resolvable();
+ const sendSecondChunkBarrier = resolvable();
+ const app = await createApp({
+ typeDefs,
+ __testing_incrementalExecutionResults: {
+ initialResult: {
+ hasNext: true,
+ pending: [{ id: '0', path: [] }],
+ data: { testString: 'it works' },
+ },
+ subsequentResults: (async function* () {
+ await sendSecondChunkBarrier;
+ yield {
+ hasNext: false,
+ incremental: [
+ { id: '0', data: { barrierString: 'we waited' } },
+ ],
+ completed: [{ id: '0' }],
+ };
+ })(),
+ },
+ });
+ const resPromise = request(app)
+ .post('/')
+ .set(
+ 'accept',
+ `multipart/mixed; incrementalDeliverySpec=3283f8a, application/json`,
+ )
+ .parse((res, fn) => {
+ res.text = '';
+ res.setEncoding('utf8');
+ res.on('data', (chunk) => {
+ res.text += chunk;
+ if (
+ res.text.includes('it works') &&
+ res.text.endsWith('---\r\n')
+ ) {
+ gotFirstChunkBarrier.resolve();
+ }
+ });
+ res.on('end', fn);
+ })
+ .send({ query: '{ testString ... @defer { barrierString } }' })
+ // believe it or not, superagent uses `.then` to decide to actually send the request
+ .then((r) => r);
+
+ // We ensure that the second chunk can't be sent until after we've
+ // gotten back a chunk containing the value of testString.
+ await gotFirstChunkBarrier;
+ sendSecondChunkBarrier.resolve();
+
+ const res = await resPromise;
+ expect(res.status).toEqual(200);
+ expect(res.header['content-type']).toMatchInlineSnapshot(
+ `"multipart/mixed; boundary="-"; incrementalDeliverySpec=3283f8a"`,
+ );
expect(res.text).toMatchInlineSnapshot(`
"
---
content-type: application/json; charset=utf-8
- {"hasNext":true,"data":{"testString":"it works"}}
+ {"hasNext":true,"pending":[{"id":"0","path":[]}],"data":{"testString":"it works"}}
---
content-type: application/json; charset=utf-8
- {"hasNext":false,"incremental":[{"path":[],"data":{"barrierString":"we waited"}}]}
+ {"hasNext":false,"incremental":[{"id":"0","data":{"barrierString":"we waited"}}],"completed":[{"id":"0"}]}
-----
"
`);
@@ -2309,116 +2431,372 @@ export function defineIntegrationTestSuiteHttpServerTests(
},
};
- it.each([
- [undefined],
- ['application/json'],
- ['multipart/mixed'],
- ['multipart/mixed; deferSpec=12345'],
- ])('errors when @defer is used with accept: %s', async (accept) => {
- const app = await createApp({ typeDefs, resolvers });
- const req = request(app).post('/');
- if (accept) {
- req.set('accept', accept);
- }
- const res = await req.send({
- query: '{ ... @defer { testString } }',
+ describe('tests that require legacy incremental format', () => {
+ it.each([
+ [undefined],
+ ['application/json'],
+ ['multipart/mixed'],
+ ['multipart/mixed; deferSpec=12345'],
+ ])('errors when @defer is used with accept: %s', async (accept) => {
+ const app = await createApp({ typeDefs, resolvers });
+ const req = request(app).post('/');
+ if (accept) {
+ req.set('accept', accept);
+ }
+ const res = await req.send({
+ query: '{ ... @defer { testString } }',
+ });
+ expect(res.status).toEqual(406);
+ expect(res.body).toMatchInlineSnapshot(`
+ {
+ "errors": [
+ {
+ "extensions": {
+ "code": "BAD_REQUEST",
+ },
+ "message": "Apollo server received an operation that uses incremental delivery (@defer or @stream), but the client does not accept multipart/mixed HTTP responses. To enable incremental delivery support, add the HTTP header 'Accept: multipart/mixed; incrementalDeliverySpec=3283f8a' if your client supports the current incremental format or 'Accept: multipart/mixed; deferSpec=20220824' if your client supports the legacy incremental format",
+ },
+ ],
+ }
+ `);
});
- expect(res.status).toEqual(406);
- expect(res.body).toMatchInlineSnapshot(`
- {
- "errors": [
- {
- "extensions": {
- "code": "BAD_REQUEST",
- },
- "message": "Apollo server received an operation that uses incremental delivery (@defer or @stream), but the client does not accept multipart/mixed HTTP responses. To enable incremental delivery support, add the HTTP header 'Accept: multipart/mixed; deferSpec=20220824'.",
- },
- ],
- }
- `);
- });
- it.each([
- ['multipart/mixed; deferSpec=20220824'],
- ['multipart/mixed; deferSpec=20220824, application/json'],
- ['application/json, multipart/mixed; deferSpec=20220824'],
- ])('basic @defer working with accept: %s', async (accept) => {
- const app = await createApp({ typeDefs, resolvers });
- const res = await request(app)
- .post('/')
- .set('accept', accept)
- // disables supertest's use of formidable for multipart
- .parse(superagent.parse.text)
- .send({
- query: '{ first: testString ... @defer { testString } }',
- });
- expect(res.status).toEqual(200);
- expect(res.header['content-type']).toMatchInlineSnapshot(
- `"multipart/mixed; boundary="-"; deferSpec=20220824"`,
- );
- expect(res.text).toEqual(`\r
+ it.each([
+ ['multipart/mixed; deferSpec=20220824'],
+ ['multipart/mixed; deferSpec=20220824, application/json'],
+ ['application/json, multipart/mixed; deferSpec=20220824'],
+ ])('basic @defer working with accept: %s', async (accept) => {
+ const app = await createApp({ typeDefs, resolvers });
+ const res = await request(app)
+ .post('/')
+ .set('accept', accept)
+ // disables supertest's use of formidable for multipart
+ .parse(superagent.parse.text)
+ .send({
+ query: '{ first: testString ... @defer { testString } }',
+ });
+ expect(res.status).toEqual(200);
+ expect(res.header['content-type']).toMatchInlineSnapshot(
+ `"multipart/mixed; boundary="-"; deferSpec=20220824"`,
+ );
+ expect(res.text).toEqual(`\r
---\r
content-type: application/json; charset=utf-8\r
\r
-{"hasNext":true,"data":{"first":"it works"}}\r
+{"data":{"first":"it works"},"hasNext":true}\r
---\r
content-type: application/json; charset=utf-8\r
\r
-{"hasNext":false,"incremental":[{"path":[],"data":{"testString":"it works"}}]}\r
+{"hasNext":false,"incremental":[{"data":{"testString":"it works"},"path":[]}]}\r
-----\r
`);
- });
+ });
- it('first payload sent while deferred field is blocking', async () => {
- const app = await createApp({ typeDefs, resolvers });
- const gotFirstChunkBarrier = resolvable();
- const resPromise = request(app)
- .post('/')
- .set(
- 'accept',
- 'multipart/mixed; deferSpec=20220824, application/json',
- )
- .parse((res, fn) => {
- res.text = '';
- res.setEncoding('utf8');
- res.on('data', (chunk) => {
- res.text += chunk;
- if (
- res.text.includes('it works') &&
- res.text.endsWith('---\r\n')
- ) {
- gotFirstChunkBarrier.resolve();
- }
+ it.each([
+ ['multipart/mixed; deferSpec=20220824'],
+ ['multipart/mixed; deferSpec=20220824, application/json'],
+ ['application/json, multipart/mixed; deferSpec=20220824'],
+ ])('basic @stream working with accept: %s', async (accept) => {
+ const app = await createApp({
+ typeDefs: `#graphql
+ directive @stream(if: Boolean! = true, label: String, initialCount: Int! = 0) on FIELD
+ type Query {
+ testStrings: [String]
+ }
+ `,
+ resolvers: {
+ Query: {
+ testStrings: async function* () {
+ await new Promise((r) => setTimeout(r, 10));
+ yield 'it works';
+
+ await new Promise((r) => setTimeout(r, 10));
+ yield 'it works again';
+
+ await new Promise((r) => setTimeout(r, 10));
+ yield 'it works again again';
+ },
+ },
+ },
+ });
+ const res = await request(app)
+ .post('/')
+ .set('accept', accept)
+ // disables supertest's use of formidable for multipart
+ .parse(superagent.parse.text)
+ .send({
+ query: '{ testStrings @stream }',
});
- res.on('end', fn);
- })
- .send({ query: '{ testString ... @defer { barrierString } }' })
- // believe it or not, superagent uses `.then` to decide to actually send the request
- .then((r) => r);
+ expect(res.status).toEqual(200);
+ expect(res.header['content-type']).toMatchInlineSnapshot(
+ `"multipart/mixed; boundary="-"; deferSpec=20220824"`,
+ );
+ expect(res.text).toEqual(`\r
+---\r
+content-type: application/json; charset=utf-8\r
+\r
+{"data":{"testStrings":[]},"hasNext":true}\r
+---\r
+content-type: application/json; charset=utf-8\r
+\r
+{"hasNext":true,"incremental":[{"items":["it works"],"path":["testStrings",0]}]}\r
+---\r
+content-type: application/json; charset=utf-8\r
+\r
+{"hasNext":true,"incremental":[{"items":["it works again"],"path":["testStrings",1]}]}\r
+---\r
+content-type: application/json; charset=utf-8\r
+\r
+{"hasNext":true,"incremental":[{"items":["it works again again"],"path":["testStrings",2]}]}\r
+---\r
+content-type: application/json; charset=utf-8\r
+\r
+{"hasNext":false}\r
+-----\r
+`);
+ });
- // We ensure that the `barrierString` resolver isn't allowed to resolve
- // until after we've gotten back a chunk containing the value of testString.
- await gotFirstChunkBarrier;
- barrierStringBarrier.resolve();
+ it('first payload sent while deferred field is blocking', async () => {
+ const app = await createApp({ typeDefs, resolvers });
+ const gotFirstChunkBarrier = resolvable();
+ const resPromise = request(app)
+ .post('/')
+ .set(
+ 'accept',
+ 'multipart/mixed; deferSpec=20220824, application/json',
+ )
+ .parse((res, fn) => {
+ res.text = '';
+ res.setEncoding('utf8');
+ res.on('data', (chunk) => {
+ res.text += chunk;
+ if (
+ res.text.includes('it works') &&
+ res.text.endsWith('---\r\n')
+ ) {
+ gotFirstChunkBarrier.resolve();
+ }
+ });
+ res.on('end', fn);
+ })
+ .send({
+ query: '{ testString ... @defer { barrierString } }',
+ })
+ // believe it or not, superagent uses `.then` to decide to actually send the request
+ .then((r) => r);
+
+ // We ensure that the `barrierString` resolver isn't allowed to resolve
+ // until after we've gotten back a chunk containing the value of testString.
+ await gotFirstChunkBarrier;
+ barrierStringBarrier.resolve();
+
+ const res = await resPromise;
+ expect(res.status).toEqual(200);
+ expect(res.header['content-type']).toMatchInlineSnapshot(
+ `"multipart/mixed; boundary="-"; deferSpec=20220824"`,
+ );
+ expect(res.text).toMatchInlineSnapshot(`
+ "
+ ---
+ content-type: application/json; charset=utf-8
+
+ {"data":{"testString":"it works"},"hasNext":true}
+ ---
+ content-type: application/json; charset=utf-8
+
+ {"hasNext":false,"incremental":[{"data":{"barrierString":"we waited"},"path":[]}]}
+ -----
+ "
+ `);
+ });
+ });
- const res = await resPromise;
- expect(res.status).toEqual(200);
- expect(res.header['content-type']).toMatchInlineSnapshot(
- `"multipart/mixed; boundary="-"; deferSpec=20220824"`,
- );
- expect(res.text).toMatchInlineSnapshot(`
- "
- ---
- content-type: application/json; charset=utf-8
+ describe('tests that require modern incremental format', () => {
+ it.each([
+ [undefined],
+ ['application/json'],
+ ['multipart/mixed'],
+ ['multipart/mixed; deferSpec=12345'],
+ ])('errors when @defer is used with accept: %s', async (accept) => {
+ const app = await createApp({ typeDefs, resolvers });
+ const req = request(app).post('/');
+ if (accept) {
+ req.set('accept', accept);
+ }
+ const res = await req.send({
+ query: '{ ... @defer { testString } }',
+ });
+ expect(res.status).toEqual(406);
+ expect(res.body).toMatchInlineSnapshot(`
+ {
+ "errors": [
+ {
+ "extensions": {
+ "code": "BAD_REQUEST",
+ },
+ "message": "Apollo server received an operation that uses incremental delivery (@defer or @stream), but the client does not accept multipart/mixed HTTP responses. To enable incremental delivery support, add the HTTP header 'Accept: multipart/mixed; incrementalDeliverySpec=3283f8a' if your client supports the current incremental format or 'Accept: multipart/mixed; deferSpec=20220824' if your client supports the legacy incremental format",
+ },
+ ],
+ }
+ `);
+ });
- {"hasNext":true,"data":{"testString":"it works"}}
- ---
- content-type: application/json; charset=utf-8
+ it.each([
+ ['multipart/mixed; incrementalDeliverySpec=3283f8a'],
+ [
+ 'multipart/mixed; incrementalDeliverySpec=3283f8a, application/json',
+ ],
+ [
+ 'application/json, multipart/mixed; incrementalDeliverySpec=3283f8a',
+ ],
+ ])('basic @defer working with accept: %s', async (accept) => {
+ const app = await createApp({ typeDefs, resolvers });
+ const res = await request(app)
+ .post('/')
+ .set('accept', accept)
+ // disables supertest's use of formidable for multipart
+ .parse(superagent.parse.text)
+ .send({
+ query: '{ first: testString ... @defer { testString } }',
+ });
+ expect(res.status).toEqual(200);
+ expect(res.header['content-type']).toMatchInlineSnapshot(
+ `"multipart/mixed; boundary="-"; incrementalDeliverySpec=3283f8a"`,
+ );
+ expect(res.text).toEqual(`\r
+---\r
+content-type: application/json; charset=utf-8\r
+\r
+{"data":{"first":"it works"},"pending":[{"id":"0","path":[]}],"hasNext":true}\r
+---\r
+content-type: application/json; charset=utf-8\r
+\r
+{"hasNext":false,"incremental":[{"data":{"testString":"it works"},"id":"0"}],"completed":[{"id":"0"}]}\r
+-----\r
+`);
+ });
- {"hasNext":false,"incremental":[{"path":[],"data":{"barrierString":"we waited"}}]}
- -----
- "
- `);
+ it.each([
+ ['multipart/mixed; incrementalDeliverySpec=3283f8a'],
+ [
+ 'multipart/mixed; incrementalDeliverySpec=3283f8a, application/json',
+ ],
+ [
+ 'application/json, multipart/mixed; incrementalDeliverySpec=3283f8a',
+ ],
+ ])('basic @stream working with accept: %s', async (accept) => {
+ const app = await createApp({
+ typeDefs: `#graphql
+ directive @stream(if: Boolean! = true, label: String, initialCount: Int! = 0) on FIELD
+ type Query {
+ testStrings: [String]
+ }
+ `,
+ resolvers: {
+ Query: {
+ testStrings: async function* () {
+ await new Promise((r) => setTimeout(r, 10));
+ yield 'it works';
+
+ await new Promise((r) => setTimeout(r, 10));
+ yield 'it works again';
+
+ await new Promise((r) => setTimeout(r, 10));
+ yield 'it works again again';
+ },
+ },
+ },
+ });
+ const res = await request(app)
+ .post('/')
+ .set('accept', accept)
+ // disables supertest's use of formidable for multipart
+ .parse(superagent.parse.text)
+ .send({
+ query: '{ testStrings @stream }',
+ });
+ expect(res.status).toEqual(200);
+ expect(res.header['content-type']).toMatchInlineSnapshot(
+ `"multipart/mixed; boundary="-"; incrementalDeliverySpec=3283f8a"`,
+ );
+ expect(res.text).toEqual(`\r
+---\r
+content-type: application/json; charset=utf-8\r
+\r
+{"data":{"testStrings":[]},"pending":[{"id":"0","path":["testStrings"]}],"hasNext":true}\r
+---\r
+content-type: application/json; charset=utf-8\r
+\r
+{"hasNext":true,"incremental":[{"id":"0","items":["it works"]}]}\r
+---\r
+content-type: application/json; charset=utf-8\r
+\r
+{"hasNext":true,"incremental":[{"id":"0","items":["it works again"]}]}\r
+---\r
+content-type: application/json; charset=utf-8\r
+\r
+{"hasNext":true,"incremental":[{"id":"0","items":["it works again again"]}]}\r
+---\r
+content-type: application/json; charset=utf-8\r
+\r
+{"hasNext":false,"completed":[{"id":"0"}]}\r
+-----\r
+`);
+ });
+
+ it('first payload sent while deferred field is blocking', async () => {
+ const app = await createApp({ typeDefs, resolvers });
+ const gotFirstChunkBarrier = resolvable();
+ const resPromise = request(app)
+ .post('/')
+ .set(
+ 'accept',
+ 'multipart/mixed; incrementalDeliverySpec=3283f8a, application/json',
+ )
+ .parse((res, fn) => {
+ res.text = '';
+ res.setEncoding('utf8');
+ res.on('data', (chunk) => {
+ res.text += chunk;
+ if (
+ res.text.includes('it works') &&
+ res.text.endsWith('---\r\n')
+ ) {
+ gotFirstChunkBarrier.resolve();
+ }
+ });
+ res.on('end', fn);
+ })
+ .send({
+ query: '{ testString ... @defer { barrierString } }',
+ })
+ // believe it or not, superagent uses `.then` to decide to actually send the request
+ .then((r) => r);
+
+ // We ensure that the `barrierString` resolver isn't allowed to resolve
+ // until after we've gotten back a chunk containing the value of testString.
+ await gotFirstChunkBarrier;
+ barrierStringBarrier.resolve();
+
+ const res = await resPromise;
+ expect(res.status).toEqual(200);
+ expect(res.header['content-type']).toMatchInlineSnapshot(
+ `"multipart/mixed; boundary="-"; incrementalDeliverySpec=3283f8a"`,
+ );
+ expect(res.text).toMatchInlineSnapshot(`
+ "
+ ---
+ content-type: application/json; charset=utf-8
+
+ {"data":{"testString":"it works"},"pending":[{"id":"0","path":[]}],"hasNext":true}
+ ---
+ content-type: application/json; charset=utf-8
+
+ {"hasNext":false,"incremental":[{"data":{"barrierString":"we waited"},"id":"0"}],"completed":[{"id":"0"}]}
+ -----
+ "
+ `);
+ });
});
});
},
diff --git a/packages/server/src/ApolloServer.ts b/packages/server/src/ApolloServer.ts
index 0f377e97a7b..2e6380a783c 100644
--- a/packages/server/src/ApolloServer.ts
+++ b/packages/server/src/ApolloServer.ts
@@ -57,7 +57,10 @@ import type {
PersistedQueryOptions,
} from './externalTypes/index.js';
import { runPotentiallyBatchedHttpQuery } from './httpBatching.js';
-import type { GraphQLExperimentalIncrementalExecutionResults } from './incrementalDeliveryPolyfill.js';
+import type {
+ GraphQLExperimentalIncrementalExecutionResultsAlpha2,
+ GraphQLExperimentalIncrementalExecutionResultsAlpha9,
+} from './incrementalDeliveryPolyfill.js';
import { pluginIsInternal, type InternalPluginId } from './internalPlugin.js';
import {
preventCsrf,
@@ -162,7 +165,9 @@ export interface ApolloServerInternals {
fieldResolver?: GraphQLFieldResolver;
// TODO(AS6): remove this option.
status400ForVariableCoercionErrors?: boolean;
- __testing_incrementalExecutionResults?: GraphQLExperimentalIncrementalExecutionResults;
+ __testing_incrementalExecutionResults?:
+ | GraphQLExperimentalIncrementalExecutionResultsAlpha2
+ | GraphQLExperimentalIncrementalExecutionResultsAlpha9;
stringifyResult: (
value: FormattedExecutionResult,
) => string | Promise;
@@ -1205,7 +1210,8 @@ export class ApolloServer {
// by the order in the list we provide, so we put text/html last.
MEDIA_TYPES.APPLICATION_JSON,
MEDIA_TYPES.APPLICATION_GRAPHQL_RESPONSE_JSON,
- MEDIA_TYPES.MULTIPART_MIXED_EXPERIMENTAL,
+ MEDIA_TYPES.MULTIPART_MIXED_EXPERIMENTAL_ALPHA_2,
+ MEDIA_TYPES.MULTIPART_MIXED_EXPERIMENTAL_ALPHA_9,
MEDIA_TYPES.MULTIPART_MIXED_NO_DEFER_SPEC,
MEDIA_TYPES.TEXT_HTML,
]) === MEDIA_TYPES.TEXT_HTML
@@ -1401,7 +1407,10 @@ export const MEDIA_TYPES = {
// We do *not* currently support this content-type; we will once incremental
// delivery is part of the official GraphQL spec.
MULTIPART_MIXED_NO_DEFER_SPEC: 'multipart/mixed',
- MULTIPART_MIXED_EXPERIMENTAL: 'multipart/mixed; deferSpec=20220824',
+ MULTIPART_MIXED_EXPERIMENTAL_ALPHA_2: 'multipart/mixed; deferSpec=20220824',
+ // spec version represents the commit hash of 17.0.0-alpha.9
+ MULTIPART_MIXED_EXPERIMENTAL_ALPHA_9:
+ 'multipart/mixed; incrementalDeliverySpec=3283f8a',
TEXT_HTML: 'text/html',
};
diff --git a/packages/server/src/externalTypes/constructor.ts b/packages/server/src/externalTypes/constructor.ts
index 9f2dd7d11bf..285edb2852a 100644
--- a/packages/server/src/externalTypes/constructor.ts
+++ b/packages/server/src/externalTypes/constructor.ts
@@ -19,7 +19,10 @@ import type { KeyValueCache } from '@apollo/utils.keyvaluecache';
import type { GatewayInterface } from '@apollo/server-gateway-interface';
import type { ApolloServerPlugin } from './plugins.js';
import type { BaseContext } from './index.js';
-import type { GraphQLExperimentalIncrementalExecutionResults } from '../incrementalDeliveryPolyfill.js';
+import type {
+ GraphQLExperimentalIncrementalExecutionResultsAlpha2,
+ GraphQLExperimentalIncrementalExecutionResultsAlpha9,
+} from '../incrementalDeliveryPolyfill.js';
export type DocumentStore = KeyValueCache;
@@ -119,7 +122,9 @@ interface ApolloServerOptionsBase {
status400ForVariableCoercionErrors?: boolean;
// For testing only.
- __testing_incrementalExecutionResults?: GraphQLExperimentalIncrementalExecutionResults;
+ __testing_incrementalExecutionResults?:
+ | GraphQLExperimentalIncrementalExecutionResultsAlpha2
+ | GraphQLExperimentalIncrementalExecutionResultsAlpha9;
}
export interface ApolloServerOptionsWithGateway
diff --git a/packages/server/src/externalTypes/graphql.ts b/packages/server/src/externalTypes/graphql.ts
index 20090b4daa7..ec433338043 100644
--- a/packages/server/src/externalTypes/graphql.ts
+++ b/packages/server/src/externalTypes/graphql.ts
@@ -9,9 +9,13 @@ import type { BaseContext } from './context.js';
import type { HTTPGraphQLHead, HTTPGraphQLRequest } from './http.js';
import type { WithRequired } from '@apollo/utils.withrequired';
import type {
- GraphQLExperimentalFormattedInitialIncrementalExecutionResult,
- GraphQLExperimentalFormattedSubsequentIncrementalExecutionResult,
-} from './incrementalDeliveryPolyfill.js';
+ GraphQLExperimentalFormattedInitialIncrementalExecutionResultAlpha2,
+ GraphQLExperimentalFormattedSubsequentIncrementalExecutionResultAlpha2,
+} from './incrementalDeliveryPolyfillAlpha2.js';
+import type {
+ GraphQLExperimentalFormattedInitialIncrementalExecutionResultAlpha9,
+ GraphQLExperimentalFormattedSubsequentIncrementalExecutionResultAlpha9,
+} from './incrementalDeliveryPolyfillAlpha9.js';
export interface GraphQLRequest<
TVariables extends VariableValues = VariableValues,
@@ -37,8 +41,13 @@ export type GraphQLResponseBody> =
}
| {
kind: 'incremental';
- initialResult: GraphQLExperimentalFormattedInitialIncrementalExecutionResult;
- subsequentResults: AsyncIterable;
+ initialResult: GraphQLExperimentalFormattedInitialIncrementalExecutionResultAlpha2;
+ subsequentResults: AsyncIterable;
+ }
+ | {
+ kind: 'incremental';
+ initialResult: GraphQLExperimentalFormattedInitialIncrementalExecutionResultAlpha9;
+ subsequentResults: AsyncIterable;
};
export type GraphQLInProgressResponse> = {
diff --git a/packages/server/src/externalTypes/incrementalDeliveryPolyfill.ts b/packages/server/src/externalTypes/incrementalDeliveryPolyfillAlpha2.ts
similarity index 77%
rename from packages/server/src/externalTypes/incrementalDeliveryPolyfill.ts
rename to packages/server/src/externalTypes/incrementalDeliveryPolyfillAlpha2.ts
index 6ddbc419e1b..c5b6c1cb661 100644
--- a/packages/server/src/externalTypes/incrementalDeliveryPolyfill.ts
+++ b/packages/server/src/externalTypes/incrementalDeliveryPolyfillAlpha2.ts
@@ -11,36 +11,39 @@ interface ObjMap {
[key: string]: T;
}
-export interface GraphQLExperimentalFormattedInitialIncrementalExecutionResult<
+export interface GraphQLExperimentalFormattedInitialIncrementalExecutionResultAlpha2<
TData = ObjMap,
TExtensions = ObjMap,
> extends FormattedExecutionResult {
hasNext: boolean;
incremental?: ReadonlyArray<
- GraphQLExperimentalFormattedIncrementalResult
+ GraphQLExperimentalFormattedIncrementalResultAlpha2
>;
extensions?: TExtensions;
}
-export interface GraphQLExperimentalFormattedSubsequentIncrementalExecutionResult<
+export interface GraphQLExperimentalFormattedSubsequentIncrementalExecutionResultAlpha2<
TData = ObjMap,
TExtensions = ObjMap,
> {
hasNext: boolean;
incremental?: ReadonlyArray<
- GraphQLExperimentalFormattedIncrementalResult
+ GraphQLExperimentalFormattedIncrementalResultAlpha2
>;
extensions?: TExtensions;
}
-export type GraphQLExperimentalFormattedIncrementalResult<
+export type GraphQLExperimentalFormattedIncrementalResultAlpha2<
TData = ObjMap,
TExtensions = ObjMap,
> =
- | GraphQLExperimentalFormattedIncrementalDeferResult
- | GraphQLExperimentalFormattedIncrementalStreamResult;
+ | GraphQLExperimentalFormattedIncrementalDeferResultAlpha2
+ | GraphQLExperimentalFormattedIncrementalStreamResultAlpha2<
+ TData,
+ TExtensions
+ >;
-export interface GraphQLExperimentalFormattedIncrementalDeferResult<
+export interface GraphQLExperimentalFormattedIncrementalDeferResultAlpha2<
TData = ObjMap,
TExtensions = ObjMap,
> extends FormattedExecutionResult {
@@ -48,7 +51,7 @@ export interface GraphQLExperimentalFormattedIncrementalDeferResult<
label?: string;
}
-export interface GraphQLExperimentalFormattedIncrementalStreamResult<
+export interface GraphQLExperimentalFormattedIncrementalStreamResultAlpha2<
TData = Array,
TExtensions = ObjMap,
> {
diff --git a/packages/server/src/externalTypes/incrementalDeliveryPolyfillAlpha9.ts b/packages/server/src/externalTypes/incrementalDeliveryPolyfillAlpha9.ts
new file mode 100644
index 00000000000..37effbff5f3
--- /dev/null
+++ b/packages/server/src/externalTypes/incrementalDeliveryPolyfillAlpha9.ts
@@ -0,0 +1,82 @@
+import type { FormattedExecutionResult, GraphQLFormattedError } from 'graphql';
+
+// This file defines types used in our public interface that will be imported
+// from `graphql-js` once graphql 17 is released. It is possible that these
+// types will change slightly before the final v17 is released, in which case
+// the relevant parts of our API may change incompatibly in a minor version of
+// AS5; this should not affect any users who aren't explicitly installing
+// pre-releases of graphql 17.
+
+interface ObjMap {
+ [key: string]: T;
+}
+
+export interface GraphQLExperimentalFormattedInitialIncrementalExecutionResultAlpha9<
+ TData = ObjMap,
+ TExtensions = ObjMap,
+> extends FormattedExecutionResult {
+ data: TData;
+ pending: ReadonlyArray;
+ hasNext: boolean;
+ extensions?: TExtensions;
+}
+
+export interface GraphQLExperimentalFormattedSubsequentIncrementalExecutionResultAlpha9<
+ TData = ObjMap,
+ TExtensions = ObjMap,
+> {
+ hasNext: boolean;
+ pending?: ReadonlyArray;
+ incremental?: ReadonlyArray<
+ GraphQLExperimentalFormattedIncrementalResultAlpha9
+ >;
+ completed?: ReadonlyArray;
+ extensions?: TExtensions;
+}
+
+export type GraphQLExperimentalFormattedIncrementalResultAlpha9<
+ TData = ObjMap,
+ TExtensions = ObjMap,
+> =
+ | GraphQLExperimentalFormattedIncrementalDeferResultAlpha9
+ | GraphQLExperimentalFormattedIncrementalStreamResultAlpha9<
+ TData,
+ TExtensions
+ >;
+
+export interface GraphQLExperimentalFormattedIncrementalDeferResultAlpha9<
+ TData = ObjMap,
+ TExtensions = ObjMap,
+> {
+ errors?: ReadonlyArray;
+ data: TData;
+ id: string;
+ subPath?: ReadonlyArray;
+ extensions?: TExtensions;
+}
+
+export interface GraphQLExperimentalFormattedIncrementalStreamResultAlpha9<
+ TData = Array,
+ TExtensions = ObjMap,
+> {
+ errors?: ReadonlyArray;
+ items: TData;
+ id: string;
+ subPath?: ReadonlyArray;
+ extensions?: TExtensions;
+}
+
+export interface GraphQLExperimentalPendingResultAlpha9 {
+ id: string;
+ path: ReadonlyArray;
+ label?: string;
+}
+
+// Deviation. The type implemented in alpha.9 is wrong. The type below is the
+// correct type implementation. We may or may not need to provide a patch.
+//
+// This has been fixed by https://github.com/graphql/graphql-js/pull/4481
+export interface GraphQLExperimentalFormattedCompletedResultAlpha9 {
+ id: string;
+ errors?: ReadonlyArray;
+}
diff --git a/packages/server/src/externalTypes/index.ts b/packages/server/src/externalTypes/index.ts
index 44825446a73..4f64b2c0c2a 100644
--- a/packages/server/src/externalTypes/index.ts
+++ b/packages/server/src/externalTypes/index.ts
@@ -51,9 +51,19 @@ export type {
} from './constructor.js';
export type {
- GraphQLExperimentalFormattedInitialIncrementalExecutionResult,
- GraphQLExperimentalFormattedSubsequentIncrementalExecutionResult,
- GraphQLExperimentalFormattedIncrementalResult,
- GraphQLExperimentalFormattedIncrementalDeferResult,
- GraphQLExperimentalFormattedIncrementalStreamResult,
-} from './incrementalDeliveryPolyfill.js';
+ GraphQLExperimentalFormattedInitialIncrementalExecutionResultAlpha2,
+ GraphQLExperimentalFormattedSubsequentIncrementalExecutionResultAlpha2,
+ GraphQLExperimentalFormattedIncrementalResultAlpha2,
+ GraphQLExperimentalFormattedIncrementalDeferResultAlpha2,
+ GraphQLExperimentalFormattedIncrementalStreamResultAlpha2,
+} from './incrementalDeliveryPolyfillAlpha2.js';
+
+export type {
+ GraphQLExperimentalFormattedInitialIncrementalExecutionResultAlpha9,
+ GraphQLExperimentalFormattedSubsequentIncrementalExecutionResultAlpha9,
+ GraphQLExperimentalFormattedIncrementalStreamResultAlpha9,
+ GraphQLExperimentalFormattedIncrementalDeferResultAlpha9,
+ GraphQLExperimentalFormattedCompletedResultAlpha9,
+ GraphQLExperimentalFormattedIncrementalResultAlpha9,
+ GraphQLExperimentalPendingResultAlpha9,
+} from './incrementalDeliveryPolyfillAlpha9.js';
diff --git a/packages/server/src/externalTypes/plugins.ts b/packages/server/src/externalTypes/plugins.ts
index 32dd89604dc..57151621256 100644
--- a/packages/server/src/externalTypes/plugins.ts
+++ b/packages/server/src/externalTypes/plugins.ts
@@ -9,7 +9,7 @@ import type { GraphQLError, GraphQLResolveInfo, GraphQLSchema } from 'graphql';
import type { ApolloConfig } from './constructor.js';
import type { BaseContext } from './context.js';
import type { GraphQLResponse } from './graphql.js';
-import type { GraphQLExperimentalFormattedSubsequentIncrementalExecutionResult } from './incrementalDeliveryPolyfill.js';
+import type { GraphQLExperimentalFormattedSubsequentIncrementalExecutionResultAlpha2 } from './incrementalDeliveryPolyfillAlpha2.js';
import type {
GraphQLRequestContext,
GraphQLRequestContextDidEncounterErrors,
@@ -23,6 +23,7 @@ import type {
GraphQLRequestContextWillSendResponse,
GraphQLRequestContextWillSendSubsequentPayload,
} from './requestPipeline.js';
+import type { GraphQLExperimentalFormattedSubsequentIncrementalExecutionResultAlpha9 } from './incrementalDeliveryPolyfillAlpha9.js';
export interface GraphQLServerContext {
readonly logger: Logger;
@@ -180,7 +181,9 @@ export interface GraphQLRequestListener {
// You can use hasNext to tell if this is the end or not.
willSendSubsequentPayload?(
requestContext: GraphQLRequestContextWillSendSubsequentPayload,
- payload: GraphQLExperimentalFormattedSubsequentIncrementalExecutionResult,
+ payload:
+ | GraphQLExperimentalFormattedSubsequentIncrementalExecutionResultAlpha2
+ | GraphQLExperimentalFormattedSubsequentIncrementalExecutionResultAlpha9,
): Promise;
}
diff --git a/packages/server/src/incrementalDeliveryPolyfill.ts b/packages/server/src/incrementalDeliveryPolyfill.ts
index 1dbc1b7a746..22a1e2e8467 100644
--- a/packages/server/src/incrementalDeliveryPolyfill.ts
+++ b/packages/server/src/incrementalDeliveryPolyfill.ts
@@ -4,6 +4,8 @@ import {
type ExecutionResult,
type GraphQLError,
} from 'graphql';
+import { BadRequestError } from './internalErrorClasses.js';
+import { MEDIA_TYPES } from './ApolloServer.js';
// This file "polyfills" graphql@17's experimentalExecuteIncrementally (by
// returning a function that does not understand incremental directives if
@@ -13,36 +15,38 @@ import {
interface ObjMap {
[key: string]: T;
}
-export interface GraphQLExperimentalInitialIncrementalExecutionResult<
+
+// 17.0.0-alpha.2
+export interface GraphQLExperimentalInitialIncrementalExecutionResultAlpha2<
TData = ObjMap,
TExtensions = ObjMap,
> extends ExecutionResult {
hasNext: boolean;
incremental?: ReadonlyArray<
- GraphQLExperimentalIncrementalResult
+ GraphQLExperimentalIncrementalResultAlpha2
>;
extensions?: TExtensions;
}
-export interface GraphQLExperimentalSubsequentIncrementalExecutionResult<
+export interface GraphQLExperimentalSubsequentIncrementalExecutionResultAlpha2<
TData = ObjMap,
TExtensions = ObjMap,
> {
hasNext: boolean;
incremental?: ReadonlyArray<
- GraphQLExperimentalIncrementalResult
+ GraphQLExperimentalIncrementalResultAlpha2
>;
extensions?: TExtensions;
}
-type GraphQLExperimentalIncrementalResult<
+type GraphQLExperimentalIncrementalResultAlpha2<
TData = ObjMap,
TExtensions = ObjMap,
> =
- | GraphQLExperimentalIncrementalDeferResult
- | GraphQLExperimentalIncrementalStreamResult;
+ | GraphQLExperimentalIncrementalDeferResultAlpha2
+ | GraphQLExperimentalIncrementalStreamResultAlpha2;
-interface GraphQLExperimentalIncrementalDeferResult<
+interface GraphQLExperimentalIncrementalDeferResultAlpha2<
TData = ObjMap,
TExtensions = ObjMap,
> extends ExecutionResult {
@@ -50,7 +54,7 @@ interface GraphQLExperimentalIncrementalDeferResult<
label?: string;
}
-interface GraphQLExperimentalIncrementalStreamResult<
+interface GraphQLExperimentalIncrementalStreamResultAlpha2<
TData = Array,
TExtensions = ObjMap,
> {
@@ -61,16 +65,112 @@ interface GraphQLExperimentalIncrementalStreamResult<
extensions?: TExtensions;
}
-export interface GraphQLExperimentalIncrementalExecutionResults<
+export interface GraphQLExperimentalIncrementalExecutionResultsAlpha2<
TData = ObjMap,
TExtensions = ObjMap,
> {
- initialResult: GraphQLExperimentalInitialIncrementalExecutionResult<
+ initialResult: GraphQLExperimentalInitialIncrementalExecutionResultAlpha2<
TData,
TExtensions
>;
subsequentResults: AsyncGenerator<
- GraphQLExperimentalSubsequentIncrementalExecutionResult,
+ GraphQLExperimentalSubsequentIncrementalExecutionResultAlpha2<
+ TData,
+ TExtensions
+ >,
+ void,
+ void
+ >;
+}
+
+// 17.0.0-alpha.9
+export interface GraphQLExperimentalInitialIncrementalExecutionResultAlpha9<
+ TData = ObjMap,
+ TExtensions = ObjMap,
+> extends ExecutionResult {
+ data: TData;
+ pending: ReadonlyArray;
+ hasNext: true;
+ extensions?: TExtensions;
+}
+
+export interface GraphQLExperimentalSubsequentIncrementalExecutionResultAlpha9<
+ TData = ObjMap,
+ TExtensions = ObjMap,
+> {
+ pending?: ReadonlyArray;
+ incremental?: ReadonlyArray<
+ GraphQLExperimentalIncrementalResultAlpha9
+ >;
+ completed?: ReadonlyArray;
+ hasNext: boolean;
+ extensions?: TExtensions;
+}
+
+interface GraphQLExperimentalExecutionGroupResultAlpha9<
+ TData = ObjMap,
+> {
+ errors?: ReadonlyArray;
+ data: TData;
+}
+
+interface GraphQLExperimentalIncrementalDeferResultAlpha9<
+ TData = ObjMap,
+ TExtensions = ObjMap,
+> extends GraphQLExperimentalExecutionGroupResultAlpha9 {
+ id: string;
+ subPath?: ReadonlyArray;
+ extensions?: TExtensions;
+}
+
+interface GraphQLExperimentalStreamItemsRecordResultAlpha9<
+ TData = ReadonlyArray,
+> {
+ errors?: ReadonlyArray;
+ items: TData;
+}
+
+interface GraphQLExperimentalIncrementalStreamResultAlpha9<
+ TData = ReadonlyArray,
+ TExtensions = ObjMap,
+> extends GraphQLExperimentalStreamItemsRecordResultAlpha9 {
+ id: string;
+ subPath?: ReadonlyArray;
+ extensions?: TExtensions;
+}
+
+type GraphQLExperimentalIncrementalResultAlpha9<
+ TData = unknown,
+ TExtensions = ObjMap,
+> =
+ | GraphQLExperimentalIncrementalDeferResultAlpha9
+ | GraphQLExperimentalIncrementalStreamResultAlpha9;
+
+interface GraphQLExperimentalPendingResultAlpha9 {
+ id: string;
+ path: ReadonlyArray;
+ label?: string;
+}
+
+interface GraphQLExperimentalCompletedResultAlpha9 {
+ id: string;
+ errors?: ReadonlyArray;
+}
+
+export interface GraphQLExperimentalIncrementalExecutionResultsAlpha9<
+ TInitial = ObjMap,
+ TSubsequent = unknown,
+ TExtensions = ObjMap,
+> {
+ initialResult: GraphQLExperimentalInitialIncrementalExecutionResultAlpha9<
+ TInitial,
+ TExtensions
+ >;
+ subsequentResults: AsyncGenerator<
+ GraphQLExperimentalSubsequentIncrementalExecutionResultAlpha9<
+ TSubsequent,
+ TExtensions
+ >,
void,
void
>;
@@ -86,20 +186,45 @@ let graphqlExperimentalExecuteIncrementally:
| ((
args: ExecutionArgs,
) => PromiseOrValue<
- ExecutionResult | GraphQLExperimentalIncrementalExecutionResults
+ ExecutionResult | GraphQLExperimentalIncrementalExecutionResultsAlpha9
>)
| null
| undefined = undefined;
+let legacyExecuteIncrementally:
+ | ((
+ args: ExecutionArgs,
+ ) => PromiseOrValue<
+ ExecutionResult | GraphQLExperimentalIncrementalExecutionResultsAlpha2
+ >)
+ | null
+ | undefined;
+
+async function tryToLoadLegacyExecuteIncrementally() {
+ try {
+ // @ts-ignore `@yaacovcr/transform` is an optional peer dependency
+ const transform = await import('@yaacovcr/transform');
+ legacyExecuteIncrementally = transform.legacyExecuteIncrementally;
+ } catch {
+ legacyExecuteIncrementally = null;
+ }
+}
+
async function tryToLoadGraphQL17() {
- if (graphqlExperimentalExecuteIncrementally !== undefined) {
+ if (
+ graphqlExperimentalExecuteIncrementally !== undefined &&
+ legacyExecuteIncrementally !== undefined
+ ) {
return;
}
+
const graphql = await import('graphql');
if (
- graphql.version === '17.0.0-alpha.2' &&
+ graphql.version === '17.0.0-alpha.9' &&
'experimentalExecuteIncrementally' in graphql
) {
+ await tryToLoadLegacyExecuteIncrementally();
+
graphqlExperimentalExecuteIncrementally = (graphql as any)
.experimentalExecuteIncrementally;
} else {
@@ -107,10 +232,37 @@ async function tryToLoadGraphQL17() {
}
}
-export async function executeIncrementally(
- args: ExecutionArgs,
-): Promise {
+export async function executeIncrementally({
+ useLegacyIncremental,
+ ...args
+}: ExecutionArgs & { useLegacyIncremental?: boolean }): Promise<
+ | ExecutionResult
+ | GraphQLExperimentalIncrementalExecutionResultsAlpha2
+ | GraphQLExperimentalIncrementalExecutionResultsAlpha9
+> {
await tryToLoadGraphQL17();
+
+ if (useLegacyIncremental) {
+ if (legacyExecuteIncrementally) {
+ return legacyExecuteIncrementally(args);
+ }
+
+ // Only throw if the server supports incremental delivery with the new
+ // format, but not the legacy format. We don't want to accidentally send
+ // alpha.9 format when the client requested the legacy format.
+ if (graphqlExperimentalExecuteIncrementally) {
+ throw new BadRequestError(
+ 'Apollo Server received an operation that uses incremental delivery ' +
+ '(@defer or @stream) with the legacy incremental format, but the server ' +
+ 'does not support the legacy incremental delivery format. Add the HTTP ' +
+ `header: 'Accept: ${MEDIA_TYPES.MULTIPART_MIXED_EXPERIMENTAL_ALPHA_9}' ` +
+ 'to use the current incremental delivery format',
+ // Use 406 Not Accepted
+ { extensions: { http: { status: 406 } } },
+ );
+ }
+ }
+
if (graphqlExperimentalExecuteIncrementally) {
return graphqlExperimentalExecuteIncrementally(args);
}
diff --git a/packages/server/src/requestPipeline.ts b/packages/server/src/requestPipeline.ts
index 362762e21c5..3befb9e95c1 100644
--- a/packages/server/src/requestPipeline.ts
+++ b/packages/server/src/requestPipeline.ts
@@ -40,7 +40,8 @@ import type {
GraphQLRequestExecutionListener,
BaseContext,
GraphQLResponse,
- GraphQLExperimentalFormattedSubsequentIncrementalExecutionResult,
+ GraphQLExperimentalFormattedSubsequentIncrementalExecutionResultAlpha2,
+ GraphQLExperimentalFormattedSubsequentIncrementalExecutionResultAlpha9,
} from './externalTypes/index.js';
import {
@@ -52,10 +53,11 @@ import {
import { makeGatewayGraphQLRequestContext } from './utils/makeGatewayGraphQLRequestContext.js';
import { mergeHTTPGraphQLHead, newHTTPGraphQLHead } from './runHttpQuery.js';
-import type {
- ApolloServer,
- ApolloServerInternals,
- SchemaDerivedData,
+import {
+ MEDIA_TYPES,
+ type ApolloServer,
+ type ApolloServerInternals,
+ type SchemaDerivedData,
} from './ApolloServer.js';
import { isDefined } from './utils/isDefined.js';
import type {
@@ -64,10 +66,13 @@ import type {
} from './externalTypes/requestPipeline.js';
import {
executeIncrementally,
- type GraphQLExperimentalInitialIncrementalExecutionResult,
- type GraphQLExperimentalSubsequentIncrementalExecutionResult,
+ type GraphQLExperimentalSubsequentIncrementalExecutionResultAlpha9,
+ type GraphQLExperimentalInitialIncrementalExecutionResultAlpha2,
+ type GraphQLExperimentalInitialIncrementalExecutionResultAlpha9,
+ type GraphQLExperimentalSubsequentIncrementalExecutionResultAlpha2,
} from './incrementalDeliveryPolyfill.js';
import { HeaderMap } from './utils/HeaderMap.js';
+import Negotiator from 'negotiator';
export const APQ_CACHE_PREFIX = 'apq:';
@@ -109,8 +114,12 @@ type SemiFormattedExecuteIncrementallyResults =
singleResult: ExecutionResult;
}
| {
- initialResult: GraphQLExperimentalInitialIncrementalExecutionResult;
- subsequentResults: AsyncIterable;
+ initialResult: GraphQLExperimentalInitialIncrementalExecutionResultAlpha2;
+ subsequentResults: AsyncIterable;
+ }
+ | {
+ initialResult: GraphQLExperimentalInitialIncrementalExecutionResultAlpha9;
+ subsequentResults: AsyncIterable;
};
export async function processGraphQLRequest(
@@ -441,9 +450,16 @@ export async function processGraphQLRequest(
}
try {
- const fullResult = await execute(
- requestContext as GraphQLRequestContextExecutionDidStart,
- );
+ const fullResult = await execute({
+ ...requestContext,
+ useLegacyIncremental:
+ new Negotiator({
+ headers: { accept: request.http?.headers.get('accept') },
+ }).mediaType([
+ MEDIA_TYPES.MULTIPART_MIXED_EXPERIMENTAL_ALPHA_9,
+ MEDIA_TYPES.MULTIPART_MIXED_EXPERIMENTAL_ALPHA_2,
+ ]) === MEDIA_TYPES.MULTIPART_MIXED_EXPERIMENTAL_ALPHA_2,
+ } as GraphQLRequestContextExecutionDidStart);
const result =
'singleResult' in fullResult
? fullResult.singleResult
@@ -540,9 +556,12 @@ export async function processGraphQLRequest(
}
return requestContext.response as GraphQLResponse; // cast checked on previous line
- async function execute(
- requestContext: GraphQLRequestContextExecutionDidStart,
- ): Promise {
+ async function execute({
+ useLegacyIncremental,
+ ...requestContext
+ }: GraphQLRequestContextExecutionDidStart & {
+ useLegacyIncremental?: boolean;
+ }): Promise {
const { request, document } = requestContext;
if (internals.__testing_incrementalExecutionResults) {
@@ -564,13 +583,19 @@ export async function processGraphQLRequest(
variableValues: request.variables,
operationName: request.operationName,
fieldResolver: internals.fieldResolver,
+ useLegacyIncremental,
});
if ('initialResult' in resultOrResults) {
return {
initialResult: resultOrResults.initialResult,
- subsequentResults: formatErrorsInSubsequentResults(
- resultOrResults.subsequentResults,
- ),
+ subsequentResults:
+ 'pending' in resultOrResults.initialResult
+ ? formatErrorsInSubsequentResultsAlpha9(
+ resultOrResults.subsequentResults as AsyncIterable,
+ )
+ : formatErrorsInSubsequentResultsAlpha2(
+ resultOrResults.subsequentResults,
+ ),
};
} else {
return { singleResult: resultOrResults };
@@ -578,11 +603,61 @@ export async function processGraphQLRequest(
}
}
- async function* formatErrorsInSubsequentResults(
- results: AsyncIterable,
- ): AsyncIterable {
+ async function* formatErrorsInSubsequentResultsAlpha2(
+ results: AsyncIterable,
+ ): AsyncIterable {
+ for await (const result of results) {
+ const payload: GraphQLExperimentalFormattedSubsequentIncrementalExecutionResultAlpha2 =
+ result.incremental
+ ? {
+ ...result,
+ incremental: await seriesAsyncMap(
+ result.incremental,
+ async (incrementalResult) => {
+ const { errors } = incrementalResult;
+ if (errors) {
+ await Promise.all(
+ requestListeners.map((l) =>
+ l.didEncounterSubsequentErrors?.(
+ requestContext as GraphQLRequestContextDidEncounterSubsequentErrors,
+ errors,
+ ),
+ ),
+ );
+
+ return {
+ ...incrementalResult,
+ // Note that any `http` extensions in errors have no
+ // effect, because we've already sent the status code
+ // and response headers.
+ errors: formatErrors(errors).formattedErrors,
+ };
+ }
+ return incrementalResult;
+ },
+ ),
+ }
+ : result;
+
+ // Invoke hook, which is allowed to mutate payload if it really wants to.
+ await Promise.all(
+ requestListeners.map((l) =>
+ l.willSendSubsequentPayload?.(
+ requestContext as GraphQLRequestContextWillSendSubsequentPayload,
+ payload,
+ ),
+ ),
+ );
+
+ yield payload;
+ }
+ }
+
+ async function* formatErrorsInSubsequentResultsAlpha9(
+ results: AsyncIterable,
+ ): AsyncIterable {
for await (const result of results) {
- const payload: GraphQLExperimentalFormattedSubsequentIncrementalExecutionResult =
+ const payload: GraphQLExperimentalFormattedSubsequentIncrementalExecutionResultAlpha9 =
result.incremental
? {
...result,
@@ -614,6 +689,36 @@ export async function processGraphQLRequest(
}
: result;
+ if (result.completed) {
+ payload.completed = await seriesAsyncMap(
+ result.completed,
+ async (completedResult) => {
+ const { errors } = completedResult;
+
+ if (errors) {
+ await Promise.all(
+ requestListeners.map((l) =>
+ l.didEncounterSubsequentErrors?.(
+ requestContext as GraphQLRequestContextDidEncounterSubsequentErrors,
+ errors,
+ ),
+ ),
+ );
+
+ return {
+ ...completedResult,
+ // Note that any `http` extensions in errors have no
+ // effect, because we've already sent the status code
+ // and response headers.
+ errors: formatErrors(errors).formattedErrors,
+ };
+ }
+
+ return completedResult;
+ },
+ );
+ }
+
// Invoke hook, which is allowed to mutate payload if it really wants to.
await Promise.all(
requestListeners.map((l) =>
diff --git a/packages/server/src/runHttpQuery.ts b/packages/server/src/runHttpQuery.ts
index 06341dd1b30..bebdc248297 100644
--- a/packages/server/src/runHttpQuery.ts
+++ b/packages/server/src/runHttpQuery.ts
@@ -1,8 +1,9 @@
import type {
BaseContext,
- GraphQLExperimentalFormattedIncrementalResult,
- GraphQLExperimentalFormattedInitialIncrementalExecutionResult,
- GraphQLExperimentalFormattedSubsequentIncrementalExecutionResult,
+ GraphQLExperimentalFormattedInitialIncrementalExecutionResultAlpha2,
+ GraphQLExperimentalFormattedInitialIncrementalExecutionResultAlpha9,
+ GraphQLExperimentalFormattedSubsequentIncrementalExecutionResultAlpha2,
+ GraphQLExperimentalFormattedSubsequentIncrementalExecutionResultAlpha9,
GraphQLRequest,
HTTPGraphQLHead,
HTTPGraphQLRequest,
@@ -274,19 +275,20 @@ export async function runHttpQuery({
// without `deferSpec` as well (perhaps with slightly different behavior if
// anything has changed).
const acceptHeader = httpRequest.headers.get('accept');
+ const negotiator = new Negotiator({ headers: { accept: acceptHeader } });
+ const preferredMediaType = negotiator.mediaType([
+ // mediaType() will return the first one that matches, so if the client
+ // doesn't include the deferSpec parameter it will match this one here,
+ // which isn't good enough.
+ MEDIA_TYPES.MULTIPART_MIXED_NO_DEFER_SPEC,
+ MEDIA_TYPES.MULTIPART_MIXED_EXPERIMENTAL_ALPHA_9,
+ MEDIA_TYPES.MULTIPART_MIXED_EXPERIMENTAL_ALPHA_2,
+ ]);
+
if (
- !(
- acceptHeader &&
- new Negotiator({
- headers: { accept: httpRequest.headers.get('accept') },
- }).mediaType([
- // mediaType() will return the first one that matches, so if the client
- // doesn't include the deferSpec parameter it will match this one here,
- // which isn't good enough.
- MEDIA_TYPES.MULTIPART_MIXED_NO_DEFER_SPEC,
- MEDIA_TYPES.MULTIPART_MIXED_EXPERIMENTAL,
- ]) === MEDIA_TYPES.MULTIPART_MIXED_EXPERIMENTAL
- )
+ !acceptHeader ||
+ (preferredMediaType !== MEDIA_TYPES.MULTIPART_MIXED_EXPERIMENTAL_ALPHA_2 &&
+ preferredMediaType !== MEDIA_TYPES.MULTIPART_MIXED_EXPERIMENTAL_ALPHA_9)
) {
// The client ran an operation that would yield multiple parts, but didn't
// specify `accept: multipart/mixed`. We return an error.
@@ -294,7 +296,10 @@ export async function runHttpQuery({
'Apollo server received an operation that uses incremental delivery ' +
'(@defer or @stream), but the client does not accept multipart/mixed ' +
'HTTP responses. To enable incremental delivery support, add the HTTP ' +
- "header 'Accept: multipart/mixed; deferSpec=20220824'.",
+ `header 'Accept: ${MEDIA_TYPES.MULTIPART_MIXED_EXPERIMENTAL_ALPHA_9}' ` +
+ 'if your client supports the current incremental format or ' +
+ `'Accept: ${MEDIA_TYPES.MULTIPART_MIXED_EXPERIMENTAL_ALPHA_2}' if your ` +
+ 'client supports the legacy incremental format',
// Use 406 Not Accepted
{ extensions: { http: { status: 406 } } },
);
@@ -302,7 +307,7 @@ export async function runHttpQuery({
graphQLResponse.http.headers.set(
'content-type',
- 'multipart/mixed; boundary="-"; deferSpec=20220824',
+ `multipart/mixed; boundary="-"; ${preferredMediaType.replace('multipart/mixed; ', '')}`,
);
return {
...graphQLResponse.http,
@@ -317,8 +322,13 @@ export async function runHttpQuery({
}
async function* writeMultipartBody(
- initialResult: GraphQLExperimentalFormattedInitialIncrementalExecutionResult,
- subsequentResults: AsyncIterable,
+ initialResult:
+ | GraphQLExperimentalFormattedInitialIncrementalExecutionResultAlpha2
+ | GraphQLExperimentalFormattedInitialIncrementalExecutionResultAlpha9,
+ subsequentResults: AsyncIterable<
+ | GraphQLExperimentalFormattedSubsequentIncrementalExecutionResultAlpha2
+ | GraphQLExperimentalFormattedSubsequentIncrementalExecutionResultAlpha9
+ >,
): AsyncGenerator {
// Note: we assume in this function that every result other than the last has
// hasNext=true and the last has hasNext=false. That is, we choose which kind
@@ -329,12 +339,12 @@ async function* writeMultipartBody(
// iterator is finished until we do async work.
yield `\r\n---\r\ncontent-type: application/json; charset=utf-8\r\n\r\n${JSON.stringify(
- orderInitialIncrementalExecutionResultFields(initialResult),
+ initialResult,
)}\r\n---${initialResult.hasNext ? '' : '--'}\r\n`;
for await (const result of subsequentResults) {
yield `content-type: application/json; charset=utf-8\r\n\r\n${JSON.stringify(
- orderSubsequentIncrementalExecutionResultFields(result),
+ result,
)}\r\n---${result.hasNext ? '' : '--'}\r\n`;
}
}
@@ -350,40 +360,6 @@ function orderExecutionResultFields(
extensions: result.extensions,
};
}
-function orderInitialIncrementalExecutionResultFields(
- result: GraphQLExperimentalFormattedInitialIncrementalExecutionResult,
-): GraphQLExperimentalFormattedInitialIncrementalExecutionResult {
- return {
- hasNext: result.hasNext,
- errors: result.errors,
- data: result.data,
- incremental: orderIncrementalResultFields(result.incremental),
- extensions: result.extensions,
- };
-}
-function orderSubsequentIncrementalExecutionResultFields(
- result: GraphQLExperimentalFormattedSubsequentIncrementalExecutionResult,
-): GraphQLExperimentalFormattedSubsequentIncrementalExecutionResult {
- return {
- hasNext: result.hasNext,
- incremental: orderIncrementalResultFields(result.incremental),
- extensions: result.extensions,
- };
-}
-
-function orderIncrementalResultFields(
- incremental?: readonly GraphQLExperimentalFormattedIncrementalResult[],
-): undefined | GraphQLExperimentalFormattedIncrementalResult[] {
- return incremental?.map((i: any) => ({
- hasNext: i.hasNext,
- errors: i.errors,
- path: i.path,
- label: i.label,
- data: i.data,
- items: i.items,
- extensions: i.extensions,
- }));
-}
// The result of a curl does not appear well in the terminal, so we add an extra new line
export function prettyJSONStringify(value: FormattedExecutionResult) {
diff --git a/smoke-test/prepare.sh b/smoke-test/prepare.sh
index 5490c315f81..0452c82cb51 100755
--- a/smoke-test/prepare.sh
+++ b/smoke-test/prepare.sh
@@ -21,7 +21,8 @@ npm i
if [[ -n "${GRAPHQL_JS_VERSION:-}" ]]; then
npm i --no-save --legacy-peer-deps \
"$TARBALL_DIR"/*.tgz \
- "graphql@${GRAPHQL_JS_VERSION}"
+ "graphql@${GRAPHQL_JS_VERSION}" \
+ "@yaacovcr/transform"
else
npm i --no-save "$TARBALL_DIR"/*.tgz
fi
diff --git a/smoke-test/smoke-test.cjs b/smoke-test/smoke-test.cjs
index 4cd4af07182..32a7710f00d 100644
--- a/smoke-test/smoke-test.cjs
+++ b/smoke-test/smoke-test.cjs
@@ -49,35 +49,69 @@ async function smokeTest() {
}
if (process.env.INCREMENTAL_DELIVERY_TESTS_ENABLED) {
- const response = await fetch(url, {
- method: 'POST',
- headers: {
- 'content-type': 'application/json',
- accept: 'multipart/mixed; deferSpec=20220824, application/json',
- },
- body: JSON.stringify({ query: '{h1: hello ...@defer{ h2: hello }}' }),
- });
+ {
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ accept: `multipart/mixed; deferSpec=20220824, application/json`,
+ },
+ body: JSON.stringify({ query: '{h1: hello ...@defer{ h2: hello }}' }),
+ });
- assert.strictEqual(
- response.headers.get('content-type'),
- 'multipart/mixed; boundary="-"; deferSpec=20220824',
- );
+ assert.strictEqual(
+ response.headers.get('content-type'),
+ `multipart/mixed; boundary="-"; deferSpec=20220824`,
+ );
- const body = await response.text();
+ const body = await response.text();
- assert.strictEqual(
- body,
- '\r\n' +
- '---\r\n' +
- 'content-type: application/json; charset=utf-8\r\n' +
+ assert.strictEqual(
+ body,
'\r\n' +
- '{"hasNext":true,"data":{"h1":"world"}}\r\n' +
- '---\r\n' +
- 'content-type: application/json; charset=utf-8\r\n' +
+ '---\r\n' +
+ 'content-type: application/json; charset=utf-8\r\n' +
+ '\r\n' +
+ '{"data":{"h1":"world"},"hasNext":true}\r\n' +
+ '---\r\n' +
+ 'content-type: application/json; charset=utf-8\r\n' +
+ '\r\n' +
+ '{"hasNext":false,"incremental":[{"data":{"h2":"world"},"path":[]}]}\r\n' +
+ '-----\r\n',
+ );
+ }
+
+ {
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ accept: `multipart/mixed; incrementalDeliverySpec=3283f8a, application/json`,
+ },
+ body: JSON.stringify({ query: '{h1: hello ...@defer{ h2: hello }}' }),
+ });
+
+ assert.strictEqual(
+ response.headers.get('content-type'),
+ `multipart/mixed; boundary="-"; incrementalDeliverySpec=3283f8a`,
+ );
+
+ const body = await response.text();
+
+ assert.strictEqual(
+ body,
'\r\n' +
- '{"hasNext":false,"incremental":[{"path":[],"data":{"h2":"world"}}]}\r\n' +
- '-----\r\n',
- );
+ '---\r\n' +
+ 'content-type: application/json; charset=utf-8\r\n' +
+ '\r\n' +
+ '{"data":{"h1":"world"},"pending":[{"id":"0","path":[]}],"hasNext":true}\r\n' +
+ '---\r\n' +
+ 'content-type: application/json; charset=utf-8\r\n' +
+ '\r\n' +
+ '{"hasNext":false,"incremental":[{"data":{"h2":"world"},"id":"0"}],"completed":[{"id":"0"}]}\r\n' +
+ '-----\r\n',
+ );
+ }
}
await s.stop();
diff --git a/smoke-test/smoke-test.mjs b/smoke-test/smoke-test.mjs
index 5cb20d9bc48..ada2b4ebad9 100644
--- a/smoke-test/smoke-test.mjs
+++ b/smoke-test/smoke-test.mjs
@@ -45,36 +45,71 @@ const { url } = await startStandaloneServer(s, { listen: { port: 0 } });
}
if (process.env.INCREMENTAL_DELIVERY_TESTS_ENABLED) {
- const response = await fetch(url, {
- method: 'POST',
- headers: {
- 'content-type': 'application/json',
- accept: 'multipart/mixed; deferSpec=20220824, application/json',
- },
- body: JSON.stringify({ query: '{h1: hello ...@defer{ h2: hello }}' }),
- });
+ {
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ accept: `multipart/mixed; deferSpec=20220824, application/json`,
+ },
+ body: JSON.stringify({ query: '{h1: hello ...@defer{ h2: hello }}' }),
+ });
- assert.strictEqual(
- response.headers.get('content-type'),
- 'multipart/mixed; boundary="-"; deferSpec=20220824',
- );
+ assert.strictEqual(
+ response.headers.get('content-type'),
+ `multipart/mixed; boundary="-"; deferSpec=20220824`,
+ );
- const body = await response.text();
+ const body = await response.text();
- assert.strictEqual(
- body,
- '\r\n' +
- '---\r\n' +
- 'content-type: application/json; charset=utf-8\r\n' +
+ assert.strictEqual(
+ body,
'\r\n' +
- '{"hasNext":true,"data":{"h1":"world"}}\r\n' +
- '---\r\n' +
- 'content-type: application/json; charset=utf-8\r\n' +
+ '---\r\n' +
+ 'content-type: application/json; charset=utf-8\r\n' +
+ '\r\n' +
+ '{"data":{"h1":"world"},"hasNext":true}\r\n' +
+ '---\r\n' +
+ 'content-type: application/json; charset=utf-8\r\n' +
+ '\r\n' +
+ '{"hasNext":false,"incremental":[{"data":{"h2":"world"},"path":[]}]}\r\n' +
+ '-----\r\n',
+ );
+ }
+
+ {
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ accept: `multipart/mixed; incrementalDeliverySpec=3283f8a, application/json`,
+ },
+ body: JSON.stringify({ query: '{h1: hello ...@defer{ h2: hello }}' }),
+ });
+
+ assert.strictEqual(
+ response.headers.get('content-type'),
+ `multipart/mixed; boundary="-"; incrementalDeliverySpec=3283f8a`,
+ );
+
+ const body = await response.text();
+
+ assert.strictEqual(
+ body,
'\r\n' +
- '{"hasNext":false,"incremental":[{"path":[],"data":{"h2":"world"}}]}\r\n' +
- '-----\r\n',
- );
+ '---\r\n' +
+ 'content-type: application/json; charset=utf-8\r\n' +
+ '\r\n' +
+ '{"data":{"h1":"world"},"pending":[{"id":"0","path":[]}],"hasNext":true}\r\n' +
+ '---\r\n' +
+ 'content-type: application/json; charset=utf-8\r\n' +
+ '\r\n' +
+ '{"hasNext":false,"incremental":[{"data":{"h2":"world"},"id":"0"}],"completed":[{"id":"0"}]}\r\n' +
+ '-----\r\n',
+ );
+ }
}
+
await s.stop();
console.log('ESM smoke test passed!');