Skip to content

Commit

Permalink
graphql-ws integration adjustments and tests (V3) (#1609)
Browse files Browse the repository at this point in the history
* fix integration and test

* update ws integration docs

* failing ws mutation

* mutations over ws

cb5d83d

* changeset
  • Loading branch information
enisdenjo committed Aug 17, 2022
1 parent c5001d3 commit 74e1f83
Show file tree
Hide file tree
Showing 8 changed files with 226 additions and 88 deletions.
5 changes: 5 additions & 0 deletions .changeset/grumpy-kangaroos-tell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'graphql-yoga': patch
---

`usePreventMutationViaGET` doesn't do assertion if it is not `YogaContext`
5 changes: 5 additions & 0 deletions .changeset/seven-tomatoes-invent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'graphql-yoga': patch
---

Expose readonly "graphqlEndpoint" in `YogaServerInstance`
79 changes: 79 additions & 0 deletions examples/graphql-ws/__integration-tests__/graphql-ws.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { buildApp } from '../src/app.js'
import WebSocket from 'ws'
import { createClient } from 'graphql-ws'

describe('graphql-ws example integration', () => {
const app = buildApp()
beforeAll(() => app.start(4000))
afterAll(() => app.stop())

it('should execute query', async () => {
const client = createClient({
webSocketImpl: WebSocket,
url: 'ws://localhost:4000/graphql',
retryAttempts: 0, // fail right away
})

const onNext = jest.fn()

await new Promise<void>((resolve, reject) => {
client.subscribe(
{ query: '{ hello }' },
{
next: onNext,
error: reject,
complete: resolve,
},
)
})

expect(onNext).toBeCalledWith({ data: { hello: 'world' } })
})

it('should execute mutation', async () => {
const client = createClient({
webSocketImpl: WebSocket,
url: 'ws://localhost:4000/graphql',
retryAttempts: 0, // fail right away
})

const onNext = jest.fn()

await new Promise<void>((resolve, reject) => {
client.subscribe(
{ query: 'mutation { dontChange }' },
{
next: onNext,
error: reject,
complete: resolve,
},
)
})

expect(onNext).toBeCalledWith({ data: { dontChange: 'didntChange' } })
})

it('should subscribe', async () => {
const client = createClient({
webSocketImpl: WebSocket,
url: 'ws://localhost:4000/graphql',
retryAttempts: 0, // fail right away
})

const onNext = jest.fn()

await new Promise<void>((resolve, reject) => {
client.subscribe(
{ query: 'subscription { greetings }' },
{
next: onNext,
error: reject,
complete: resolve,
},
)
})

expect(onNext).toBeCalledTimes(5)
expect(onNext).toBeCalledWith({ data: { greetings: 'Hi' } })
})
})
104 changes: 104 additions & 0 deletions examples/graphql-ws/src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { Socket } from 'net'
import { createServer } from 'http'
import { WebSocketServer } from 'ws'
import { createYoga, createSchema } from 'graphql-yoga'
import { useServer } from 'graphql-ws/lib/use/ws'

export function buildApp() {
const yoga = createYoga({
schema: createSchema({
typeDefs: /* GraphQL */ `
type Query {
hello: String!
}
type Mutation {
dontChange: String!
}
type Subscription {
greetings: String!
}
`,
resolvers: {
Query: {
hello() {
return 'world'
},
},
Mutation: {
dontChange() {
return 'didntChange'
},
},
Subscription: {
greetings: {
async *subscribe() {
for (const hi of ['Hi', 'Bonjour', 'Hola', 'Ciao', 'Zdravo']) {
yield { greetings: hi }
}
},
},
},
},
}),
})

const server = createServer(yoga)
const wss = new WebSocketServer({
server,
path: yoga.graphqlEndpoint,
})

useServer(
{
execute: (args: any) => args.execute(args),
subscribe: (args: any) => args.subscribe(args),
onSubscribe: async (ctx, msg) => {
const { schema, execute, subscribe, contextFactory, parse, validate } =
yoga.getEnveloped({
...ctx,
req: ctx.extra.request,
socket: ctx.extra.socket,
})

const args = {
schema,
operationName: msg.payload.operationName,
document: parse(msg.payload.query),
variableValues: msg.payload.variables,
contextValue: await contextFactory(),
execute,
subscribe,
}

const errors = validate(args.schema, args.document)
if (errors.length) return errors
return args
},
},
wss,
)

// for termination
const sockets = new Set<Socket>()
server.on('connection', (socket) => {
sockets.add(socket)
server.once('close', () => sockets.delete(socket))
})

return {
start: (port: number) =>
new Promise<void>((resolve, reject) => {
server.on('error', (err) => reject(err))
server.on('listening', () => resolve())
server.listen(port)
}),
stop: () =>
new Promise<void>((resolve) => {
for (const socket of sockets) {
socket.destroy()
sockets.delete(socket)
}
server.close(() => resolve())
}),
}
}
75 changes: 3 additions & 72 deletions examples/graphql-ws/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,77 +1,8 @@
import { createYoga, createSchema, Repeater } from 'graphql-yoga'
import { createServer } from 'http'
import { WebSocketServer } from 'ws'
import { useServer } from 'graphql-ws/lib/use/ws'
import { buildApp } from './app'

async function main() {
const yogaApp = createYoga({
graphiql: {
subscriptionsProtocol: 'WS',
},
schema: createSchema({
typeDefs: /* GraphQL */ `
type Query {
hello: String!
}
type Subscription {
currentTime: String
}
`,
resolvers: {
Query: {
hello: () => 'Hi there.',
},
Subscription: {
currentTime: {
subscribe: () =>
new Repeater(async (push, end) => {
const interval = setInterval(() => {
console.log('Publish new time')
push({ currentTime: new Date().toISOString() })
}, 1000)
end.then(() => clearInterval(interval))
await end
}),
},
},
},
}),
})

const httpServer = createServer(yogaApp)
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql',
})

