Skip to content

Commit 09d23a4

Browse files
ardatanenisdenjo
andauthored
GraphQL SSE Distinct Connections support (#2445)
* GraphQL SSE Distinct Connections support * add graphql-sse spec * drop graphql-sse-client spec * adapt pings timeout * Always use 200 per GraphQL SSE spec * Hapi hapi hapi chulo * Update website/src/pages/docs/features/subscriptions.mdx Co-authored-by: Denis Badurina <badurinadenis@gmail.com> * Update website/src/pages/docs/features/subscriptions.mdx Co-authored-by: Denis Badurina <badurinadenis@gmail.com> * Update .changeset/tricky-teachers-sin.md Co-authored-by: Denis Badurina <badurinadenis@gmail.com> --------- Co-authored-by: enisdenjo <badurinadenis@gmail.com>
1 parent 59f4e56 commit 09d23a4

File tree

16 files changed

+387
-104
lines changed

16 files changed

+387
-104
lines changed

.changeset/tricky-teachers-sin.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'graphql-yoga': minor
3+
---
4+
5+
GraphQL SSE Distinct Connections mode support with `legacySse = false` flag

examples/fastify/__integration-tests__/fastify.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,8 @@ describe('fastify example integration', () => {
161161
162162
data: {"data":{"countdown":0}}
163163
164+
event: complete
165+
164166
"
165167
`)
166168
})
@@ -202,6 +204,8 @@ describe('fastify example integration', () => {
202204
203205
data: {"data":{"countdown":0}}
204206
207+
event: complete
208+
205209
"
206210
`)
207211
})

