From d77276fb98a618b6d2e77d3976274cc20ccb025b Mon Sep 17 00:00:00 2001 From: Taylor Jones Date: Sun, 31 Aug 2025 17:34:02 -0400 Subject: [PATCH 1/2] Update example to remove AsyncGenerator (#654) AsyncGenerators are not safe to use outside of toy examples. The `next()` function will block any call to `return()` or `throw()` if it is waiting on a promise. To avoid this pitfall, implementations should not use the native `async function*()` syntax and should instead provide their own `AsyncIterator` implementations. --- website/README.md | 3 ++ website/src/pages/get-started.mdx | 54 ++++++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 website/README.md diff --git a/website/README.md b/website/README.md new file mode 100644 index 00000000..deee6ebd --- /dev/null +++ b/website/README.md @@ -0,0 +1,3 @@ +# README + +Building the website requires that the documentation be generated from the root project into the `src/pages/docs` directory _first_. This can be done by `yarn gendocs` from the root of the repo. \ No newline at end of file diff --git a/website/src/pages/get-started.mdx b/website/src/pages/get-started.mdx index 577fdb24..75b89625 100644 --- a/website/src/pages/get-started.mdx +++ b/website/src/pages/get-started.mdx @@ -42,15 +42,61 @@ export const schema = new GraphQLSchema({ fields: { greetings: { type: GraphQLString, - subscribe: async function* () { - for (const hi of ['Hi', 'Bonjour', 'Hola', 'Ciao', 'Zdravo']) { - yield { greetings: hi }; - } + subscribe: async function () { + return new EventProvider().iterator(); }, }, }, }), }); + +/** + * A toy event provider implementation. Will return a series of greetings with a small + * delay in between each one. + */ +class EventProvider { + private readonly events = ['Hi', 'Bonjour', 'Hola', 'Ciao', 'Zdravo']; + private promiseReject: ((v?: any) => void) | null = null; + private closed = false; + + close() { + if (this.closed) { return; } + if (this.promiseReject) { this.promiseReject('closed'); } + this.closed = true; + } + + isClosed() { return this.closed || this.events.length === 0; } + + async event() { + return await new Promise(async (resolve, reject) => { + this.promiseReject = reject; + setTimeout(() => resolve(this.events.shift()), 100); + }); + } + + iterator() { + const self = this; + return { + [Symbol.asyncIterator]() { return this; }, + async next(): { + try { + if (!self.isClosed()) { + return { value: await self.event(), done: false }; + } + } catch (e) { + return { value: undefined, done: true }; + } + }, + async return(v?: any) { + self.close(); + return { value: v, done: true }; + }, + async throw(v?: any) { + return { value: undefined, done: true }; + } + }; + } +} ``` ### Start the server From 7afd924f17194b9a40742e6422c844a40984b9d4 Mon Sep 17 00:00:00 2001 From: Taylor Jones Date: Wed, 3 Sep 2025 21:21:31 -0400 Subject: [PATCH 2/2] Moving example to recipes --- website/src/pages/get-started.mdx | 58 +++---------------- website/src/pages/recipes.mdx | 96 +++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 50 deletions(-) diff --git a/website/src/pages/get-started.mdx b/website/src/pages/get-started.mdx index 75b89625..4f69d440 100644 --- a/website/src/pages/get-started.mdx +++ b/website/src/pages/get-started.mdx @@ -16,6 +16,7 @@ npm i graphql-ws ```ts import { GraphQLObjectType, GraphQLSchema, GraphQLString } from 'graphql'; +import { Repeater } from "@repeaterjs/repeater"; /** * Construct a GraphQL schema and define the necessary resolvers. @@ -42,61 +43,18 @@ export const schema = new GraphQLSchema({ fields: { greetings: { type: GraphQLString, - subscribe: async function () { - return new EventProvider().iterator(); + subscribe: async function* () { + const greetings = ['Hi', 'Bonjour', 'Hola', 'Ciao', 'Zdravo']; + return new Repeater(async (push, stop) => { + const interval = setInterval(() => greetings.length > 0 ? push(greetings.shift()) : null, 1000); + await stop; + clearInterval(interval); + }); }, }, }, }), }); - -/** - * A toy event provider implementation. Will return a series of greetings with a small - * delay in between each one. - */ -class EventProvider { - private readonly events = ['Hi', 'Bonjour', 'Hola', 'Ciao', 'Zdravo']; - private promiseReject: ((v?: any) => void) | null = null; - private closed = false; - - close() { - if (this.closed) { return; } - if (this.promiseReject) { this.promiseReject('closed'); } - this.closed = true; - } - - isClosed() { return this.closed || this.events.length === 0; } - - async event() { - return await new Promise(async (resolve, reject) => { - this.promiseReject = reject; - setTimeout(() => resolve(this.events.shift()), 100); - }); - } - - iterator() { - const self = this; - return { - [Symbol.asyncIterator]() { return this; }, - async next(): { - try { - if (!self.isClosed()) { - return { value: await self.event(), done: false }; - } - } catch (e) { - return { value: undefined, done: true }; - } - }, - async return(v?: any) { - self.close(); - return { value: v, done: true }; - }, - async throw(v?: any) { - return { value: undefined, done: true }; - } - }; - } -} ``` ### Start the server diff --git a/website/src/pages/recipes.mdx b/website/src/pages/recipes.mdx index 45dc891c..9256c30d 100644 --- a/website/src/pages/recipes.mdx +++ b/website/src/pages/recipes.mdx @@ -922,6 +922,102 @@ wsServer.on('connection', (socket, request) => { }); ``` +### Server usage with a custom event emitter + +You may have a customer event production system that you need to tie into that requires you to write your own event emitter. When using graphql-ws, your `subscribe()` function *can* return an [`AsyncGenerator`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncGenerator) implementation to do this. Javascript has language-level support for `AsyncGenerator`s through the `async function*()`/`yield` syntax, and it easy easy to work with. However, it is not recommended that you use it in real-world applications. This is due to a constraint of `AsyncGenerators` in general and understanding why will take a little explanation. + +The consumer of an `AsyncGenerator` (in this case, `graphql-ws`) awaits the [`next()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncGenerator/next) function, which will return the value `yield`ed in your function. If the generator function is *also* `await`ing a promise before `yield`ing it, nothing else can change the state of the `AsyncGenerator` until the promise returns. + +The reason this is an issue is that `graphql-ws` will react to client "unsubscribe" requests by calling [`return()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncGenerator/return). This method will cause the `AsyncGenerator` to return a value immediately from the the `yield` statement, but if your function is currently `await`ing a promise, the call to `return()` will be blocked until that promise resolves. Until then, your event emitter will not be able to run any cleanup operations and none of its allocated objects will be garbage collected. + +Below is a toy implementation that can be used as a template for rolling your own emitter (you may also use a library that does this for you, like [Repeater.js](https://repeater.js.org/)). + + +```ts +import { GraphQLObjectType, GraphQLSchema, GraphQLString } from 'graphql'; + +/** + * Construct a GraphQL schema and define the necessary resolvers. + * + * type Query { + * hello: String + * } + * type Subscription { + * greetings: String + * } + */ +export const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + hello: { + type: GraphQLString, + resolve: () => 'world', + }, + }, + }), + subscription: new GraphQLObjectType({ + name: 'Subscription', + fields: { + greetings: { + type: GraphQLString, + subscribe: async function () { + return new EventProvider().iterator(); + }, + }, + }, + }), +}); + +/** + * A toy event provider implementation. Will return a series of greetings with a small + * delay in between each one. + */ +class EventProvider { + private readonly events = ['Hi', 'Bonjour', 'Hola', 'Ciao', 'Zdravo']; + private promiseReject: ((v?: any) => void) | null = null; + private closed = false; + + close() { + if (this.closed) { return; } + if (this.promiseReject) { this.promiseReject('closed'); } + this.closed = true; + } + + isClosed() { return this.closed || this.events.length === 0; } + + async event() { + return await new Promise(async (resolve, reject) => { + this.promiseReject = reject; + setTimeout(() => resolve(this.events.shift()), 100); + }); + } + + iterator() { + const self = this; + return { + [Symbol.asyncIterator]() { return this; }, + async next(): { + try { + if (!self.isClosed()) { + return { value: await self.event(), done: false }; + } + } catch (e) { + return { value: undefined, done: true }; + } + }, + async return(v?: any) { + self.close(); + return { value: v, done: true }; + }, + async throw(v?: any) { + return { value: undefined, done: true }; + } + }; + } +} +``` + ### Server usage with [Cloudflare Workers](https://workers.cloudflare.com/) [Please check the `worker-graphql-ws-template` repo out.](https://github.com/enisdenjo/cloudflare-worker-graphql-ws-template)