Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

graphql-ws integration adjustments and tests (V3) #1609

Merged
merged 5 commits into from
Aug 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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