diff --git a/guides/javascript_client/graphiql_subscriptions.md b/guides/javascript_client/graphiql_subscriptions.md new file mode 100644 index 0000000000..bfd097628a --- /dev/null +++ b/guides/javascript_client/graphiql_subscriptions.md @@ -0,0 +1,94 @@ +--- +layout: guide +doc_stub: false +search: true +section: JavaScript Client +title: GraphiQL Subscriptions +desc: Testing GraphQL subscriptions in the GraphiQL IDE +index: 5 +--- + +After setting up your server, you can integrate subscriptions into [GraphiQL](https://github.com/graphql/graphiql/tree/main/packages/graphiql#readme), the in-browser GraphQL IDE. + +## Adding GraphiQL to your app + +To get started, make a page for rendering GraphiQL, for example: + +```html + +
+``` + +Then, install GraphiQL (eg, `yarn add graphiql`) and add JavaScript code to import GraphiQL and render it on your page: + +```js +import { GraphiQL } from 'graphiql'; +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import 'graphiql/graphiql.css'; +import { createGraphiQLFetcher } from '@graphiql/toolkit'; + +const fetcher = createGraphiQLFetcher({ url: '/graphql' }); +const root = createRoot(document.getElementById('root')); +root.render(); +``` + +After that, you should be able to load the page in your app and see the GraphiQL editor. + +## Ably + +To integrate {% internal_link "Ably subscriptions", "subscriptions/ably_implementation" %}, use `createAblyFetcher`, for example: + +```js +import Ably from "ably" +import createAblyFetcher from 'graphql-ruby-client/subscriptions/createAblyFetcher'; + +// Initialize a client +// the key must have "subscribe" and "presence" permissions +const ably = new Ably.Realtime({ key: "your.application.key" }) + +// Initialize a new fetcher and pass it to GraphiQL below +var fetcher = createAblyFetcher({ ably: ably, url: "/graphql" }) +const root = createRoot(document.getElementById('root')); +root.render(); +``` + +Under the hood, it will use `window.fetch` to send GraphQL operations to the server, then listen for `X-Subscription-ID` headers in responses. To customize its HTTP requests, you can pass a `fetchOptions:` object or a custom `fetch:` function to `createAblyFetcher({ ... })`. + +## Pusher + +To integrate {% internal_link "Pusher subscriptions", "subscriptions/pusher_implementation" %}, use `createPusherFetcher`, for example: + +```js +import Pusher from "pusher-js" +import createPusherFetcher from 'graphql-ruby-client/subscriptions/createPusherFetcher'; + +// Initialize a client +const pusher = new Pusher("your-app-key", { cluster: "your-cluster" }) + +// Initialize a new fetcher and pass it to GraphiQL below +var fetcher = createPusherFetcher({ pusher: pusher, url: "/graphql" }) +const root = createRoot(document.getElementById('root')); +root.render(); +``` + +Under the hood, it will use `window.fetch` to send GraphQL operations to the server, then listen for `X-Subscription-ID` headers in responses. To customize its HTTP requests, you can pass a `fetchOptions:` object or a custom `fetch:` function to `createPusherFetcher({ ... })`. + +## ActionCable + +To integrate {% internal_link "ActionCable subscriptions", "subscriptions/action_cable_implementation" %}, use `createActionCableFetcher`, for example: + +```js +import { createConsumer } from "@rails/actioncable" +import createActionCableFetcher from 'graphql-ruby-client/subscriptions/createActionCableFetcher'; + +// Initialize a client +const actionCable = createConsumer() + +// Initialize a new fetcher and pass it to GraphiQL below +var fetcher = createActionCableFetcher({ consumer: actionCable, url: "/graphql" }) +const root = createRoot(document.getElementById('root')); +root.render(); +``` + +Under the hood, it will split traffic: it will send `subscription { ... }` operations via ActionCable and send queries and mutations via HTTP `POST` using `window.fetch`. To customize its HTTP requests, you can pass a `fetchOptions:` object or a custom `fetch:` function to `createPusherFetcher({ ... })`. diff --git a/guides/subscriptions/ably_implementation.md b/guides/subscriptions/ably_implementation.md index f4710cf90d..fad7478a31 100644 --- a/guides/subscriptions/ably_implementation.md +++ b/guides/subscriptions/ably_implementation.md @@ -313,4 +313,8 @@ To receive webhooks in development, you can [use ngrok](https://www.ably.io/tuto ## Client configuration -Install the [Ably JS client](https://github.com/ably/ably-js) then see docs for {% internal_link "Apollo Client", "/javascript_client/apollo_subscriptions" %}. +Install the [Ably JS client](https://github.com/ably/ably-js) then see docs for: + +- {% internal_link "Apollo Client", "/javascript_client/apollo_subscriptions" %} +- {% internal_link "Relay Modern", "/javascript_client/relay_subscriptions" %}. +- {% internal_link "GraphiQL", "/javascript_client/graphiql_subscriptions" %} diff --git a/guides/subscriptions/action_cable_implementation.md b/guides/subscriptions/action_cable_implementation.md index 03e663d523..3464159244 100644 --- a/guides/subscriptions/action_cable_implementation.md +++ b/guides/subscriptions/action_cable_implementation.md @@ -12,4 +12,8 @@ index: 4 To get started, see examples in the API docs: {{ "GraphQL::Subscriptions::ActionCableSubscriptions" | api_doc }}. -See client usage for {% internal_link "Apollo Client", "/javascript_client/apollo_subscriptions" %} or {% internal_link "Relay Modern", "/javascript_client/relay_subscriptions" %}. +See client usage for: + +- {% internal_link "Apollo Client", "/javascript_client/apollo_subscriptions" %} +- {% internal_link "Relay Modern", "/javascript_client/relay_subscriptions" %}. +- {% internal_link "GraphiQL", "/javascript_client/graphiql_subscriptions" %} diff --git a/guides/subscriptions/pusher_implementation.md b/guides/subscriptions/pusher_implementation.md index 16bb02cc00..38ae45659a 100644 --- a/guides/subscriptions/pusher_implementation.md +++ b/guides/subscriptions/pusher_implementation.md @@ -296,4 +296,9 @@ To receive Pusher's webhooks in development, Pusher [suggests using ngrok](https ## Client configuration -Install the [Pusher JS client](https://github.com/pusher/pusher-js) then see docs for {% internal_link "Apollo Client", "/javascript_client/apollo_subscriptions" %} or {% internal_link "Relay Modern", "/javascript_client/relay_subscriptions" %}. +Install the [Pusher JS client](https://github.com/pusher/pusher-js) then see docs for: + +- {% internal_link "Apollo Client", "/javascript_client/apollo_subscriptions" %} +- {% internal_link "Relay Modern", "/javascript_client/relay_subscriptions" %} +- {% internal_link "GraphiQL", "/javascript_client/graphiql_subscriptions" %} +- {% internal_link "urql", "/javascript_client/urql_subscriptions" %} diff --git a/javascript_client/package.json b/javascript_client/package.json index 5e99cc0e87..a992e7dac1 100644 --- a/javascript_client/package.json +++ b/javascript_client/package.json @@ -27,6 +27,7 @@ "pako": "^2.0.3", "prettier": "^1.19.1", "pusher-js": "^7.0.3", + "@rails/actioncable": "^7.0.0", "relay-runtime": "11.0.2", "ts-jest": "^29.0.0", "typescript": "5.1.6", diff --git a/javascript_client/src/subscriptions/__tests__/createAblyFetcherTest.ts b/javascript_client/src/subscriptions/__tests__/createAblyFetcherTest.ts new file mode 100644 index 0000000000..f256ec53a9 --- /dev/null +++ b/javascript_client/src/subscriptions/__tests__/createAblyFetcherTest.ts @@ -0,0 +1,105 @@ +import createAblyFetcher from "../createAblyFetcher" +import { Realtime } from "ably" + +function createAbly() { + const _channels: {[key: string]: any } = {} + + const ably = { + _channels: _channels, + channels: { + get(channelName: string) { + return _channels[channelName] ||= { + _listeners: [] as [string, Function][], + name: channelName, + presence: { + enterClient(_clientName: string, _status: string) {}, + leaveClient(_clientName: string) {}, + }, + detach(callback: Function) { + callback() + }, + subscribe(eventName: string, callback: Function) { + this._listeners.push([eventName, callback]) + }, + unsubscribe(){} + } + }, + release(channelName: string) { + delete _channels[channelName] + } + }, + __testTrigger(channelName: string, eventName: string, data: any) { + const channel = this.channels.get(channelName) + const handler = channel._listeners.find((l: any) => l[0] == eventName) + if (handler) { + handler[1](data) + } + } + } + + return ably +} + + +describe("createAblyFetcher", () => { + it("yields updates for subscriptions", () => { + const ably = createAbly() + + const fetchLog: any[] = [] + const dummyFetch = function(url: string, fetchArgs: any) { + fetchLog.push([url, fetchArgs.customOpt]) + const dummyResponse = { + json: () => { + return { + data: { + hi: "First response" + } + } + }, + headers: { + get() { + return fetchArgs.body.includes("subscription") ? "abcd" : null + } + } + } + return Promise.resolve(dummyResponse) + } + + const fetcher = createAblyFetcher({ + ably: (ably as unknown) as Realtime, + url: "/graphql", + fetch: ((dummyFetch as unknown) as typeof fetch), + fetchOptions: {customOpt: true} + }) + + const result = fetcher({ + variables: {}, + operationName: "hello", + body: "subscription hello { hi }" + }, {}) + + return result.next().then((res) => { + expect(res.value.data.hi).toEqual("First response") + expect(fetchLog).toEqual([["/graphql", true]]) + }).then(() => { + const promise = result.next().then((res2) => { + expect(res2).toEqual({ value: { data: { hi: "Bonjour" } }, done: false }) + }) + + ably.__testTrigger("abcd", "update", { data: { result: { data: { hi: "Bonjour" } } } }) + + return promise.then(() => { + // Test non-subscriptions too: + expect(Object.keys(ably._channels)).toEqual(["abcd"]) + const queryResult = fetcher({ variables: {}, operationName: null, body: "{ __typename }"}, {}) + return queryResult.next().then((res) => { + expect(res.value.data).toEqual({ hi: "First response"}) + return queryResult.next().then((res2) => { + expect(res2.done).toEqual(true) + expect(ably._channels).toEqual({}) + }) + }) + }) + }) + }) +}) diff --git a/javascript_client/src/subscriptions/__tests__/createActionCableFetcherTest.ts b/javascript_client/src/subscriptions/__tests__/createActionCableFetcherTest.ts new file mode 100644 index 0000000000..0a21f680da --- /dev/null +++ b/javascript_client/src/subscriptions/__tests__/createActionCableFetcherTest.ts @@ -0,0 +1,66 @@ +import createActionCableFetcher from "../createActionCableFetcher" +import type { Consumer } from "@rails/actioncable" +import { parse } from "graphql" + +describe("createActionCableFetcherTest", () => { + it("yields updates for subscriptions", () => { + var handlers: any + var log: [string, any][]= [] + + var dummyActionCableConsumer = { + subscriptions: { + create: (_conn: any, newHandlers: any) => { + handlers = newHandlers + return { + perform: (evt: string, data: any) => { + log.push([evt, data]) + } + } + } + } + } + + const fetchLog: any[] = [] + const dummyFetch = function(url: string, fetchArgs: any) { + fetchLog.push([url, fetchArgs.custom]) + return Promise.resolve({ json: () => { {} } }) + } + + var options = { + consumer: (dummyActionCableConsumer as unknown) as Consumer, + url: "/graphql", + fetch: dummyFetch as typeof fetch, + fetchOptions: { + custom: true, + } + } + + var fetcher = createActionCableFetcher(options) + + + const queryStr = "subscription listen { update { message } }" + const doc = parse(queryStr) + + const res = fetcher({ operationName: "listen", query: queryStr, variables: {}}, { documentAST: doc }) + const promise = res.next().then((result) => { + + handlers.connected() // trigger the GraphQL send + + expect(result).toEqual({ value: { data: "hello" } , done: false }) + expect(fetchLog).toEqual([]) + expect(log).toEqual([ + ["execute", { operationName: "listen", query: queryStr, variables: {} }], + ]) + }) + + handlers.received({ result: { data: "hello" } }) // simulate an update + + return promise.then(() => { + let res2 = fetcher({ operationName: null, query: "{ __typename } ", variables: {}}, {}) + const promise2 = res2.next().then(() => { + expect(fetchLog).toEqual([["/graphql", true]]) + }) + return promise2 + }) + }) +}) diff --git a/javascript_client/src/subscriptions/__tests__/createPusherFetcherTest.ts b/javascript_client/src/subscriptions/__tests__/createPusherFetcherTest.ts new file mode 100644 index 0000000000..595baac353 --- /dev/null +++ b/javascript_client/src/subscriptions/__tests__/createPusherFetcherTest.ts @@ -0,0 +1,99 @@ +import createPusherFetcher from "../createPusherFetcher" +import type Pusher from "pusher-js" + +type MockChannel = { + bind: (action: string, handler: Function) => void, + unsubscribe: () => void, +} + +describe("createPusherFetcher", () => { + it("yields updates for subscriptions", () => { + const pusher = { + _channels: {} as {[key: string]: [string, Function][]}, + + trigger: function(channel: string, event: string, data: any) { + var handlers = this._channels[channel] + if (handlers) { + handlers.forEach(function(handler: [string, Function]) { + if (handler[0] == event) { + handler[1](data) + } + }) + } + }, + subscribe: function(channel: string): MockChannel { + var handlers = this._channels[channel] + if (!handlers) { + handlers = this._channels[channel] = [] + } + + return { + bind: (action: string, handler: Function): void => { + handlers.push([action, handler]) + }, + unsubscribe: () => { + delete this._channels[channel] + } + } + }, + unsubscribe: (_channel: string): void => { + }, + } + + const fetchLog: any[] = [] + const dummyFetch = function(url: string, fetchArgs: any) { + fetchLog.push([url, fetchArgs.customOpt]) + const dummyResponse = { + json: () => { + return { + data: { + hi: "First response" + } + } + }, + headers: { + get() { + return fetchArgs.body.includes("subscription") ? "abcd" : null + } + } + } + return Promise.resolve(dummyResponse) + } + + const fetcher = createPusherFetcher({ + pusher: (pusher as unknown) as Pusher, + url: "/graphql", + fetch: ((dummyFetch as unknown) as typeof fetch), + fetchOptions: {customOpt: true} + }) + + const result = fetcher({ + variables: {}, + operationName: "hello", + body: "subscription hello { hi }" + }, {}) + + return result.next().then((res) => { + expect(res.value.data.hi).toEqual("First response") + expect(fetchLog).toEqual([["/graphql", true]]) + }).then(() => { + const promise = result.next().then((res2) => { + expect(res2).toEqual({ value: { data: { hi: "Bonjour" } }, done: false }) + }) + pusher.trigger("abcd", "update", { result: { data: { hi: "Bonjour" } } }) + + return promise.then(() => { + // Test non-subscriptions too: + expect(Object.keys(pusher._channels)).toEqual(["abcd"]) + const queryResult = fetcher({ variables: {}, operationName: null, body: "{ __typename }"}, {}) + return queryResult.next().then((res) => { + expect(res.value.data).toEqual({ hi: "First response"}) + return queryResult.next().then((res2) => { + expect(res2.done).toEqual(true) + expect(pusher._channels).toEqual({}) + }) + }) + }) + }) + }) +}) diff --git a/javascript_client/src/subscriptions/createAblyFetcher.ts b/javascript_client/src/subscriptions/createAblyFetcher.ts new file mode 100644 index 0000000000..dc360f4c08 --- /dev/null +++ b/javascript_client/src/subscriptions/createAblyFetcher.ts @@ -0,0 +1,92 @@ +import type Types from "ably" + +type AblyFetcherOptions = { + ably: Types.Realtime, + url: String, + fetch?: typeof fetch, + fetchOptions?: any, +} + +type SubscriptionIteratorPayload = { + value: any, + done: Boolean +} + +const clientName = "graphiql-subscriber" + +export default function createAblyFetcher(options: AblyFetcherOptions) { + var currentChannel: Types.Types.RealtimeChannelCallbacks | null = null + + return async function*(graphqlParams: any, _fetcherParams: any) { + var nextPromiseResolve: Function | null = null + var shouldBreak = false + + var iterator = { + [Symbol.asyncIterator]() { + return { + next(): Promise { + return new Promise((resolve, _reject) => { + nextPromiseResolve = resolve + }) + }, + return(): Promise { + if (currentChannel) { + currentChannel.presence.leaveClient(clientName) + currentChannel.unsubscribe() + const channelName = currentChannel.name + currentChannel.detach(() => { + options.ably.channels.release(channelName) + }) + currentChannel = null + nextPromiseResolve = null + } + return Promise.resolve({ value: null, done: true }) + } + } + } + } + + const fetchFn = options.fetch || window.fetch + fetchFn("/graphql", { + method: "POST", + body: JSON.stringify(graphqlParams), + headers: { + 'content-type': 'application/json', + }, + ... options.fetchOptions + }).then((r) => { + const subId = r.headers.get("X-Subscription-ID") + if (subId) { + currentChannel && currentChannel.unsubscribe() + currentChannel = options.ably.channels.get(subId, { modes: ["SUBSCRIBE", "PRESENCE"] }) + currentChannel.presence.enterClient(clientName, "subscribed", (err) => { + if (err) { + console.error(err) + } + }) + currentChannel.subscribe("update", (message: Types.Types.Message) => { + console.log("update", message) + if (nextPromiseResolve) { + nextPromiseResolve({ value: message.data.result, done: false }) + } + }) + + if (nextPromiseResolve) { + nextPromiseResolve({ value: r.json(), done: false }) + } + } else { + shouldBreak = true + if (nextPromiseResolve) { + nextPromiseResolve({ value: r.json(), done: false}) + } + } + }) + + for await (const payload of iterator) { + yield payload + if (shouldBreak) { + break + } + } + } +} diff --git a/javascript_client/src/subscriptions/createActionCableFetcher.ts b/javascript_client/src/subscriptions/createActionCableFetcher.ts new file mode 100644 index 0000000000..22b5692120 --- /dev/null +++ b/javascript_client/src/subscriptions/createActionCableFetcher.ts @@ -0,0 +1,95 @@ + +import { visit } from "graphql"; +import type { Consumer, Subscription } from "@rails/actioncable" + +type ActionCableFetcherOptions = { + consumer: Consumer, + url: String, + fetch?: typeof fetch, + fetchOptions?: any, +} + +type SubscriptionIteratorPayload = { + value: any, + done: Boolean +} + +export default function createActionCableFetcher(options: ActionCableFetcherOptions) { + let currentChannel: Subscription | null = null + const consumer = options.consumer + + const subscriptionFetcher = async function*(graphqlParams: any, fetcherOpts: any) { + let isSubscription = false; + let nextPromiseResolve: Function | null = null; + + fetcherOpts.documentAST && visit(fetcherOpts.documentAST, { + OperationDefinition(node) { + if (graphqlParams.operationName === node.name?.value && node.operation === 'subscription') { + isSubscription = true; + } + }, + }); + + if (isSubscription) { + currentChannel?.unsubscribe() + currentChannel = consumer.subscriptions.create("GraphqlChannel", + { + connected: function() { + currentChannel?.perform("execute", { + query: graphqlParams.query, + operationName: graphqlParams.operationName, + variables: graphqlParams.variables, + }) + }, + + received: function(data: any) { + if (nextPromiseResolve) { + nextPromiseResolve({ value: data.result, done: false }) + } + } + } as any + ) + + var iterator = { + [Symbol.asyncIterator]() { + return { + next(): Promise { + return new Promise((resolve, _reject) => { + nextPromiseResolve = resolve + }) + }, + return(): Promise { + if (currentChannel) { + currentChannel.unsubscribe() + currentChannel = null + } + return Promise.resolve({ value: null, done: true }) + } + } + } + } + + for await (const payload of iterator) { + yield payload + } + } else { + const fetchFn = options.fetch || window.fetch + // Not a subscription fetcher, post to the given URL + yield fetchFn("/graphql", { + method: "POST", + body: JSON.stringify({ + query: graphqlParams.query, + operationName: graphqlParams.operationName, + variables: graphqlParams.variables, + }), + headers: { + 'content-type': 'application/json', + }, + ... options.fetchOptions + }).then((r) => r.json()) + return + } + } + + return subscriptionFetcher +} diff --git a/javascript_client/src/subscriptions/createPusherFetcher.ts b/javascript_client/src/subscriptions/createPusherFetcher.ts new file mode 100644 index 0000000000..f400ba6ab6 --- /dev/null +++ b/javascript_client/src/subscriptions/createPusherFetcher.ts @@ -0,0 +1,79 @@ +import type Pusher from "pusher-js" +import type { Channel } from "pusher-js" + +type PusherFetcherOptions = { + pusher: Pusher, + url: String, + fetch?: typeof fetch, + fetchOptions: any, +} + +type SubscriptionIteratorPayload = { + value: any, + done: Boolean +} + +export default function createPusherFetcher(options: PusherFetcherOptions) { + var currentChannel: Channel | null = null + + return async function*(graphqlParams: any, _fetcherParams: any) { + var nextPromiseResolve: Function | null = null + var shouldBreak = false + + var iterator = { + [Symbol.asyncIterator]() { + return { + next(): Promise { + return new Promise((resolve, _reject) => { + nextPromiseResolve = resolve + }) + }, + return(): Promise { + if (currentChannel) { + currentChannel.unsubscribe() + currentChannel = null + } + return Promise.resolve({ value: null, done: true }) + } + } + } + } + + const fetchFn = options.fetch || window.fetch + fetchFn("/graphql", { + method: "POST", + body: JSON.stringify(graphqlParams), + headers: { + 'content-type': 'application/json', + }, + ...options.fetchOptions + }).then((r) => { + const subId = r.headers.get("X-Subscription-ID") + if (subId) { + currentChannel && currentChannel.unsubscribe() + currentChannel = options.pusher.subscribe(subId) + currentChannel.bind("update", (payload: any) => { + if (nextPromiseResolve) { + nextPromiseResolve({ value: payload.result, done: false }) + } + }) + + if (nextPromiseResolve) { + nextPromiseResolve({ value: r.json(), done: false }) + } + } else { + shouldBreak = true + if (nextPromiseResolve) { + nextPromiseResolve({ value: r.json(), done: false}) + } + } + }) + + for await (const payload of iterator) { + yield payload + if (shouldBreak) { + break + } + } + } +}