From f89a1aa2a0bd6efc145627a674370b1b22e231fa Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 28 Mar 2024 16:05:23 +0100 Subject: [PATCH] send empty data for complete event on sse stream (#3214) * send empty data for complete event on sse stream * update snapshots * more details in changeset * update snapshots --- .changeset/weak-trees-grow.md | 5 + .../__integration-tests__/fastify.spec.ts | 102 +++++++++--------- .../generic-auth.spec.ts | 2 + .../hapi/__integration-tests__/hapi.spec.ts | 1 + .../__integration-tests__/node-ts.spec.ts | 1 + .../__integration-tests__/pothos.spec.ts | 1 + .../__integration-tests__/browser.spec.ts | 38 +++++-- .../incremental-delivery.spec.ts | 7 +- .../__tests__/graphql-sse.spec.ts | 2 + .../__tests__/subscriptions.spec.ts | 32 +++--- .../src/plugins/result-processor/sse.ts | 3 +- .../nestjs/__tests__/subscriptions.spec.ts | 2 + 12 files changed, 119 insertions(+), 77 deletions(-) create mode 100644 .changeset/weak-trees-grow.md diff --git a/.changeset/weak-trees-grow.md b/.changeset/weak-trees-grow.md new file mode 100644 index 0000000000..4b745cc2a8 --- /dev/null +++ b/.changeset/weak-trees-grow.md @@ -0,0 +1,5 @@ +--- +'graphql-yoga': patch +--- + +Always include empty data payload for final `complete` event of SSE stream responses to ensure [`EventSource`](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) compatibility. See the [GraphQL over SSE protocol](https://github.com/enisdenjo/graphql-sse/blob/master/PROTOCOL.md#complete-event) for more information. diff --git a/examples/fastify/__integration-tests__/fastify.spec.ts b/examples/fastify/__integration-tests__/fastify.spec.ts index 843ac3cd55..5849d3ee4c 100644 --- a/examples/fastify/__integration-tests__/fastify.spec.ts +++ b/examples/fastify/__integration-tests__/fastify.spec.ts @@ -139,43 +139,42 @@ describe('fastify example integration', () => { }); expect(response.statusCode).toEqual(200); expect(response.text.replace(/:\n\n/g, '')).toMatchInlineSnapshot(` - "event: next - data: {"data":{"countdown":10}} +"event: next +data: {"data":{"countdown":10}} - event: next - data: {"data":{"countdown":9}} +event: next +data: {"data":{"countdown":9}} - event: next - data: {"data":{"countdown":8}} +event: next +data: {"data":{"countdown":8}} - event: next - data: {"data":{"countdown":7}} +event: next +data: {"data":{"countdown":7}} - event: next - data: {"data":{"countdown":6}} +event: next +data: {"data":{"countdown":6}} - event: next - data: {"data":{"countdown":5}} +event: next +data: {"data":{"countdown":5}} - event: next - data: {"data":{"countdown":4}} +event: next +data: {"data":{"countdown":4}} - event: next - data: {"data":{"countdown":3}} +event: next +data: {"data":{"countdown":3}} - event: next - data: {"data":{"countdown":2}} +event: next +data: {"data":{"countdown":2}} - event: next - data: {"data":{"countdown":1}} +event: next +data: {"data":{"countdown":1}} - event: next - data: {"data":{"countdown":0}} +event: next +data: {"data":{"countdown":0}} - event: complete - - " - `); +event: complete +data" +`); }); it('handles subscription operations via POST', async () => { const response = await request(app.server) @@ -193,43 +192,42 @@ describe('fastify example integration', () => { }); expect(response.statusCode).toEqual(200); expect(response.text.replace(/:\n\n/g, '')).toMatchInlineSnapshot(` - "event: next - data: {"data":{"countdown":10}} - - event: next - data: {"data":{"countdown":9}} +"event: next +data: {"data":{"countdown":10}} - event: next - data: {"data":{"countdown":8}} +event: next +data: {"data":{"countdown":9}} - event: next - data: {"data":{"countdown":7}} +event: next +data: {"data":{"countdown":8}} - event: next - data: {"data":{"countdown":6}} +event: next +data: {"data":{"countdown":7}} - event: next - data: {"data":{"countdown":5}} +event: next +data: {"data":{"countdown":6}} - event: next - data: {"data":{"countdown":4}} +event: next +data: {"data":{"countdown":5}} - event: next - data: {"data":{"countdown":3}} +event: next +data: {"data":{"countdown":4}} - event: next - data: {"data":{"countdown":2}} +event: next +data: {"data":{"countdown":3}} - event: next - data: {"data":{"countdown":1}} +event: next +data: {"data":{"countdown":2}} - event: next - data: {"data":{"countdown":0}} +event: next +data: {"data":{"countdown":1}} - event: complete +event: next +data: {"data":{"countdown":0}} - " - `); +event: complete +data" +`); }); it('should handle file uploads', async () => { const response = await request(app.server) diff --git a/examples/generic-auth/__integration-tests__/generic-auth.spec.ts b/examples/generic-auth/__integration-tests__/generic-auth.spec.ts index 24c16e7758..675eb8d1fd 100644 --- a/examples/generic-auth/__integration-tests__/generic-auth.spec.ts +++ b/examples/generic-auth/__integration-tests__/generic-auth.spec.ts @@ -82,6 +82,7 @@ event: next data: {"data":{"requiresAuth":"hi foo@foo.com"}} event: complete +data: " `); @@ -103,6 +104,7 @@ event: next data: {"data":null,"errors":[{"message":"Accessing 'Subscription.requiresAuth' requires authentication.","locations":[{"line":1,"column":14}]}]} event: complete +data: " `); diff --git a/examples/hapi/__integration-tests__/hapi.spec.ts b/examples/hapi/__integration-tests__/hapi.spec.ts index de9d2959f4..25082744be 100644 --- a/examples/hapi/__integration-tests__/hapi.spec.ts +++ b/examples/hapi/__integration-tests__/hapi.spec.ts @@ -76,6 +76,7 @@ event: next data: {"data":{"greetings":"Zdravo"}} event: complete +data: " `); diff --git a/examples/node-ts/__integration-tests__/node-ts.spec.ts b/examples/node-ts/__integration-tests__/node-ts.spec.ts index c516237445..9e7f2c7cf8 100644 --- a/examples/node-ts/__integration-tests__/node-ts.spec.ts +++ b/examples/node-ts/__integration-tests__/node-ts.spec.ts @@ -25,6 +25,7 @@ event: next data: {"errors":[{"message":"Subscriptions have been disabled"}]} event: complete +data: " `); diff --git a/examples/pothos/__integration-tests__/pothos.spec.ts b/examples/pothos/__integration-tests__/pothos.spec.ts index 9277b5dbb0..832974cadc 100644 --- a/examples/pothos/__integration-tests__/pothos.spec.ts +++ b/examples/pothos/__integration-tests__/pothos.spec.ts @@ -35,6 +35,7 @@ event: next data: {"data":{"greetings":"Zdravo"}} event: complete +data: " `); diff --git a/packages/graphql-yoga/__integration-tests__/browser.spec.ts b/packages/graphql-yoga/__integration-tests__/browser.spec.ts index ef9d0301ce..93391c6bc9 100644 --- a/packages/graphql-yoga/__integration-tests__/browser.spec.ts +++ b/packages/graphql-yoga/__integration-tests__/browser.spec.ts @@ -119,8 +119,32 @@ export function createTestSchema() { }), resolve: value => value, }, + countdown: { + type: new GraphQLNonNull(GraphQLInt), + args: { + from: { + type: new GraphQLNonNull(GraphQLInt), + }, + }, + subscribe: (_, { from }) => + new Repeater((push, end) => { + let counter = from; + const send = () => { + push(counter); + if (counter === 0) { + end(); + } + + counter--; + }; + const interval = setInterval(() => send(), 1000); + end.then(() => clearInterval(interval)); + }), + resolve: value => value, + }, error: { type: GraphQLBoolean, + // eslint-disable-next-line require-yield async *subscribe() { throw new Error('This is not okay'); }, @@ -650,7 +674,7 @@ describe('browser', () => { 'query', /* GraphQL */ ` subscription { - counter + countdown(from: 1) } `, ); @@ -662,10 +686,10 @@ describe('browser', () => { const values: Array = []; source.addEventListener('next', event => { values.push(event.data); - if (values.length === 2) { - res({ data: values }); - source.close(); - } + }); + source.addEventListener('complete', () => { + source.close(); + res({ data: values }); }); source.onerror = err => { res({ error: String(err) }); @@ -679,8 +703,8 @@ describe('browser', () => { expect(result.data).toMatchInlineSnapshot(` [ - "{"data":{"counter":0}}", - "{"data":{"counter":1}}", + "{"data":{"countdown":1}}", + "{"data":{"countdown":0}}", ] `); }); diff --git a/packages/graphql-yoga/__integration-tests__/incremental-delivery.spec.ts b/packages/graphql-yoga/__integration-tests__/incremental-delivery.spec.ts index ca5b4cb7b4..61fb8211ba 100644 --- a/packages/graphql-yoga/__integration-tests__/incremental-delivery.spec.ts +++ b/packages/graphql-yoga/__integration-tests__/incremental-delivery.spec.ts @@ -374,10 +374,11 @@ describe('incremental delivery: node-fetch', () => { chunk = await reader.read(); expect(Buffer.from(chunk.value!).toString('utf-8')).toMatchInlineSnapshot(` - "event: complete +"event: complete +data: - " - `); +" +`); chunk = await reader.read(); expect(chunk.done).toBeTruthy(); diff --git a/packages/graphql-yoga/__tests__/graphql-sse.spec.ts b/packages/graphql-yoga/__tests__/graphql-sse.spec.ts index e88986d909..153488ef2d 100644 --- a/packages/graphql-yoga/__tests__/graphql-sse.spec.ts +++ b/packages/graphql-yoga/__tests__/graphql-sse.spec.ts @@ -61,6 +61,7 @@ describe('GraphQL over SSE', () => { : event: complete +data: " `); @@ -178,6 +179,7 @@ event: next data: {"errors":[{"message":"Cannot query field \\"nope\\" on type \\"Query\\".","locations":[{"line":1,"column":2}]}]} event: complete +data: " `); diff --git a/packages/graphql-yoga/__tests__/subscriptions.spec.ts b/packages/graphql-yoga/__tests__/subscriptions.spec.ts index 9713b20f51..22c3d0d45d 100644 --- a/packages/graphql-yoga/__tests__/subscriptions.spec.ts +++ b/packages/graphql-yoga/__tests__/subscriptions.spec.ts @@ -122,25 +122,26 @@ describe('Subscription', () => { } expect(results).toMatchInlineSnapshot(` - [ - ": +[ + ": - ", - ": +", + ": - ", - ": +", + ": - ", - "event: next - data: {"data":{"hi":"hi"}} +", + "event: next +data: {"data":{"hi":"hi"}} - ", - "event: complete +", + "event: complete +data: - ", - ] - `); +", +] +`); }); test('should issue pings event if event source never publishes anything', async () => { @@ -284,6 +285,7 @@ event: next data: {"errors":[{"message":"Unexpected error.","locations":[{"line":2,"column":11}]}]} event: complete +data: " `); @@ -352,6 +354,7 @@ event: next data: {"errors":[{"message":"hi","locations":[{"line":2,"column":11}]}]} event: complete +data: " `); @@ -415,6 +418,7 @@ event: next data: {"errors":[{"message":"hi","locations":[{"line":2,"column":11}]}]} event: complete +data: " `); diff --git a/packages/graphql-yoga/src/plugins/result-processor/sse.ts b/packages/graphql-yoga/src/plugins/result-processor/sse.ts index 78ebbaf2a7..414e8ee842 100644 --- a/packages/graphql-yoga/src/plugins/result-processor/sse.ts +++ b/packages/graphql-yoga/src/plugins/result-processor/sse.ts @@ -65,7 +65,8 @@ export function getSSEProcessor(): ResultProcessor { controller.enqueue(textEncoder.encode(`data: ${chunk}\n\n`)); } if (done) { - controller.enqueue(textEncoder.encode(`event: complete\n\n`)); + controller.enqueue(textEncoder.encode(`event: complete\n`)); + controller.enqueue(textEncoder.encode(`data:\n\n`)); clearInterval(pingInterval); controller.close(); } diff --git a/packages/nestjs/__tests__/subscriptions.spec.ts b/packages/nestjs/__tests__/subscriptions.spec.ts index 739d85e73c..92d5e3cac5 100644 --- a/packages/nestjs/__tests__/subscriptions.spec.ts +++ b/packages/nestjs/__tests__/subscriptions.spec.ts @@ -50,6 +50,7 @@ event: next data: {"data":{"greetings":"Zdravo"}} event: complete +data: " `); @@ -80,6 +81,7 @@ event: next data: {"data":{"filteredGreetings":"Hola"}} event: complete +data: " `);