useServer(
{
execute: (args: any) => args.rootValue.execute(args),
subscribe: (args: any) => args.rootValue.subscribe(args),
onSubscribe: async (context, msg) => {
const { schema, execute, subscribe, contextFactory, parse, validate } =
yogaApp.getEnveloped(context)
const args = {
schema,
operationName: msg.payload.operationName,
document: parse(msg.payload.query),
variableValues: msg.payload.variables,
contextValue: await contextFactory(context),
rootValue: {
execute,
subscribe,
},
}

const errors = validate(args.schema, args.document)
if (errors.length) return errors
return args
},
},
wsServer,
)

httpServer.listen(4000)
const app = buildApp()
await app.start(4000)
}

main().catch((e) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ export function usePreventMutationViaGET(): Plugin<YogaInitialContext> {
onParse() {
// We should improve this by getting Yoga stuff from the hook params directly instead of the context
return ({ result, context: { request, operationName } }) => {
// Run only if this is a Yoga request
// the `request` might be missing when using graphql-ws for example
// in which case throwing an error would abruptly close the socket
if (!request) {
return
}

if (result instanceof Error) {
if (result instanceof GraphQLError) {
result.extensions.http = {
Expand Down
2 changes: 1 addition & 1 deletion packages/graphql-yoga/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ export class YogaServer<
TUserContext & TServerContext & YogaInitialContext
>
public logger: YogaLogger
protected graphqlEndpoint: string
public readonly graphqlEndpoint: string
public fetchAPI: FetchAPI
protected plugins: Array<
Plugin<TUserContext & TServerContext & YogaInitialContext, TServerContext>
Expand Down
37 changes: 22 additions & 15 deletions website/docs/features/subscriptions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -266,54 +266,61 @@ Also, you can set `subscriptionsProtocol` in GraphiQL options to use WebSockets
`yoga-with-ws.ts`

```ts
import { createServer } from '@graphql-yoga/node'
import { createServer } from 'http'
import { WebSocketServer } from 'ws'
import { createServer } from 'graphql-yoga'
import { useServer } from 'graphql-ws/lib/use/ws'

async function main() {
const yogaApp = createServer({
const yoga = createYoga({
graphiql: {
// Use WebSockets in GraphiQL
subscriptionsProtocol: 'WS',
},
})

// Get NodeJS Server from Yoga
const httpServer = await yogaApp.start()
// Create NodeJS Server from Yoga
const server = createServer(yoga)

// Create WebSocket server instance from our Node server
const wsServer = new WebSocketServer({
server: httpServer,
path: yogaApp.getAddressInfo().endpoint,
const wss = new WebSocketServer({
server,
// Make sure WS is on the same endpoint
path: yoga.graphqlEndpoint,
})

// Integrate Yoga's Envelop instance and NodeJS server with graphql-ws
useServer(
{
execute: (args: any) => args.rootValue.execute(args),
subscribe: (args: any) => args.rootValue.subscribe(args),
execute: (args: any) => args.execute(args),
subscribe: (args: any) => args.subscribe(args),
onSubscribe: async (ctx, msg) => {
const { schema, execute, subscribe, contextFactory, parse, validate } =
yogaApp.getEnveloped(ctx)
yoga.getEnveloped({
...ctx,
req: ctx.extra.request,
socket: ctx.extra.socket,
})

const args = {
schema,
operationName: msg.payload.operationName,
document: parse(msg.payload.query),
variableValues: msg.payload.variables,
contextValue: await contextFactory(),
rootValue: {
execute,
subscribe,
},
execute,
subscribe,
}

const errors = validate(args.schema, args.document)
if (errors.length) return errors
return args
},
},
wsServer,
wss,
)

server.listen(4000)
}

main().catch((e) => {
Expand Down

0 comments on commit 74e1f83

Please sign in to comment.