Skip to content

Commit 74e1f83

Browse files
authored
graphql-ws integration adjustments and tests (V3) (#1609)
* fix integration and test * update ws integration docs * failing ws mutation * mutations over ws cb5d83d * changeset
1 parent c5001d3 commit 74e1f83

File tree

8 files changed

+226
-88
lines changed

8 files changed

+226
-88
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'graphql-yoga': patch
3+
---
4+
5+
`usePreventMutationViaGET` doesn't do assertion if it is not `YogaContext`
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'graphql-yoga': patch
3+
---
4+
5+
Expose readonly "graphqlEndpoint" in `YogaServerInstance`
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { buildApp } from '../src/app.js'
2+
import WebSocket from 'ws'
3+
import { createClient } from 'graphql-ws'
4+
5+
describe('graphql-ws example integration', () => {
6+
const app = buildApp()
7+
beforeAll(() => app.start(4000))
8+
afterAll(() => app.stop())
9+
10+
it('should execute query', async () => {
11+
const client = createClient({
12+
webSocketImpl: WebSocket,
13+
url: 'ws://localhost:4000/graphql',
14+
retryAttempts: 0, // fail right away
15+
})
16+
17+
const onNext = jest.fn()
18+
19+
await new Promise<void>((resolve, reject) => {
20+
client.subscribe(
21+
{ query: '{ hello }' },
22+
{
23+
next: onNext,
24+
error: reject,
25+
complete: resolve,
26+
},
27+
)
28+
})
29+
30+
expect(onNext).toBeCalledWith({ data: { hello: 'world' } })
31+
})
32+
33+
it('should execute mutation', async () => {
34+
const client = createClient({
35+
webSocketImpl: WebSocket,
36+
url: 'ws://localhost:4000/graphql',
37+
retryAttempts: 0, // fail right away
38+
})
39+
40+
const onNext = jest.fn()
41+
42+
await new Promise<void>((resolve, reject) => {
43+
client.subscribe(
44+
{ query: 'mutation { dontChange }' },
45+
{
46+
next: onNext,
47+
error: reject,
48+
complete: resolve,
49+
},
50+
)
51+
})
52+
53+
expect(onNext).toBeCalledWith({ data: { dontChange: 'didntChange' } })
54+
})
55+
56+
it('should subscribe', async () => {
57+
const client = createClient({
58+
webSocketImpl: WebSocket,
59+
url: 'ws://localhost:4000/graphql',
60+
retryAttempts: 0, // fail right away
61+
})
62+
63+
const onNext = jest.fn()
64+
65+
await new Promise<void>((resolve, reject) => {
66+
client.subscribe(
67+
{ query: 'subscription { greetings }' },
68+
{
69+
next: onNext,
70+
error: reject,
71+
complete: resolve,
72+
},
73+
)
74+
})
75+
76+
expect(onNext).toBeCalledTimes(5)
77+
expect(onNext).toBeCalledWith({ data: { greetings: 'Hi' } })
78+
})
79+
})

examples/graphql-ws/src/app.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { Socket } from 'net'
2+
import { createServer } from 'http'
3+
import { WebSocketServer } from 'ws'
4+
import { createYoga, createSchema } from 'graphql-yoga'
5+
import { useServer } from 'graphql-ws/lib/use/ws'
6+
7+
export function buildApp() {
8+
const yoga = createYoga({
9+
schema: createSchema({
10+
typeDefs: /* GraphQL */ `
11+
type Query {
12+
hello: String!
13+
}
14+
type Mutation {
15+
dontChange: String!
16+
}
17+
type Subscription {
18+
greetings: String!
19+
}
20+
`,
21+
resolvers: {
22+
Query: {
23+
hello() {
24+
return 'world'
25+
},
26+
},
27+
Mutation: {
28+
dontChange() {
29+
return 'didntChange'
30+
},
31+
},
32+
Subscription: {
33+
greetings: {
34+
async *subscribe() {
35+
for (const hi of ['Hi', 'Bonjour', 'Hola', 'Ciao', 'Zdravo']) {
36+
yield { greetings: hi }
37+
}
38+
},
39+
},
40+
},
41+
},
42+
}),
43+
})
44+
45+
const server = createServer(yoga)
46+
const wss = new WebSocketServer({
47+
server,
48+
path: yoga.graphqlEndpoint,
49+
})
50+
51+
useServer(
52+
{
53+
execute: (args: any) => args.execute(args),
54+
subscribe: (args: any) => args.subscribe(args),
55+
onSubscribe: async (ctx, msg) => {
56+
const { schema, execute, subscribe, contextFactory, parse, validate } =
57+
yoga.getEnveloped({
58+
...ctx,
59+
req: ctx.extra.request,
60+
socket: ctx.extra.socket,
61+
})
62+
63+
const args = {
64+
schema,
65+
operationName: msg.payload.operationName,
66+
document: parse(msg.payload.query),
67+
variableValues: msg.payload.variables,
68+
contextValue: await contextFactory(),
69+
execute,
70+
subscribe,
71+
}
72+
73+
const errors = validate(args.schema, args.document)
74+
if (errors.length) return errors
75+
return args
76+
},
77+
},
78+
wss,
79+
)
80+
81+
// for termination
82+
const sockets = new Set<Socket>()
83+
server.on('connection', (socket) => {
84+
sockets.add(socket)
85+
server.once('close', () => sockets.delete(socket))
86+
})
87+
88+
return {
89+
start: (port: number) =>
90+
new Promise<void>((resolve, reject) => {
91+
server.on('error', (err) => reject(err))
92+
server.on('listening', () => resolve())
93+
server.listen(port)
94+
}),
95+
stop: () =>
96+
new Promise<void>((resolve) => {
97+
for (const socket of sockets) {
98+
socket.destroy()
99+
sockets.delete(socket)
100+
}
101+
server.close(() => resolve())
102+
}),
103+
}
104+
}

examples/graphql-ws/src/index.ts

Lines changed: 3 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,77 +1,8 @@
1-
import { createYoga, createSchema, Repeater } from 'graphql-yoga'
2-
import { createServer } from 'http'
3-
import { WebSocketServer } from 'ws'
4-
import { useServer } from 'graphql-ws/lib/use/ws'
1+
import { buildApp } from './app'
52

63
async function main() {
7-
const yogaApp = createYoga({
8-
graphiql: {
9-
subscriptionsProtocol: 'WS',
10-
},
11-
schema: createSchema({
12-
typeDefs: /* GraphQL */ `
13-
type Query {
14-
hello: String!
15-
}
16-
type Subscription {
17-
currentTime: String
18-
}
19-
`,
20-
resolvers: {
21-
Query: {
22-
hello: () => 'Hi there.',
23-
},
24-
Subscription: {
25-
currentTime: {
26-
subscribe: () =>
27-
new Repeater(async (push, end) => {
28-
const interval = setInterval(() => {
29-
console.log('Publish new time')
30-
push({ currentTime: new Date().toISOString() })
31-
}, 1000)
32-
end.then(() => clearInterval(interval))
33-
await end
34-
}),
35-
},
36-
},
37-
},
38-
}),
39-
})
40-
41-
const httpServer = createServer(yogaApp)
42-
const wsServer = new WebSocketServer({
43-
server: httpServer,
44-
path: '/graphql',
45-
})
46-
47-
useServer(
48-
{
49-
execute: (args: any) => args.rootValue.execute(args),
50-
subscribe: (args: any) => args.rootValue.subscribe(args),
51-
onSubscribe: async (context, msg) => {
52-
const { schema, execute, subscribe, contextFactory, parse, validate } =
53-
yogaApp.getEnveloped(context)
54-
const args = {
55-
schema,
56-
operationName: msg.payload.operationName,
57-
document: parse(msg.payload.query),
58-
variableValues: msg.payload.variables,
59-
contextValue: await contextFactory(context),
60-
rootValue: {
61-
execute,
62-
subscribe,
63-
},
64-
}
65-
66-
const errors = validate(args.schema, args.document)
67-
if (errors.length) return errors
68-
return args
69-
},
70-
},
71-
wsServer,
72-
)
73-
74-
httpServer.listen(4000)
4+
const app = buildApp()
5+
await app.start(4000)
756
}
767

778
main().catch((e) => {

packages/graphql-yoga/src/plugins/requestValidation/usePreventMutationViaGET.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ export function usePreventMutationViaGET(): Plugin<YogaInitialContext> {
5050
onParse() {
5151
// We should improve this by getting Yoga stuff from the hook params directly instead of the context
5252
return ({ result, context: { request, operationName } }) => {
53+
// Run only if this is a Yoga request
54+
// the `request` might be missing when using graphql-ws for example
55+
// in which case throwing an error would abruptly close the socket
56+
if (!request) {
57+
return
58+
}
59+
5360
if (result instanceof Error) {
5461
if (result instanceof GraphQLError) {
5562
result.extensions.http = {

packages/graphql-yoga/src/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ export class YogaServer<
182182
TUserContext & TServerContext & YogaInitialContext
183183
>
184184
public logger: YogaLogger
185-
protected graphqlEndpoint: string
185+
public readonly graphqlEndpoint: string
186186
public fetchAPI: FetchAPI
187187
protected plugins: Array<
188188
Plugin<TUserContext & TServerContext & YogaInitialContext, TServerContext>

website/docs/features/subscriptions.mdx

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -266,54 +266,61 @@ Also, you can set `subscriptionsProtocol` in GraphiQL options to use WebSockets
266266
`yoga-with-ws.ts`
267267

268268
```ts
269-
import { createServer } from '@graphql-yoga/node'
269+
import { createServer } from 'http'
270270
import { WebSocketServer } from 'ws'
271+
import { createServer } from 'graphql-yoga'
271272
import { useServer } from 'graphql-ws/lib/use/ws'
272273

273274
async function main() {
274-
const yogaApp = createServer({
275+
const yoga = createYoga({
275276
graphiql: {
276277
// Use WebSockets in GraphiQL
277278
subscriptionsProtocol: 'WS',
278279
},
279280
})
280281

281-
// Get NodeJS Server from Yoga
282-
const httpServer = await yogaApp.start()
282+
// Create NodeJS Server from Yoga
283+
const server = createServer(yoga)
284+
283285
// Create WebSocket server instance from our Node server
284-
const wsServer = new WebSocketServer({
285-
server: httpServer,
286-
path: yogaApp.getAddressInfo().endpoint,
286+
const wss = new WebSocketServer({
287+
server,
288+
// Make sure WS is on the same endpoint
289+
path: yoga.graphqlEndpoint,
287290
})
288291

289292
// Integrate Yoga's Envelop instance and NodeJS server with graphql-ws
290293
useServer(
291294
{
292-
execute: (args: any) => args.rootValue.execute(args),
293-
subscribe: (args: any) => args.rootValue.subscribe(args),
295+
execute: (args: any) => args.execute(args),
296+
subscribe: (args: any) => args.subscribe(args),
294297
onSubscribe: async (ctx, msg) => {
295298
const { schema, execute, subscribe, contextFactory, parse, validate } =
296-
yogaApp.getEnveloped(ctx)
299+
yoga.getEnveloped({
300+
...ctx,
301+
req: ctx.extra.request,
302+
socket: ctx.extra.socket,
303+
})
297304

298305
const args = {
299306
schema,
300307
operationName: msg.payload.operationName,
301308
document: parse(msg.payload.query),
302309
variableValues: msg.payload.variables,
303310
contextValue: await contextFactory(),
304-
rootValue: {
305-
execute,
306-
subscribe,
307-
},
311+
execute,
312+
subscribe,
308313
}
309314

310315
const errors = validate(args.schema, args.document)
311316
if (errors.length) return errors
312317
return args
313318
},
314319
},
315-
wsServer,
320+
wss,
316321
)
322+
323+
server.listen(4000)
317324
}
318325

319326
main().catch((e) => {

0 commit comments

Comments
 (0)