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` 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..edf2f796c9 --- /dev/null +++ b/examples/graphql-ws/__integration-tests__/graphql-ws.spec.ts @@ -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((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((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((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..b9131597e7 --- /dev/null +++ b/examples/graphql-ws/src/app.ts @@ -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() + 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) => { 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 4f12138d16..def40572d0 100644 --- a/website/docs/features/subscriptions.mdx +++ b/website/docs/features/subscriptions.mdx @@ -266,34 +266,41 @@ 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, @@ -301,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) @@ -312,8 +317,10 @@ async function main() { return args }, }, - wsServer, + wss, ) + + server.listen(4000) } main().catch((e) => {