diff --git a/.changeset/light-pants-protect.md b/.changeset/light-pants-protect.md new file mode 100644 index 0000000000..d318d61b05 --- /dev/null +++ b/.changeset/light-pants-protect.md @@ -0,0 +1,7 @@ +--- +'graphql-yoga': patch +--- + +Restores compatibility with [RFC1341: The Multipart Content-Type](https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html) by including preceding `\r\n` for initial boundary delimiter when using the multipart response protocol. + +This makes Yoga compatible with libraries that strictly follow the response protocol, such as [fetch-multipart-graphql](https://github.com/relay-tools/fetch-multipart-graphql). diff --git a/examples/defer-stream/__integration-tests__/__snapshots__/defer-stream.spec.ts.snap b/examples/defer-stream/__integration-tests__/__snapshots__/defer-stream.spec.ts.snap index 7aca82eeed..5b13003e6b 100644 --- a/examples/defer-stream/__integration-tests__/__snapshots__/defer-stream.spec.ts.snap +++ b/examples/defer-stream/__integration-tests__/__snapshots__/defer-stream.spec.ts.snap @@ -1,7 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Defer / Stream defer: defer 1`] = ` -"--- +" +--- Content-Type: application/json; charset=utf-8 Content-Length: 50 @@ -16,7 +17,8 @@ Content-Length: 78 `; exports[`Defer / Stream stream: stream 1`] = ` -"--- +" +--- Content-Type: application/json; charset=utf-8 Content-Length: 39 diff --git a/packages/graphql-yoga/src/plugins/result-processor/multipart.ts b/packages/graphql-yoga/src/plugins/result-processor/multipart.ts index 3ceed18aa5..8f72648531 100644 --- a/packages/graphql-yoga/src/plugins/result-processor/multipart.ts +++ b/packages/graphql-yoga/src/plugins/result-processor/multipart.ts @@ -34,6 +34,7 @@ export function processMultipartResult(result: ResultProcessorInput, fetchAPI: F }, }; } + controller.enqueue(textEncoder.encode('\r\n')); controller.enqueue(textEncoder.encode(`---`)); }, async pull(controller) { diff --git a/packages/plugins/defer-stream/__integration-tests__/defer-stream.spec.ts b/packages/plugins/defer-stream/__integration-tests__/defer-stream.spec.ts index c1151a7d8f..1d65fc8257 100644 --- a/packages/plugins/defer-stream/__integration-tests__/defer-stream.spec.ts +++ b/packages/plugins/defer-stream/__integration-tests__/defer-stream.spec.ts @@ -1,6 +1,7 @@ import { createServer, get, IncomingMessage } from 'node:http'; import { AddressInfo } from 'node:net'; import { setTimeout as setTimeout$ } from 'node:timers/promises'; +import fetchMultipart from 'fetch-multipart-graphql'; import { createLogger, createSchema, createYoga, useExecutionCancellation } from 'graphql-yoga'; import { useDeferStream } from '@graphql-yoga/plugin-defer-stream'; import { createPushPullAsyncIterable } from '../__tests__/push-pull-async-iterable.js'; @@ -165,13 +166,14 @@ it('memory/cleanup leak by source that never publishes a value', async () => { const chunkStr = Buffer.from(next.value).toString('utf-8'); expect(chunkStr).toMatchInlineSnapshot(` - "--- - Content-Type: application/json; charset=utf-8 - Content-Length: 33 +" +--- +Content-Type: application/json; charset=utf-8 +Content-Length: 33 - {"data":{"hi":[]},"hasNext":true} - ---" - `); +{"data":{"hi":[]},"hasNext":true} +---" +`); await expect(iterator.next()).rejects.toMatchInlineSnapshot(`[Error: aborted]`); @@ -194,3 +196,94 @@ it('memory/cleanup leak by source that never publishes a value', async () => { }); } }); + +describe('fetch-multipart-graphql', () => { + it('execute defer operation', async () => { + const yoga = createYoga({ + schema: createSchema({ + typeDefs: /* GraphQL */ ` + type Query { + a: String + b: String + } + `, + resolvers: { + Query: { + a: async () => { + return 'a'; + }, + b: async () => { + return 'b'; + }, + }, + }, + }), + plugins: [useDeferStream()], + }); + + const server = createServer(yoga); + + try { + await new Promise(resolve => { + server.listen(() => { + resolve(); + }); + }); + + const port = (server.address() as AddressInfo)?.port ?? null; + if (port === null) { + throw new Error('Missing port...'); + } + + await new Promise((resolve, reject) => { + fetchMultipart(`http://localhost:${port}/graphql`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'multipart/mixed', + }, + body: JSON.stringify({ + query: /* GraphQL */ ` + query { + ... on Query @defer { + a + } + } + `, + }), + onNext(next) { + expect(next).toEqual([ + { + data: {}, + hasNext: true, + }, + { + hasNext: false, + incremental: [ + { + data: { + a: 'a', + }, + path: [], + }, + ], + }, + ]); + }, + onError(err) { + reject(err); + }, + onComplete() { + resolve(); + }, + }); + }); + } finally { + await new Promise(res => { + server.close(() => { + res(); + }); + }); + } + }); +}); diff --git a/packages/plugins/defer-stream/__tests__/defer-stream.spec.ts b/packages/plugins/defer-stream/__tests__/defer-stream.spec.ts index bc030ee297..db16e8ceb6 100644 --- a/packages/plugins/defer-stream/__tests__/defer-stream.spec.ts +++ b/packages/plugins/defer-stream/__tests__/defer-stream.spec.ts @@ -105,19 +105,20 @@ describe('Defer/Stream', () => { const finalText = await response.text(); expect(finalText).toMatchInlineSnapshot(` - "--- - Content-Type: application/json; charset=utf-8 - Content-Length: 26 - - {"data":{},"hasNext":true} - --- - Content-Type: application/json; charset=utf-8 - Content-Length: 74 - - {"incremental":[{"data":{"goodbye":"goodbye"},"path":[]}],"hasNext":false} - ----- - " - `); +" +--- +Content-Type: application/json; charset=utf-8 +Content-Length: 26 + +{"data":{},"hasNext":true} +--- +Content-Type: application/json; charset=utf-8 +Content-Length: 74 + +{"incremental":[{"data":{"goodbye":"goodbye"},"path":[]}],"hasNext":false} +----- +" +`); }); it('should execute on stream directive', async () => { @@ -140,7 +141,8 @@ describe('Defer/Stream', () => { const finalText = await response.text(); expect(finalText).toMatchInlineSnapshot(` -"--- +" +--- Content-Type: application/json; charset=utf-8 Content-Length: 44 diff --git a/packages/plugins/defer-stream/package.json b/packages/plugins/defer-stream/package.json index a3c8e9b11b..1268c307b8 100644 --- a/packages/plugins/defer-stream/package.json +++ b/packages/plugins/defer-stream/package.json @@ -46,6 +46,7 @@ "devDependencies": { "@graphql-tools/executor-http": "^1.0.4", "@whatwg-node/fetch": "^0.9.17", + "fetch-multipart-graphql": "3.2.1", "graphql": "^16.6.0", "graphql-yoga": "workspace:*", "tslib": "^2.5.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2eb9af3e3f..e3ae5a2f72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1850,6 +1850,9 @@ importers: '@whatwg-node/fetch': specifier: ^0.9.17 version: 0.9.19 + fetch-multipart-graphql: + specifier: 3.2.1 + version: 3.2.1 graphql: specifier: 16.8.1 version: 16.8.1 @@ -10645,6 +10648,9 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + fetch-multipart-graphql@3.2.1: + resolution: {integrity: sha512-uNbr6ysfn3GmR7s6LzkeACpYfxdLvCHSDn9DHSZNHpn6jkDH9mWa8rT4jfqXNzua5PG1z5DpDnZjJzIH462Kew==} + fetch-node-website@7.3.0: resolution: {integrity: sha512-/wayUHbdVUWrD72aqRNNrr6+MHnCkumZgNugN0RfiWJpbNJUdAkMk4Z18MGayGZVVqYXR1RWrV+bIFEt5HuBZg==} engines: {node: '>=14.18.0'} @@ -28200,6 +28206,8 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + fetch-multipart-graphql@3.2.1: {} + fetch-node-website@7.3.0: dependencies: cli-progress: 3.12.0