diff --git a/.changeset/nasty-beers-bow.md b/.changeset/nasty-beers-bow.md new file mode 100644 index 00000000000..2fe9941da42 --- /dev/null +++ b/.changeset/nasty-beers-bow.md @@ -0,0 +1,5 @@ +--- +'@graphiql/toolkit': minor +--- + +support graphql SSE for `options.subscriptionUrl` diff --git a/packages/graphiql-toolkit/package.json b/packages/graphiql-toolkit/package.json index 8e3c8bcfde2..945f16d6b21 100644 --- a/packages/graphiql-toolkit/package.json +++ b/packages/graphiql-toolkit/package.json @@ -32,17 +32,22 @@ "devDependencies": { "graphql": "^17.0.0-alpha.7", "graphql-ws": "^5.5.5", + "graphql-sse": "^2.5.3", "isomorphic-fetch": "^3.0.0", "subscriptions-transport-ws": "0.11.0", "tsup": "^8.2.4" }, "peerDependencies": { "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0-alpha.2", - "graphql-ws": ">= 4.5.0" + "graphql-ws": ">= 4.5.0", + "graphql-sse": "^2" }, "peerDependenciesMeta": { "graphql-ws": { "optional": true + }, + "graphql-sse": { + "optional": true } }, "keywords": [ diff --git a/packages/graphiql-toolkit/src/create-fetcher/create-sse-fetcher.ts b/packages/graphiql-toolkit/src/create-fetcher/create-sse-fetcher.ts new file mode 100644 index 00000000000..e7c5ca1c67f --- /dev/null +++ b/packages/graphiql-toolkit/src/create-fetcher/create-sse-fetcher.ts @@ -0,0 +1,87 @@ +import type { + ClientOptions, + createClient as createClientType, + ExecutionResult, +} from 'graphql-sse'; +import { Fetcher, FetcherParams } from './types'; + +/** + * Based on https://gist.github.com/enisdenjo/d7bc1a013433502349d2763c3d2f2b79 + */ +export async function createSseFetcher(opts: ClientOptions): Promise { + const { createClient } = + process.env.USE_IMPORT === 'false' + ? (require('graphql-sse') as { createClient: typeof createClientType }) + : await import('graphql-sse'); + + const sseClient = createClient({ + retryAttempts: 0, + // @ts-expect-error + singleConnection: true, // or use false if you have an HTTP/2 server + // @ts-expect-error + lazy: false, // connect as soon as the page opens + ...opts, + }); + + function subscribe(payload: FetcherParams) { + let deferred: { + resolve: (arg: boolean) => void; + reject: (arg: unknown) => void; + }; + + const pending: ExecutionResult, unknown>[] = []; + let throwMe: unknown; + let done = false; + + const dispose = sseClient.subscribe( + { + ...payload, + // types are different with FetcherParams + operationName: payload.operationName ?? undefined, + }, + { + next(data) { + pending.push(data); + deferred?.resolve(false); + }, + error(err) { + throwMe = err; + deferred?.reject(throwMe); + }, + complete() { + done = true; + deferred?.resolve(true); + }, + }, + ); + + return { + [Symbol.asyncIterator]() { + return this; + }, + async next() { + if (done) { + return { done: true, value: undefined }; + } + if (throwMe) { + throw throwMe; + } + if (pending.length) { + return { value: pending.shift() }; + } + return (await new Promise((resolve, reject) => { + deferred = { resolve, reject }; + })) + ? { done: true, value: undefined } + : { value: pending.shift() }; + }, + async return() { + dispose(); + return { done: true, value: undefined }; + }, + }; + } + + // @ts-expect-error todo: fix type + return subscribe; +} diff --git a/packages/graphiql-toolkit/src/create-fetcher/createFetcher.ts b/packages/graphiql-toolkit/src/create-fetcher/createFetcher.ts index b90c65ee83e..e242701984c 100644 --- a/packages/graphiql-toolkit/src/create-fetcher/createFetcher.ts +++ b/packages/graphiql-toolkit/src/create-fetcher/createFetcher.ts @@ -6,11 +6,12 @@ import { isSubscriptionWithName, getWsFetcher, } from './lib'; +import { createSseFetcher } from './create-sse-fetcher'; /** - * build a GraphiQL fetcher that is: + * Build a GraphiQL fetcher that is: * - backwards compatible - * - optionally supports graphql-ws or ` + * - optionally supports graphql-ws or graphql-sse */ export function createGraphiQLFetcher(options: CreateFetcherOptions): Fetcher { const httpFetch = @@ -40,7 +41,18 @@ export function createGraphiQLFetcher(options: CreateFetcherOptions): Fetcher { graphQLParams.operationName || undefined, ) : false; + if (isSubscription) { + if ( + options.subscriptionUrl && + !options.subscriptionUrl.startsWith('ws') + ) { + const sseFetcher = await createSseFetcher({ + url: options.subscriptionUrl, + }); + return sseFetcher(graphQLParams); + } + const wsFetcher = await getWsFetcher(options, fetcherOpts); if (!wsFetcher) { diff --git a/packages/graphiql-toolkit/src/create-fetcher/types.ts b/packages/graphiql-toolkit/src/create-fetcher/types.ts index 9ae06a67beb..82ed1d2df31 100644 --- a/packages/graphiql-toolkit/src/create-fetcher/types.ts +++ b/packages/graphiql-toolkit/src/create-fetcher/types.ts @@ -81,7 +81,7 @@ export interface CreateFetcherOptions { */ url: string; /** - * url for websocket subscription requests + * url for websocket subscription requests or SSE */ subscriptionUrl?: string; /** diff --git a/packages/graphiql-toolkit/tsup.config.ts b/packages/graphiql-toolkit/tsup.config.ts index a4d1c15b46f..b9e919faea4 100644 --- a/packages/graphiql-toolkit/tsup.config.ts +++ b/packages/graphiql-toolkit/tsup.config.ts @@ -4,7 +4,6 @@ const opts: Options = { entry: ['src/**/*.ts', '!**/__tests__'], bundle: false, clean: true, - dts: true, minifySyntax: true, }; @@ -17,6 +16,7 @@ export default defineConfig([ env: { USE_IMPORT: 'true', }, + dts: true, }, { ...opts, diff --git a/packages/graphiql/cypress/e2e/graphql-ws.cy.ts b/packages/graphiql/cypress/e2e/graphql-ws.cy.ts deleted file mode 100644 index 89bb792aeb6..00000000000 --- a/packages/graphiql/cypress/e2e/graphql-ws.cy.ts +++ /dev/null @@ -1,20 +0,0 @@ -describe('IncrementalDelivery support via fetcher', () => { - describe('When operation contains @stream', () => { - const testSubscription = /* GraphQL */ ` - subscription TestSubscription($delay: Int) { - message(delay: $delay) - } - `; - const mockSubscriptionSuccess = { - data: { - message: 'Zdravo', - }, - }; - - it('Expects a subscription to resolve', () => { - cy.visitWithOp({ query: testSubscription, variables: { delay: 0 } }); - cy.clickExecuteQuery(); - cy.assertQueryResult(mockSubscriptionSuccess); - }); - }); -}); diff --git a/packages/graphiql/cypress/e2e/incremental-delivery.cy.ts b/packages/graphiql/cypress/e2e/incremental-delivery.cy.ts index 1ea0c0ed822..eb57e3fa755 100644 --- a/packages/graphiql/cypress/e2e/incremental-delivery.cy.ts +++ b/packages/graphiql/cypress/e2e/incremental-delivery.cy.ts @@ -19,36 +19,16 @@ describeOrSkip('IncrementalDelivery support via fetcher', () => { const mockStreamSuccess = { data: { streamable: [ - { - text: 'Hi', - }, - { - text: '你好', - }, - { - text: 'Hola', - }, - { - text: 'أهلاً', - }, - { - text: 'Bonjour', - }, - { - text: 'سلام', - }, - { - text: '안녕', - }, - { - text: 'Ciao', - }, - { - text: 'हेलो', - }, - { - text: 'Здорово', - }, + { text: 'Hi' }, + { text: '你好' }, + { text: 'Hola' }, + { text: 'أهلاً' }, + { text: 'Bonjour' }, + { text: 'سلام' }, + { text: '안녕' }, + { text: 'Ciao' }, + { text: 'हेलो' }, + { text: 'Здорово' }, ], }, }; @@ -141,22 +121,10 @@ describeOrSkip('IncrementalDelivery support via fetcher', () => { person: { name: 'Mark', friends: [ - { - name: 'James', - age: 1000, - }, - { - name: 'Mary', - age: 1000, - }, - { - name: 'John', - age: 1000, - }, - { - name: 'Patrica', - age: 1000, - }, + { name: 'James', age: 1000 }, + { name: 'Mary', age: 1000 }, + { name: 'John', age: 1000 }, + { name: 'Patrica', age: 1000 }, ], age: 1000, }, diff --git a/packages/graphiql/cypress/e2e/ws-sse.cy.ts b/packages/graphiql/cypress/e2e/ws-sse.cy.ts new file mode 100644 index 00000000000..916506d3aca --- /dev/null +++ b/packages/graphiql/cypress/e2e/ws-sse.cy.ts @@ -0,0 +1,27 @@ +describe('IncrementalDelivery support via fetcher', () => { + const testSubscription = /* GraphQL */ ` + subscription Test { + message + } + `; + + function assertResponse() { + for (const message of ['Hi', 'Bonjour', 'Hola', 'Ciao', 'Zdravo']) { + cy.assertQueryResult({ data: { message } }); + } + } + + it('should work with ws', () => { + cy.visit(`/?query=${testSubscription}`); + cy.clickExecuteQuery(); + assertResponse(); + }); + + it('should work with sse', () => { + cy.visit( + `/?subscriptionUrl=http://localhost:8080/graphql/stream&query=${testSubscription}`, + ); + cy.clickExecuteQuery(); + assertResponse(); + }); +}); diff --git a/packages/graphiql/resources/renderExample.js b/packages/graphiql/resources/renderExample.js index 0bbadd29069..7b42b47a1a8 100644 --- a/packages/graphiql/resources/renderExample.js +++ b/packages/graphiql/resources/renderExample.js @@ -77,7 +77,8 @@ root.render( React.createElement(GraphiQL, { fetcher: GraphiQL.createFetcher({ url: getSchemaUrl(), - subscriptionUrl: 'ws://localhost:8081/subscriptions', + subscriptionUrl: + parameters.subscriptionUrl || 'ws://localhost:8081/subscriptions', }), query: parameters.query, variables: parameters.variables, diff --git a/packages/graphiql/test/e2e-server.js b/packages/graphiql/test/e2e-server.js index 970b8144162..7436e063f7d 100644 --- a/packages/graphiql/test/e2e-server.js +++ b/packages/graphiql/test/e2e-server.js @@ -17,6 +17,8 @@ const { const WebSocketsServer = require('./afterDevServer'); const schema = require('./schema'); const { customExecute } = require('./execute'); +// eslint-disable-next-line import-x/no-extraneous-dependencies +const { createHandler } = require('graphql-sse/lib/use/express'); const app = express(); @@ -42,6 +44,33 @@ async function handler(req, res) { sendResult(result, res); } +app.use('/graphql/stream', (req, res, next) => { + // Fixes + // Access to fetch at 'http://localhost:8080/graphql/stream' from origin 'http://localhost:5173' has been blocked by + // CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' + // header is present on the requested resource. If an opaque response serves your needs, set the request's mode to + // 'no-cors' to fetch the resource with CORS disabled. + + // CORS headers + res.header('Access-Control-Allow-Origin', '*'); // restrict it to the required domain + res.header('Access-Control-Allow-Methods', 'GET,PUT,POST'); + // Set custom headers for CORS + res.header( + 'Access-Control-Allow-Headers', + 'content-type,x-graphql-event-stream-token', + ); + + if (req.method === 'OPTIONS') { + return res.status(200).end(); + } + next(); +}); + +// Create the GraphQL over SSE handler +const sseHandler = createHandler({ schema, execute: customExecute }); +// Serve all methods on `/graphql/stream` +app.use('/graphql/stream', sseHandler); + // Server app.use(express.json()); diff --git a/packages/graphiql/test/schema.js b/packages/graphiql/test/schema.js index d6c048e5514..ad65af8e939 100644 --- a/packages/graphiql/test/schema.js +++ b/packages/graphiql/test/schema.js @@ -382,7 +382,7 @@ const TestSubscriptionType = new GraphQLObjectType({ }, async *subscribe(root, args) { for (const hi of ['Hi', 'Bonjour', 'Hola', 'Ciao', 'Zdravo']) { - if (args?.delay) { + if (args.delay) { await sleep(args.delay); } yield { message: hi }; diff --git a/packages/monaco-graphql/test/monaco-editor.test.ts b/packages/monaco-graphql/test/monaco-editor.test.ts index b9fc918b879..52ea84e5a49 100644 --- a/packages/monaco-graphql/test/monaco-editor.test.ts +++ b/packages/monaco-graphql/test/monaco-editor.test.ts @@ -15,7 +15,7 @@ describe('monaco-editor', () => { // expect(lines[1]).toMatch(' building for production...'); // expect(lines[2]).toBe('transforming...'); expect(lines[3]).toMatch( - `✓ ${parseInt(version, 10) > 16 ? 862 : 843} modules transformed.`, + `✓ ${parseInt(version, 10) > 16 ? 869 : 843} modules transformed.`, ); // expect(lines[4]).toBe('rendering chunks...'); // expect(lines[5]).toBe('computing gzip size...'); diff --git a/yarn.lock b/yarn.lock index b47771846d7..1e5659ea156 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10498,6 +10498,11 @@ graphql-http@^1.22.1: resolved "https://registry.yarnpkg.com/graphql-http/-/graphql-http-1.22.1.tgz#3857ac75366e55db189cfe09ade9cc4c4f2cfd09" integrity sha512-4Jor+LRbA7SfSaw7dfDUs2UBzvWg3cKrykfHRgKsOIvQaLuf+QOcG2t3Mx5N9GzSNJcuqMqJWz0ta5+BryEmXg== +graphql-sse@^2.5.3: + version "2.5.3" + resolved "https://registry.yarnpkg.com/graphql-sse/-/graphql-sse-2.5.3.tgz#c3557803f2db306d8ac87fd3bc089b6d4bac8353" + integrity sha512-5IcFW3e7fPOG7oFkK1v3X1wWtCJArQKB/H1UJeNotjy7a/9EYA5K+EiHJF1BiDSVNx7y64bd0FlDETrNBSvIHQ== + graphql-subscriptions@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/graphql-subscriptions/-/graphql-subscriptions-2.0.0.tgz#11ec181d475852d8aec879183e8e1eb94f2eb79a"