examples/generic-auth/__integration-tests__/generic-auth.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ describe('graphql-auth example integration', () => {
7070
for await (const chunk of response.body!) {
7171
const chunkString = Buffer.from(chunk).toString('utf-8')
7272
if (chunkString.includes('data:')) {
73-
expect(chunkString.trim()).toBe('data: {"data":{"public":"hi"}}')
73+
expect(chunkString.trim()).toContain('data: {"data":{"public":"hi"}}')
7474
break
7575
}
7676
}
@@ -91,7 +91,7 @@ describe('graphql-auth example integration', () => {
9191
for await (const chunk of response.body!) {
9292
const chunkStr = Buffer.from(chunk).toString('utf-8')
9393
if (chunkStr.startsWith('data:')) {
94-
expect(chunkStr.trim()).toBe(
94+
expect(chunkStr.trim()).toContain(
9595
'data: {"data":{"requiresAuth":"hi foo@foo.com"}}',
9696
)
9797
break
@@ -112,7 +112,7 @@ describe('graphql-auth example integration', () => {
112112
for await (const chunk of response.body!) {
113113
const chunkStr = Buffer.from(chunk).toString('utf-8')
114114
if (chunkStr.startsWith('data:')) {
115-
expect(chunkStr.trim()).toBe(
115+
expect(chunkStr.trim()).toContain(
116116
'data: {"data":null,"errors":[{"message":"Accessing \'Subscription.requiresAuth\' requires authentication.","locations":[{"line":1,"column":14}]}]}',
117117
)
118118
break

examples/hapi/__integration-tests__/hapi.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ describe('hapi example integration', () => {
6868
6969
data: {"data":{"greetings":"Zdravo"}}
7070
71+
event: complete
72+
7173
"
7274
`)
7375
})

examples/node-ts/__integration-tests__/node-ts.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ describe('node-ts example integration', () => {
2424
expect(await response.text()).toMatchInlineSnapshot(`
2525
"data: {"errors":[{"message":"Subscriptions have been disabled"}]}
2626
27+
event: complete
28+
2729
"
2830
`)
2931
})

examples/pothos/__integration-tests__/pothos.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ describe('pothos example integration', () => {
3232
3333
data: {"data":{"greetings":"Zdravo"}}
3434
35+
event: complete
36+
3537
"
3638
`)
3739
})
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { createSchema, createYoga } from '../src/index.js'
2+
import { createClient } from 'graphql-sse'
3+
4+
describe('GraphQL over SSE', () => {
5+
const schema = createSchema({
6+
typeDefs: /* GraphQL */ `
7+
type Query {
8+
hello: String!
9+
}
10+
type Subscription {
11+
greetings: String!
12+
waitForPings: String!
13+
}
14+
`,
15+
resolvers: {
16+
Query: {
17+
async hello() {
18+
return 'world'
19+
},
20+
},
21+
Subscription: {
22+
greetings: {
23+
async *subscribe() {
24+
for (const hi of ['Hi', 'Bonjour', 'Hola', 'Ciao', 'Zdravo']) {
25+
yield { greetings: hi }
26+
}
27+
},
28+
},
29+
waitForPings: {
30+
// eslint-disable-next-line require-yield
31+
async *subscribe() {
32+
// a ping is issued every 300ms, wait for a few and just return
33+
await new Promise((resolve) => setTimeout(resolve, 300 * 3 + 100))
34+
return
35+
},
36+
},
37+
},
38+
},
39+
})
40+
41+
const yoga = createYoga({
42+
schema,
43+
legacySse: false,
44+
maskedErrors: false,
45+
})
46+
47+
describe('Distinct connections mode', () => {
48+
test('should issue pings while connected', async () => {
49+
const res = await yoga.fetch(
50+
'http://yoga/graphql?query=subscription{waitForPings}',
51+
{
52+
headers: {
53+
accept: 'text/event-stream',
54+
},
55+
},
56+
)
57+
expect(res.ok).toBeTruthy()
58+
await expect(res.text()).resolves.toMatchInlineSnapshot(`
59+
":
60+
61+
:
62+
63+
:
64+
65+
event: complete
66+
67+
"
68+
`)
69+
})
70+
71+
it('should support single result operations', async () => {
72+
const client = createClient({
73+
url: 'http://yoga/graphql',
74+
fetchFn: yoga.fetch,
75+
abortControllerImpl: yoga.fetchAPI.AbortController,
76+
singleConnection: false, // distinct connection mode
77+
retryAttempts: 0,
78+
})
79+
80+
await expect(
81+
new Promise((resolve, reject) => {
82+
let result: unknown
83+
client.subscribe(
84+
{
85+
query: /* GraphQL */ `
86+
{
87+
hello
88+
}
89+
`,
90+
},
91+
{
92+
next: (msg) => (result = msg),
93+
error: reject,
94+
complete: () => resolve(result),
95+
},
96+
)
97+
}),
98+
).resolves.toMatchInlineSnapshot(`
99+
{
100+
"data": {
101+
"hello": "world",
102+
},
103+
}
104+
`)
105+
106+
client.dispose()
107+
})
108+
109+
it('should support streaming operations', async () => {
110+
const client = createClient({
111+
url: 'http://yoga/graphql',
112+
fetchFn: yoga.fetch,
113+
abortControllerImpl: yoga.fetchAPI.AbortController,
114+
singleConnection: false, // distinct connection mode
115+
retryAttempts: 0,
116+
})
117+
118+
await expect(
119+
new Promise((resolve, reject) => {
120+
const msgs: unknown[] = []
121+
client.subscribe(
122+
{
123+
query: /* GraphQL */ `
124+
subscription {
125+
greetings
126+
}
127+
`,
128+
},
129+
{
130+
next: (msg) => msgs.push(msg),
131+
error: reject,
132+
complete: () => resolve(msgs),
133+
},
134+
)
135+
}),
136+
).resolves.toMatchInlineSnapshot(`
137+
[
138+
{
139+
"data": {
140+
"greetings": "Hi",
141+
},
142+
},
143+
{
144+
"data": {
145+
"greetings": "Bonjour",
146+
},
147+
},
148+
{
149+
"data": {
150+
"greetings": "Hola",
151+
},
152+
},
153+
{
154+
"data": {
155+
"greetings": "Ciao",
156+
},
157+
},
158+
{
159+
"data": {
160+
"greetings": "Zdravo",
161+
},
162+
},
163+
]
164+
`)
165+
166+
client.dispose()
167+
})
168+
169+
it('should report errors through the stream', async () => {
170+
const res = await yoga.fetch('http://yoga/graphql?query={nope}', {
171+
headers: {
172+
accept: 'text/event-stream',
173+
},
174+
})
175+
expect(res.ok).toBeTruthy()
176+
await expect(res.text()).resolves.toMatchInlineSnapshot(`
177+
"event: next
178+
data: {"errors":[{"message":"Cannot query field \\"nope\\" on type \\"Query\\".","locations":[{"line":1,"column":2}]}]}
179+
180+
event: complete
181+
182+
"
183+
`)
184+
})
185+
})
186+
187+
it.todo('Single connections mode')
188+
})

packages/graphql-yoga/__tests__/subscriptions.spec.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -143,21 +143,24 @@ describe('Subscription', () => {
143143
}
144144

145145
expect(results).toMatchInlineSnapshot(`
146-
[
147-
":
146+
[
147+
":
148+
149+
",
150+
":
148151
149-
",
150-
":
152+
",
153+
":
151154
152-
",
153-
":
155+
",
156+
"data: {"data":{"hi":"hi"}}
154157
155-
",
156-
"data: {"data":{"hi":"hi"}}
158+
",
159+
"event: complete
157160
158-
",
159-
]
160-
`)
161+
",
162+
]
163+
`)
161164
})
162165

163166
test('should issue pings event if event source never publishes anything', async () => {

packages/graphql-yoga/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
"graphql": "^16.0.1",
7474
"graphql-http": "^1.7.2",
7575
"graphql-scalars": "1.20.4",
76+
"graphql-sse": "2.0.0",
7677
"html-minifier-terser": "7.1.0",
7778
"json-bigint-patch": "0.0.8",
7879
"puppeteer": "19.6.0"

packages/graphql-yoga/src/plugins/resultProcessor/push.ts

Lines changed: 0 additions & 78 deletions
This file was deleted.

0 commit comments

Comments
 (0)