From 05a62eac2083e5eff84bf7baeef83b77d0c6e37a Mon Sep 17 00:00:00 2001 From: rushrs <17338080+rushrs@users.noreply.github.com> Date: Mon, 27 Apr 2026 19:52:44 +0100 Subject: [PATCH 1/2] Add SSE subscription support to GraphiQL fetcher --- .changeset/silent-lemons-learn.md | 5 + packages/graphiql-toolkit/README.md | 2 +- .../graphiql-toolkit/docs/create-fetcher.md | 102 +++++++++++- packages/graphiql-toolkit/package.json | 5 + .../__tests__/buildFetcher.spec.ts | 41 +++++ .../src/create-fetcher/__tests__/lib.spec.ts | 149 ++++++++++++++++++ .../src/create-fetcher/createFetcher.ts | 23 +-- .../src/create-fetcher/lib.ts | 117 +++++++++++--- .../src/create-fetcher/types.ts | 31 ++++ yarn.lock | 13 ++ 10 files changed, 456 insertions(+), 32 deletions(-) create mode 100644 .changeset/silent-lemons-learn.md diff --git a/.changeset/silent-lemons-learn.md b/.changeset/silent-lemons-learn.md new file mode 100644 index 00000000000..6307b1f99a2 --- /dev/null +++ b/.changeset/silent-lemons-learn.md @@ -0,0 +1,5 @@ +--- +'@graphiql/toolkit': minor +--- + +Add GraphQL over SSE support to `createGraphiQLFetcher`. diff --git a/packages/graphiql-toolkit/README.md b/packages/graphiql-toolkit/README.md index 8446b1d8890..6eeb2cab5f4 100644 --- a/packages/graphiql-toolkit/README.md +++ b/packages/graphiql-toolkit/README.md @@ -14,5 +14,5 @@ that are useful when working with these packages. - **[`createFetcher`](./docs/create-fetcher.md)** : a utility for creating a `fetcher` prop implementation for HTTP GET, POST including multipart, - websockets fetcher + GraphQL over SSE and websockets subscriptions - more to come! diff --git a/packages/graphiql-toolkit/docs/create-fetcher.md b/packages/graphiql-toolkit/docs/create-fetcher.md index 939f6e9f872..8b9ca294c9a 100644 --- a/packages/graphiql-toolkit/docs/create-fetcher.md +++ b/packages/graphiql-toolkit/docs/create-fetcher.md @@ -2,10 +2,11 @@ A utility for generating a full-featured `fetcher` for GraphiQL including `@stream`, `@defer` `IncrementalDelivery`and `multipart` and subscriptions using -`graphql-ws` or the legacy websockets protocol. +GraphQL over SSE, `graphql-ws` or the legacy websockets protocol. -Under the hood, it uses [`graphql-ws`](https://www.npmjs.com/package/graphql-ws) -client and [`meros`](https://www.npmjs.com/package/meros) which act as client +Under the hood, it uses [`graphql-sse`](https://www.npmjs.com/package/graphql-sse), +[`graphql-ws`](https://www.npmjs.com/package/graphql-ws) and +[`meros`](https://www.npmjs.com/package/meros) which act as client reference implementations of the [GraphQL over HTTP Working Group Spec](https://github.com/graphql/graphql-over-http) specification, and the most popular transport spec proposals. @@ -45,6 +46,54 @@ const root = createRoot(document.getElementById('graphiql')); root.render(); ``` +### Adding GraphQL over SSE subscriptions + +First you'll need to install `graphql-sse` as a peer dependency: + +```bash +npm install graphql-sse +``` + +When loading GraphiQL from an ESM CDN with an import map, make sure the optional +`graphql-sse` peer dependency is also mapped if you use `sseUrl`: + +```html + +``` + +Just by providing the `sseUrl`, you can generate a `graphql-sse` client. This +client supports HTTP/Multipart Incremental Delivery for `@defer` and `@stream`, +_and_ subscriptions over Server-Sent Events. + +```jsx +import * as React from 'react'; +import { createRoot } from 'react-dom/client'; +import { GraphiQL } from 'graphiql'; +import { createGraphiQLFetcher } from '@graphiql/toolkit'; + +const url = 'https://my-schema.com/graphql'; + +const sseUrl = 'https://my-schema.com/graphql/stream'; + +const fetcher = createGraphiQLFetcher({ url, sseUrl }); + +export const App = () => ; + +const root = createRoot(document.getElementById('graphiql')); +root.render(); +``` + +You can further customize the `graphql-sse` implementation by creating a custom +client instance and providing it as the `sseClient` parameter. + ### Adding `graphql-ws` websockets subscriptions First you'll need to install `graphql-ws` as a peer dependency: @@ -90,6 +139,24 @@ This generates a `graphql-ws` client using the provided url. Note that a server must be compatible with the new `graphql-ws` subscriptions spec for this to work. +### `sseUrl` + +This generates a `graphql-sse` client using the provided url. Note that a server +must be compatible with the GraphQL over SSE protocol for this to work. When +`sseUrl` or `sseClient` is provided, GraphiQL uses SSE for subscriptions instead +of websockets. + +### `sseClient` + +Provide your own GraphQL over SSE subscriptions client. Using this option +bypasses `sseUrl`. In theory, this could be any client using any transport, as +long as it matches the `graphql-sse` client signature. + +### `sseClientOptions` + +Provide additional options used when creating a `graphql-sse` client from +`sseUrl`, for example `singleConnection`. + ### `wsClient` Provide your own subscriptions client. Using this option bypasses @@ -129,6 +196,35 @@ Pass a custom fetch implementation such as `isomorphic-fetch`. ## Customization Examples +### Custom `sseClient` Example using `graphql-sse` + +This example passes a `graphql-sse` client to the `sseClient` option: + +```jsx +import * as React from 'react'; +import { createRoot } from 'react-dom/client'; +import { GraphiQL } from 'graphiql'; +import { createClient } from 'graphql-sse'; +import { createGraphiQLFetcher } from '@graphiql/toolkit'; + +const url = 'https://my-schema.com/graphql'; + +const sseUrl = 'https://my-schema.com/graphql/stream'; + +const fetcher = createGraphiQLFetcher({ + url, + sseClient: createClient({ + url: sseUrl, + singleConnection: true, + }), +}); + +export const App = () => ; + +const root = createRoot(document.getElementById('graphiql')); +root.render(); +``` + ### Custom `wsClient` Example using `graphql-ws` This example passes a `graphql-ws` client to the `wsClient` option: diff --git a/packages/graphiql-toolkit/package.json b/packages/graphiql-toolkit/package.json index 8b456840bc9..f21feffb774 100644 --- a/packages/graphiql-toolkit/package.json +++ b/packages/graphiql-toolkit/package.json @@ -30,6 +30,7 @@ }, "devDependencies": { "graphql": "^16.9.0", + "graphql-sse": "^2.6.0", "graphql-ws": "^5.5.5", "isomorphic-fetch": "^3.0.0", "subscriptions-transport-ws": "0.11.0", @@ -37,9 +38,13 @@ }, "peerDependencies": { "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0", + "graphql-sse": ">= 2.0.0", "graphql-ws": ">= 4.5.0" }, "peerDependenciesMeta": { + "graphql-sse": { + "optional": true + }, "graphql-ws": { "optional": true } diff --git a/packages/graphiql-toolkit/src/create-fetcher/__tests__/buildFetcher.spec.ts b/packages/graphiql-toolkit/src/create-fetcher/__tests__/buildFetcher.spec.ts index 35a270933c4..b52ec79dcfa 100644 --- a/packages/graphiql-toolkit/src/create-fetcher/__tests__/buildFetcher.spec.ts +++ b/packages/graphiql-toolkit/src/create-fetcher/__tests__/buildFetcher.spec.ts @@ -16,14 +16,22 @@ import { createSimpleFetcher as _createSimpleFetcher, createWebsocketsFetcherFromClient as _createWebsocketsFetcherFromClient, createLegacyWebsocketsFetcher as _createLegacyWebsocketsFetcher, + getSubscriptionFetcher as _getSubscriptionFetcher, + isSubscriptionWithName as _isSubscriptionWithName, } from '../lib'; import { createClient as _createClient } from 'graphql-ws'; import { SubscriptionClient } from 'subscriptions-transport-ws'; const serverURL = 'http://localhost:3000/graphql'; const wssURL = 'ws://localhost:3000/graphql'; +const sseURL = 'http://localhost:3000/graphql/stream'; const exampleIntrospectionDocument = parse(getIntrospectionQuery()); +const exampleSubscriptionDocument = parse(/* GraphQL */ ` + subscription Example { + example + } +`); const createWebsocketsFetcherFromUrl = _createWebsocketsFetcherFromUrl as Mock< typeof _createWebsocketsFetcherFromUrl @@ -42,6 +50,12 @@ const createWebsocketsFetcherFromClient = const createLegacyWebsocketsFetcher = _createLegacyWebsocketsFetcher as Mock< typeof _createLegacyWebsocketsFetcher >; +const getSubscriptionFetcher = _getSubscriptionFetcher as Mock< + typeof _getSubscriptionFetcher +>; +const isSubscriptionWithName = _isSubscriptionWithName as Mock< + typeof _isSubscriptionWithName +>; describe('createGraphiQLFetcher', () => { afterEach(() => { @@ -138,4 +152,31 @@ describe('createGraphiQLFetcher', () => { expect(createWebsocketsFetcherFromClient.mock.calls).toEqual([]); expect(createLegacyWebsocketsFetcher.mock.calls).toEqual([]); }); + + it('uses the subscription fetcher for subscription operations', async () => { + const subscriptionFetcher = vi.fn(() => ({ data: { example: true } })); + isSubscriptionWithName.mockReturnValue(true); + getSubscriptionFetcher.mockResolvedValue(subscriptionFetcher); + + const args = { + url: serverURL, + sseUrl: sseURL, + enableIncrementalDelivery: true, + }; + const graphQLParams = { + query: 'subscription Example { example }', + operationName: 'Example', + }; + const fetcherOpts = { + documentAST: exampleSubscriptionDocument, + headers: { authorization: 'Bearer token' }, + }; + + const fetcher = createGraphiQLFetcher(args); + const result = await fetcher(graphQLParams, fetcherOpts); + + expect(getSubscriptionFetcher.mock.calls).toEqual([[args, fetcherOpts]]); + expect(subscriptionFetcher.mock.calls).toEqual([[graphQLParams]]); + expect(result).toEqual({ data: { example: true } }); + }); }); diff --git a/packages/graphiql-toolkit/src/create-fetcher/__tests__/lib.spec.ts b/packages/graphiql-toolkit/src/create-fetcher/__tests__/lib.spec.ts index c1b425469aa..47e69d9b73f 100644 --- a/packages/graphiql-toolkit/src/create-fetcher/__tests__/lib.spec.ts +++ b/packages/graphiql-toolkit/src/create-fetcher/__tests__/lib.spec.ts @@ -2,22 +2,29 @@ import { type Mock, describe, it, expect, vi, afterEach } from 'vitest'; import { parse } from 'graphql'; import { isSubscriptionWithName, + createSubscriptionFetcherFromClient, + createSseFetcherFromUrl, createWebsocketsFetcherFromUrl, + getSubscriptionFetcher, getWsFetcher, } from '../lib'; import 'isomorphic-fetch'; vi.mock('graphql-ws'); +vi.mock('graphql-sse'); vi.mock('subscriptions-transport-ws'); import { createClient as _createClient } from 'graphql-ws'; +import { createClient as _createSseClient } from 'graphql-sse'; import { SubscriptionClient as _SubscriptionClient } from 'subscriptions-transport-ws'; const createClient = _createClient as Mock; +const createSseClient = _createSseClient as Mock; const SubscriptionClient = _SubscriptionClient as Mock; +const sseURL = 'https://example.com/graphql/stream'; const exampleWithSubscription = parse(/* GraphQL */ ` subscription Example { @@ -66,6 +73,58 @@ describe('createWebsocketsFetcherFromUrl', () => { }); }); +describe('createSubscriptionFetcherFromClient', () => { + it('adapts subscription clients to async iterables and disposes them', async () => { + const unsubscribe = vi.fn(); + const client = { + subscribe: vi.fn((_payload, sink) => { + sink.next({ data: { example: true } }); + return unsubscribe; + }), + }; + const fetcher = createSubscriptionFetcherFromClient(client); + + const result = fetcher({ + query: 'subscription Example { example }', + operationName: 'Example', + }) as AsyncIterableIterator; + + await expect(result.next()).resolves.toEqual({ + done: false, + value: { data: { example: true } }, + }); + + await result.return?.(); + + expect(client.subscribe.mock.calls[0][0]).toEqual({ + query: 'subscription Example { example }', + operationName: 'Example', + }); + expect(unsubscribe).toHaveBeenCalled(); + }); +}); + +describe('createSseFetcherFromUrl', () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + it('creates an SSE client using provided url, headers and client options', async () => { + // @ts-expect-error + createSseClient.mockReturnValue(true); + await createSseFetcherFromUrl( + sseURL, + { authorization: 'Bearer token' }, + { singleConnection: true }, + ); + expect(createSseClient.mock.calls[0][0]).toEqual({ + singleConnection: true, + url: sseURL, + headers: { authorization: 'Bearer token' }, + }); + }); +}); + describe('getWsFetcher', () => { afterEach(() => { vi.resetAllMocks(); @@ -96,6 +155,60 @@ describe('getWsFetcher', () => { }); }); +describe('getSubscriptionFetcher', () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + it('prefers a custom sseClient option over websocket options', async () => { + // @ts-expect-error + createClient.mockReturnValue(true); + await getSubscriptionFetcher({ + url: '', + // @ts-expect-error + sseClient: true, + // @ts-expect-error + wsClient: true, + subscriptionUrl: 'wss://example', + }); + expect(createSseClient.mock.calls).toHaveLength(0); + expect(createClient.mock.calls).toHaveLength(0); + }); + + it('creates an SSE client when sseUrl is provided and merges headers', async () => { + // @ts-expect-error + createSseClient.mockReturnValue(true); + await getSubscriptionFetcher( + { + url: '', + sseUrl: sseURL, + subscriptionUrl: 'wss://example', + headers: { 'x-static': 'static' }, + sseClientOptions: { singleConnection: true }, + }, + { headers: { 'x-request': 'request' } }, + ); + expect(createSseClient.mock.calls[0][0]).toEqual({ + singleConnection: true, + url: sseURL, + headers: { 'x-static': 'static', 'x-request': 'request' }, + }); + expect(createClient.mock.calls).toHaveLength(0); + }); + + it('falls back to websocket options when SSE is not configured', async () => { + // @ts-expect-error + createClient.mockReturnValue(true); + await getSubscriptionFetcher({ + url: '', + subscriptionUrl: 'wss://example', + }); + expect(createClient.mock.calls[0]).toEqual([ + { connectionParams: {}, url: 'wss://example' }, + ]); + }); +}); + describe('missing `graphql-ws` dependency', () => { it('should throw a nice error', async () => { vi.resetModules(); @@ -117,4 +230,40 @@ describe('missing `graphql-ws` dependency', () => { /You need to install the 'graphql-ws' package to use websockets when passing a 'subscriptionUrl'/, ); }); + + it('should throw a nice error when ESM import reports a missing module', async () => { + vi.resetModules(); + vi.doMock('graphql-ws', () => { + return { + createClient: vi.fn().mockImplementation(() => { + // eslint-disable-next-line no-throw-literal + throw { code: 'ERR_MODULE_NOT_FOUND' }; + }), + }; + }); + + await expect( + createWebsocketsFetcherFromUrl('wss://example.com'), + ).rejects.toThrow( + /You need to install the 'graphql-ws' package to use websockets when passing a 'subscriptionUrl'/, + ); + }); +}); + +describe('missing `graphql-sse` dependency', () => { + it('should throw a nice error', async () => { + vi.resetModules(); + vi.doMock('graphql-sse', () => { + return { + createClient: vi.fn().mockImplementation(() => { + // eslint-disable-next-line no-throw-literal + throw { code: 'MODULE_NOT_FOUND' }; + }), + }; + }); + + await expect(createSseFetcherFromUrl(sseURL)).rejects.toThrow( + /You need to install the 'graphql-sse' package to use GraphQL over SSE when passing an 'sseUrl'/, + ); + }); }); diff --git a/packages/graphiql-toolkit/src/create-fetcher/createFetcher.ts b/packages/graphiql-toolkit/src/create-fetcher/createFetcher.ts index b90c65ee83e..05de74cdeb9 100644 --- a/packages/graphiql-toolkit/src/create-fetcher/createFetcher.ts +++ b/packages/graphiql-toolkit/src/create-fetcher/createFetcher.ts @@ -4,13 +4,13 @@ import { createMultipartFetcher, createSimpleFetcher, isSubscriptionWithName, - getWsFetcher, + getSubscriptionFetcher, } from './lib'; /** * build a GraphiQL fetcher that is: * - backwards compatible - * - optionally supports graphql-ws or ` + * - optionally supports GraphQL over SSE, graphql-ws, or legacy websockets */ export function createGraphiQLFetcher(options: CreateFetcherOptions): Fetcher { const httpFetch = @@ -41,18 +41,23 @@ export function createGraphiQLFetcher(options: CreateFetcherOptions): Fetcher { ) : false; if (isSubscription) { - const wsFetcher = await getWsFetcher(options, fetcherOpts); + const subscriptionFetcher = await getSubscriptionFetcher( + options, + fetcherOpts, + ); - if (!wsFetcher) { + if (!subscriptionFetcher) { throw new Error( - `Your GraphiQL createFetcher is not properly configured for websocket subscriptions yet. ${ - options.subscriptionUrl - ? `Provided URL ${options.subscriptionUrl} failed` - : 'Please provide subscriptionUrl, wsClient or legacyClient option first.' + `Your GraphiQL createFetcher is not properly configured for subscriptions yet. ${ + options.sseUrl + ? `Provided SSE URL ${options.sseUrl} failed` + : options.subscriptionUrl + ? `Provided websocket URL ${options.subscriptionUrl} failed` + : 'Please provide sseUrl, sseClient, subscriptionUrl, wsClient or legacyClient option first.' }`, ); } - return wsFetcher(graphQLParams); + return subscriptionFetcher(graphQLParams); } return httpFetcher(graphQLParams, fetcherOpts); }; diff --git a/packages/graphiql-toolkit/src/create-fetcher/lib.ts b/packages/graphiql-toolkit/src/create-fetcher/lib.ts index 6f046d8f41b..1f4c9d3a817 100644 --- a/packages/graphiql-toolkit/src/create-fetcher/lib.ts +++ b/packages/graphiql-toolkit/src/create-fetcher/lib.ts @@ -18,12 +18,19 @@ import type { FetcherOpts, ExecutionResultPayload, CreateFetcherOptions, + GraphQLSSEClient, + GraphQLSSEClientOptions, + SubscriptionClient, } from './types'; const errorHasCode = (err: unknown): err is { code: string } => { return typeof err === 'object' && err !== null && 'code' in err; }; +type GraphQLSSEModule = { + createClient: (options: GraphQLSSEClientOptions) => GraphQLSSEClient; +}; + /** * Returns true if the name matches a subscription in the AST * @@ -85,7 +92,10 @@ export async function createWebsocketsFetcherFromUrl( wsClient = createClient({ url, connectionParams }); return createWebsocketsFetcherFromClient(wsClient); } catch (err) { - if (errorHasCode(err) && err.code === 'MODULE_NOT_FOUND') { + if ( + errorHasCode(err) && + (err.code === 'MODULE_NOT_FOUND' || err.code === 'ERR_MODULE_NOT_FOUND') + ) { throw new Error( "You need to install the 'graphql-ws' package to use websockets when passing a 'subscriptionUrl'", ); @@ -96,29 +106,75 @@ export async function createWebsocketsFetcherFromUrl( } /** - * Create ws/s fetcher using provided wsClient implementation + * Create a fetcher using any subscription client that accepts GraphQL params + * and pushes execution results into a sink. */ -export const createWebsocketsFetcherFromClient = - (wsClient: Client): Fetcher => +export const createSubscriptionFetcherFromClient = + ( + subscriptionClient: SubscriptionClient, + normalizeError: (err: any) => any = err => err, + ): Fetcher => (graphQLParams: FetcherParams) => - makeAsyncIterableIteratorFromSink(sink => - wsClient.subscribe(graphQLParams, { + makeAsyncIterableIteratorFromSink(sink => { + const dispose = subscriptionClient.subscribe(graphQLParams, { ...sink, error(err) { - if (err instanceof CloseEvent) { - sink.error( - new Error( - `Socket closed with event ${err.code} ${ - err.reason || '' - }`.trim(), - ), - ); - } else { - sink.error(err); - } + sink.error(normalizeError(err)); }, - }), - ); + }); + return typeof dispose === 'function' ? dispose : () => {}; + }); + +/** + * Create ws/s fetcher using provided wsClient implementation + */ +export const createWebsocketsFetcherFromClient = (wsClient: Client): Fetcher => + createSubscriptionFetcherFromClient(wsClient, err => { + if (typeof CloseEvent !== 'undefined' && err instanceof CloseEvent) { + return new Error( + `Socket closed with event ${err.code} ${err.reason || ''}`.trim(), + ); + } + return err; + }); + +export async function createSseFetcherFromUrl( + url: string, + headers?: FetcherOpts['headers'], + sseClientOptions?: CreateFetcherOptions['sseClientOptions'], +): Promise { + try { + const { createClient } = + process.env.USE_IMPORT === 'false' + ? (require('graphql-sse') as GraphQLSSEModule) + : ((await import('graphql-sse')) as GraphQLSSEModule); + + const sseClient = createClient({ + ...sseClientOptions, + url, + headers: headers || {}, + }); + return createSseFetcherFromClient(sseClient); + } catch (err) { + if ( + errorHasCode(err) && + (err.code === 'MODULE_NOT_FOUND' || err.code === 'ERR_MODULE_NOT_FOUND') + ) { + throw new Error( + "You need to install the 'graphql-sse' package to use GraphQL over SSE when passing an 'sseUrl'", + ); + } + // eslint-disable-next-line no-console + console.error(`Error creating SSE client for ${url}`, err); + } +} + +/** + * Create GraphQL over SSE fetcher using provided sseClient implementation + */ +export const createSseFetcherFromClient = ( + sseClient: GraphQLSSEClient, +): Fetcher => createSubscriptionFetcherFromClient(sseClient); /** * Allow legacy websockets protocol client, but no definitions for it, @@ -198,3 +254,26 @@ export async function getWsFetcher( return createLegacyWebsocketsFetcher(legacyWebsocketsClient); } } + +/** + * If `sseClient` or `sseUrl` are provided, they take precedence over websocket clients. + */ +export async function getSubscriptionFetcher( + options: CreateFetcherOptions, + fetcherOpts?: FetcherOpts, +): Promise { + if (options.sseClient) { + return createSseFetcherFromClient(options.sseClient); + } + if (options.sseUrl) { + return createSseFetcherFromUrl( + options.sseUrl, + { + ...options.headers, + ...fetcherOpts?.headers, + }, + options.sseClientOptions, + ); + } + return getWsFetcher(options, fetcherOpts); +} diff --git a/packages/graphiql-toolkit/src/create-fetcher/types.ts b/packages/graphiql-toolkit/src/create-fetcher/types.ts index 7d3a9dac011..a3378c89f51 100644 --- a/packages/graphiql-toolkit/src/create-fetcher/types.ts +++ b/packages/graphiql-toolkit/src/create-fetcher/types.ts @@ -72,6 +72,24 @@ export type Fetcher = ( opts?: FetcherOpts, ) => FetcherReturnType; +export type SubscriptionSink = { + next: (value: ExecutionResult) => void; + error: (error: any) => void; + complete: () => void; +}; + +export type SubscriptionClient = { + subscribe: (payload: any, sink: SubscriptionSink) => void | (() => void); +}; + +export type GraphQLSSEClient = SubscriptionClient; + +export type GraphQLSSEClientOptions = { + url: string; + headers?: HeadersInit | (() => MaybePromise); + [option: string]: unknown; +}; + /** * Options for creating a simple, spec-compliant GraphiQL fetcher */ @@ -84,6 +102,19 @@ export interface CreateFetcherOptions { * url for websocket subscription requests */ subscriptionUrl?: string; + /** + * url for GraphQL over SSE subscription requests + */ + sseUrl?: string; + /** + * `sseClient` implementation that matches `graphql-sse` signature, + * whether via `createClient()` itself or another client. + */ + sseClient?: GraphQLSSEClient; + /** + * Additional `graphql-sse` client options used when you provide `sseUrl`. + */ + sseClientOptions?: Omit; /** * `wsClient` implementation that matches `ws-graphql` signature, * whether via `createClient()` itself or another client. diff --git a/yarn.lock b/yarn.lock index c11958dddac..20bdf0decff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4785,6 +4785,7 @@ __metadata: dependencies: "@n1ru4l/push-pull-async-iterable-iterator": "npm:^3.1.0" graphql: "npm:^16.9.0" + graphql-sse: "npm:^2.6.0" graphql-ws: "npm:^5.5.5" isomorphic-fetch: "npm:^3.0.0" meros: "npm:^1.1.4" @@ -4792,8 +4793,11 @@ __metadata: tsup: "npm:^8.2.4" peerDependencies: graphql: ^15.5.0 || ^16.0.0 || ^17.0.0 + graphql-sse: ">= 2.0.0" graphql-ws: ">= 4.5.0" peerDependenciesMeta: + graphql-sse: + optional: true graphql-ws: optional: true languageName: unknown @@ -16592,6 +16596,15 @@ __metadata: languageName: unknown linkType: soft +"graphql-sse@npm:^2.6.0": + version: 2.6.0 + resolution: "graphql-sse@npm:2.6.0" + peerDependencies: + graphql: ">=0.11 <=16" + checksum: 10c0/e05f0b5c8539d61e5ce34af8e0bb418c02bf922d6a7f9232a9abd53c77df5654684ca14f674ac771645c8fba9c37ce666c5d06f46eef54ea07e63653468065b0 + languageName: node + linkType: hard + "graphql-tag@npm:2.12.6": version: 2.12.6 resolution: "graphql-tag@npm:2.12.6" From 1bf4314f0b8f93667eae3cc7dac7eb48c68191a7 Mon Sep 17 00:00:00 2001 From: rushrs <17338080+rushrs@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:21:14 +0100 Subject: [PATCH 2/2] Dispose generated SSE clients --- .../src/create-fetcher/__tests__/lib.spec.ts | 66 +++++++++++++++++++ .../src/create-fetcher/lib.ts | 17 ++++- .../src/create-fetcher/types.ts | 4 +- 3 files changed, 83 insertions(+), 4 deletions(-) diff --git a/packages/graphiql-toolkit/src/create-fetcher/__tests__/lib.spec.ts b/packages/graphiql-toolkit/src/create-fetcher/__tests__/lib.spec.ts index 47e69d9b73f..b382e8d83ab 100644 --- a/packages/graphiql-toolkit/src/create-fetcher/__tests__/lib.spec.ts +++ b/packages/graphiql-toolkit/src/create-fetcher/__tests__/lib.spec.ts @@ -3,6 +3,7 @@ import { parse } from 'graphql'; import { isSubscriptionWithName, createSubscriptionFetcherFromClient, + createSseFetcherFromClient, createSseFetcherFromUrl, createWebsocketsFetcherFromUrl, getSubscriptionFetcher, @@ -102,6 +103,29 @@ describe('createSubscriptionFetcherFromClient', () => { }); expect(unsubscribe).toHaveBeenCalled(); }); + + it('runs additional owner cleanup after disposing the subscription', async () => { + const unsubscribe = vi.fn(); + const disposeClient = vi.fn(); + const client = { + subscribe: vi.fn(() => unsubscribe), + }; + const fetcher = createSubscriptionFetcherFromClient( + client, + undefined, + disposeClient, + ); + + const result = fetcher({ + query: 'subscription Example { example }', + operationName: 'Example', + }) as AsyncIterableIterator; + + await result.return?.(); + + expect(unsubscribe).toHaveBeenCalled(); + expect(disposeClient).toHaveBeenCalled(); + }); }); describe('createSseFetcherFromUrl', () => { @@ -123,6 +147,48 @@ describe('createSseFetcherFromUrl', () => { headers: { authorization: 'Bearer token' }, }); }); + + it('disposes internally created SSE clients when the subscription closes', async () => { + const unsubscribe = vi.fn(); + const disposeClient = vi.fn(); + createSseClient.mockReturnValue({ + subscribe: vi.fn(() => unsubscribe), + iterate: vi.fn(), + dispose: disposeClient, + }); + const fetcher = await createSseFetcherFromUrl(sseURL); + + const result = fetcher?.({ + query: 'subscription Example { example }', + operationName: 'Example', + }) as AsyncIterableIterator; + + await result.return?.(); + + expect(unsubscribe).toHaveBeenCalled(); + expect(disposeClient).toHaveBeenCalled(); + }); +}); + +describe('createSseFetcherFromClient', () => { + it('does not dispose caller-owned SSE clients', async () => { + const unsubscribe = vi.fn(); + const disposeClient = vi.fn(); + const fetcher = createSseFetcherFromClient({ + subscribe: vi.fn(() => unsubscribe), + dispose: disposeClient, + }); + + const result = fetcher({ + query: 'subscription Example { example }', + operationName: 'Example', + }) as AsyncIterableIterator; + + await result.return?.(); + + expect(unsubscribe).toHaveBeenCalled(); + expect(disposeClient).not.toHaveBeenCalled(); + }); }); describe('getWsFetcher', () => { diff --git a/packages/graphiql-toolkit/src/create-fetcher/lib.ts b/packages/graphiql-toolkit/src/create-fetcher/lib.ts index 1f4c9d3a817..1c552b62a97 100644 --- a/packages/graphiql-toolkit/src/create-fetcher/lib.ts +++ b/packages/graphiql-toolkit/src/create-fetcher/lib.ts @@ -113,6 +113,7 @@ export const createSubscriptionFetcherFromClient = ( subscriptionClient: SubscriptionClient, normalizeError: (err: any) => any = err => err, + onDispose?: () => void, ): Fetcher => (graphQLParams: FetcherParams) => makeAsyncIterableIteratorFromSink(sink => { @@ -122,7 +123,15 @@ export const createSubscriptionFetcherFromClient = sink.error(normalizeError(err)); }, }); - return typeof dispose === 'function' ? dispose : () => {}; + return () => { + try { + if (typeof dispose === 'function') { + dispose(); + } + } finally { + onDispose?.(); + } + }; }); /** @@ -154,7 +163,7 @@ export async function createSseFetcherFromUrl( url, headers: headers || {}, }); - return createSseFetcherFromClient(sseClient); + return createSseFetcherFromClient(sseClient, () => sseClient.dispose?.()); } catch (err) { if ( errorHasCode(err) && @@ -174,7 +183,9 @@ export async function createSseFetcherFromUrl( */ export const createSseFetcherFromClient = ( sseClient: GraphQLSSEClient, -): Fetcher => createSubscriptionFetcherFromClient(sseClient); + onDispose?: () => void, +): Fetcher => + createSubscriptionFetcherFromClient(sseClient, undefined, onDispose); /** * Allow legacy websockets protocol client, but no definitions for it, diff --git a/packages/graphiql-toolkit/src/create-fetcher/types.ts b/packages/graphiql-toolkit/src/create-fetcher/types.ts index a3378c89f51..d49705aadc5 100644 --- a/packages/graphiql-toolkit/src/create-fetcher/types.ts +++ b/packages/graphiql-toolkit/src/create-fetcher/types.ts @@ -82,7 +82,9 @@ export type SubscriptionClient = { subscribe: (payload: any, sink: SubscriptionSink) => void | (() => void); }; -export type GraphQLSSEClient = SubscriptionClient; +export type GraphQLSSEClient = SubscriptionClient & { + dispose?: () => void; +}; export type GraphQLSSEClientOptions = { url: string;