From 707d4d3cb9b1e72d33c58826b804d38f81795246 Mon Sep 17 00:00:00 2001 From: enisdenjo Date: Tue, 16 Aug 2022 16:57:26 +0200 Subject: [PATCH 1/5] fix integration and test --- .../__integration-tests__/graphql-ws.spec.ts | 56 +++++++++++ examples/graphql-ws/src/app.ts | 94 +++++++++++++++++++ examples/graphql-ws/src/index.ts | 75 +-------------- 3 files changed, 153 insertions(+), 72 deletions(-) create mode 100644 examples/graphql-ws/__integration-tests__/graphql-ws.spec.ts create mode 100644 examples/graphql-ws/src/app.ts diff --git a/examples/graphql-ws/__integration-tests__/graphql-ws.spec.ts b/examples/graphql-ws/__integration-tests__/graphql-ws.spec.ts new file mode 100644 index 0000000000..80f0b19d1b --- /dev/null +++ b/examples/graphql-ws/__integration-tests__/graphql-ws.spec.ts @@ -0,0 +1,56 @@ +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((resolve, reject) => { + client.subscribe( + { query: '{ hello }' }, + { + next: onNext, + error: reject, + complete: resolve, + }, + ) + }) + + expect(onNext).toBeCalledWith({ data: { hello: 'world' } }) + }) + + 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((resolve, reject) => { + client.subscribe( + { query: 'subscription { greetings }' }, + { + next: onNext, + error: reject, + complete: resolve, + }, + ) + }) + + expect(onNext).toBeCalledTimes(5) + expect(onNext).toBeCalledWith({ data: { greetings: 'Hi' } }) + }) +}) diff --git a/examples/graphql-ws/src/app.ts b/examples/graphql-ws/src/app.ts new file mode 100644 index 0000000000..22f4915be1 --- /dev/null +++ b/examples/graphql-ws/src/app.ts @@ -0,0 +1,94 @@ +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 Subscription { + greetings: String! + } + `, + resolvers: { + Query: { + hello() { + return 'world' + }, + }, + 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: '/graphql', + }) + + useServer( + { + execute: (args: any) => args.rootValue.execute(args), + subscribe: (args: any) => args.rootValue.subscribe(args), + onSubscribe: async (ctx, msg) => { + const { schema, execute, subscribe, contextFactory, parse, validate } = + yoga.getEnveloped({ ...ctx, ...ctx.extra }) + + const args = { + schema, + operationName: msg.payload.operationName, + document: parse(msg.payload.query), + variableValues: msg.payload.variables, + contextValue: await contextFactory(), + rootValue: { + execute, + subscribe, + }, + } + + const errors = validate(args.schema, args.document) + if (errors.length) return errors + return args + }, + }, + wss, + ) + + // for termination + const sockets = new Set() + server.on('connection', (socket) => { + sockets.add(socket) + server.once('close', () => sockets.delete(socket)) + }) + + return { + start: (port: number) => + new Promise((resolve, reject) => { + server.on('error', (err) => reject(err)) + server.on('listening', () => resolve()) + server.listen(port) + }), + stop: () => + new Promise((resolve) => { + for (const socket of sockets) { + socket.destroy() + sockets.delete(socket) + } + server.close(() => resolve()) + }), + } +} diff --git a/examples/graphql-ws/src/index.ts b/examples/graphql-ws/src/index.ts index e403fb9886..fd6490bba0 100644 --- a/examples/graphql-ws/src/index.ts +++ b/examples/graphql-ws/src/index.ts @@ -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) => { From bb2722a8d4fb2ef9550c73ef1022aa31dd2e91af Mon Sep 17 00:00:00 2001 From: enisdenjo Date: Tue, 16 Aug 2022 16:57:36 +0200 Subject: [PATCH 2/5] update ws integration docs --- website/docs/features/subscriptions.mdx | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/website/docs/features/subscriptions.mdx b/website/docs/features/subscriptions.mdx index 4f12138d16..920ca4f5d4 100644 --- a/website/docs/features/subscriptions.mdx +++ b/website/docs/features/subscriptions.mdx @@ -266,24 +266,26 @@ 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, + path: '/graphql', }) // Integrate Yoga's Envelop instance and NodeJS server with graphql-ws @@ -293,7 +295,7 @@ async function main() { subscribe: (args: any) => args.rootValue.subscribe(args), onSubscribe: async (ctx, msg) => { const { schema, execute, subscribe, contextFactory, parse, validate } = - yogaApp.getEnveloped(ctx) + yogaApp.getEnveloped({ ...ctx, ...ctx.extra }) const args = { schema, @@ -312,8 +314,10 @@ async function main() { return args }, }, - wsServer, + wss, ) + + server.listen(4000) } main().catch((e) => { From 88cd05ef0a6bcffec8d85d59ef92f88443df7ec5 Mon Sep 17 00:00:00 2001 From: enisdenjo Date: Tue, 16 Aug 2022 17:14:12 +0200 Subject: [PATCH 3/5] failing ws mutation --- .../__integration-tests__/graphql-ws.spec.ts | 23 +++++++++++++++++++ examples/graphql-ws/src/app.ts | 8 +++++++ 2 files changed, 31 insertions(+) diff --git a/examples/graphql-ws/__integration-tests__/graphql-ws.spec.ts b/examples/graphql-ws/__integration-tests__/graphql-ws.spec.ts index 80f0b19d1b..edf2f796c9 100644 --- a/examples/graphql-ws/__integration-tests__/graphql-ws.spec.ts +++ b/examples/graphql-ws/__integration-tests__/graphql-ws.spec.ts @@ -30,6 +30,29 @@ describe('graphql-ws example integration', () => { 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((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, diff --git a/examples/graphql-ws/src/app.ts b/examples/graphql-ws/src/app.ts index 22f4915be1..0be0c12c0f 100644 --- a/examples/graphql-ws/src/app.ts +++ b/examples/graphql-ws/src/app.ts @@ -11,6 +11,9 @@ export function buildApp() { type Query { hello: String! } + type Mutation { + dontChange: String! + } type Subscription { greetings: String! } @@ -21,6 +24,11 @@ export function buildApp() { return 'world' }, }, + Mutation: { + dontChange() { + return 'didntChange' + }, + }, Subscription: { greetings: { async *subscribe() { From 5f8da7d4efb8be62ed1dd5969415ccc3cf270193 Mon Sep 17 00:00:00 2001 From: enisdenjo Date: Wed, 17 Aug 2022 12:11:19 +0200 Subject: [PATCH 4/5] mutations over ws https://github.com/dotansimha/graphql-yoga/commit/cb5d83df029e685cadd798b35b1eae2ea79c830d --- examples/graphql-ws/src/app.ts | 18 ++++++++++-------- .../usePreventMutationViaGET.ts | 7 +++++++ packages/graphql-yoga/src/server.ts | 2 +- website/docs/features/subscriptions.mdx | 19 +++++++++++-------- 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/examples/graphql-ws/src/app.ts b/examples/graphql-ws/src/app.ts index 0be0c12c0f..b9131597e7 100644 --- a/examples/graphql-ws/src/app.ts +++ b/examples/graphql-ws/src/app.ts @@ -45,16 +45,20 @@ export function buildApp() { const server = createServer(yoga) const wss = new WebSocketServer({ server, - path: '/graphql', + path: yoga.graphqlEndpoint, }) 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 } = - yoga.getEnveloped({ ...ctx, ...ctx.extra }) + yoga.getEnveloped({ + ...ctx, + req: ctx.extra.request, + socket: ctx.extra.socket, + }) const args = { schema, @@ -62,10 +66,8 @@ export function buildApp() { document: parse(msg.payload.query), variableValues: msg.payload.variables, contextValue: await contextFactory(), - rootValue: { - execute, - subscribe, - }, + execute, + subscribe, } const errors = validate(args.schema, args.document) diff --git a/packages/graphql-yoga/src/plugins/requestValidation/usePreventMutationViaGET.ts b/packages/graphql-yoga/src/plugins/requestValidation/usePreventMutationViaGET.ts index c3d706e274..b9ae05eae4 100644 --- a/packages/graphql-yoga/src/plugins/requestValidation/usePreventMutationViaGET.ts +++ b/packages/graphql-yoga/src/plugins/requestValidation/usePreventMutationViaGET.ts @@ -50,6 +50,13 @@ export function usePreventMutationViaGET(): Plugin { 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 = { diff --git a/packages/graphql-yoga/src/server.ts b/packages/graphql-yoga/src/server.ts index ce0a91a663..01fb2fd284 100644 --- a/packages/graphql-yoga/src/server.ts +++ b/packages/graphql-yoga/src/server.ts @@ -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 diff --git a/website/docs/features/subscriptions.mdx b/website/docs/features/subscriptions.mdx index 920ca4f5d4..def40572d0 100644 --- a/website/docs/features/subscriptions.mdx +++ b/website/docs/features/subscriptions.mdx @@ -285,17 +285,22 @@ async function main() { // Create WebSocket server instance from our Node server const wss = new WebSocketServer({ server, - path: '/graphql', + // 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, ...ctx.extra }) + yoga.getEnveloped({ + ...ctx, + req: ctx.extra.request, + socket: ctx.extra.socket, + }) const args = { schema, @@ -303,10 +308,8 @@ async function main() { document: parse(msg.payload.query), variableValues: msg.payload.variables, contextValue: await contextFactory(), - rootValue: { - execute, - subscribe, - }, + execute, + subscribe, } const errors = validate(args.schema, args.document) From 12fa22c40c9db203e6d72a63091fc8cde7fc3ae4 Mon Sep 17 00:00:00 2001 From: enisdenjo Date: Wed, 17 Aug 2022 12:12:40 +0200 Subject: [PATCH 5/5] changeset --- .changeset/grumpy-kangaroos-tell.md | 5 +++++ .changeset/seven-tomatoes-invent.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/grumpy-kangaroos-tell.md create mode 100644 .changeset/seven-tomatoes-invent.md diff --git a/.changeset/grumpy-kangaroos-tell.md b/.changeset/grumpy-kangaroos-tell.md new file mode 100644 index 0000000000..ecc20c1aa9 --- /dev/null +++ b/.changeset/grumpy-kangaroos-tell.md @@ -0,0 +1,5 @@ +--- +'graphql-yoga': patch +--- + +`usePreventMutationViaGET` doesn't do assertion if it is not `YogaContext` diff --git a/.changeset/seven-tomatoes-invent.md b/.changeset/seven-tomatoes-invent.md new file mode 100644 index 0000000000..072c636b31 --- /dev/null +++ b/.changeset/seven-tomatoes-invent.md @@ -0,0 +1,5 @@ +--- +'graphql-yoga': patch +--- + +Expose readonly "graphqlEndpoint" in `YogaServerInstance`