diff --git a/.api-reports/api-report-utilities_subscriptions_relay.md b/.api-reports/api-report-utilities_subscriptions_relay.md new file mode 100644 index 00000000000..4e77625a6e1 --- /dev/null +++ b/.api-reports/api-report-utilities_subscriptions_relay.md @@ -0,0 +1,28 @@ +## API Report File for "@apollo/client" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { GraphQLResponse } from 'relay-runtime'; +import { Observable } from 'relay-runtime'; +import type { RequestParameters } from 'relay-runtime'; + +// Warning: (ae-forgotten-export) The symbol "CreateMultipartSubscriptionOptions" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "OperationVariables" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export function createFetchMultipartSubscription(uri: string, { fetch: preferredFetch, headers }?: CreateMultipartSubscriptionOptions): (operation: RequestParameters, variables: OperationVariables) => Observable; + +// @public (undocumented) +type CreateMultipartSubscriptionOptions = { + fetch?: WindowOrWorkerGlobalScope["fetch"]; + headers?: Record; +}; + +// @public (undocumented) +type OperationVariables = Record; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/.api-reports/api-report-utilities_subscriptions_urql.md b/.api-reports/api-report-utilities_subscriptions_urql.md new file mode 100644 index 00000000000..833fe4492b5 --- /dev/null +++ b/.api-reports/api-report-utilities_subscriptions_urql.md @@ -0,0 +1,25 @@ +## API Report File for "@apollo/client" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Observable } from 'zen-observable-ts'; + +// Warning: (ae-forgotten-export) The symbol "CreateMultipartSubscriptionOptions" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export function createFetchMultipartSubscription(uri: string, { fetch: preferredFetch, headers }?: CreateMultipartSubscriptionOptions): ({ query, variables, }: { + query?: string | undefined; + variables: undefined | Record; +}) => Observable; + +// @public (undocumented) +type CreateMultipartSubscriptionOptions = { + fetch?: WindowOrWorkerGlobalScope["fetch"]; + headers?: Record; +}; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/.changeset/strong-terms-perform.md b/.changeset/strong-terms-perform.md new file mode 100644 index 00000000000..6974100076e --- /dev/null +++ b/.changeset/strong-terms-perform.md @@ -0,0 +1,46 @@ +--- +"@apollo/client": minor +--- + +Add multipart subscription network adapters for Relay and urql + +### Relay + +```tsx +import { createFetchMultipartSubscription } from "@apollo/client/utilities/subscriptions/relay"; +import { Environment, Network, RecordSource, Store } from "relay-runtime"; + +const fetchMultipartSubs = createFetchMultipartSubscription( + "http://localhost:4000" +); + +const network = Network.create(fetchQuery, fetchMultipartSubs); + +export const RelayEnvironment = new Environment({ + network, + store: new Store(new RecordSource()), +}); +``` + +### Urql + +```tsx +import { createFetchMultipartSubscription } from "@apollo/client/utilities/subscriptions/urql"; +import { Client, fetchExchange, subscriptionExchange } from "@urql/core"; + +const url = "http://localhost:4000"; + +const multipartSubscriptionForwarder = createFetchMultipartSubscription( + url +); + +const client = new Client({ + url, + exchanges: [ + fetchExchange, + subscriptionExchange({ + forwardSubscription: multipartSubscriptionForwarder, + }), + ], +}); +``` diff --git a/config/apiExtractor.ts b/config/apiExtractor.ts index c8d6dd86ec0..9ccb35cbf6b 100644 --- a/config/apiExtractor.ts +++ b/config/apiExtractor.ts @@ -30,7 +30,7 @@ map((entryPoint: { dirs: string[] }) => { enabled: true, ...baseConfig.apiReport, reportFileName: `api-report${ - path ? "-" + path.replace("/", "_") : "" + path ? "-" + path.replace(/\//g, "_") : "" }.md`, }, }, diff --git a/config/entryPoints.js b/config/entryPoints.js index dbd41ad4d64..3cd167c045e 100644 --- a/config/entryPoints.js +++ b/config/entryPoints.js @@ -27,6 +27,8 @@ const entryPoints = [ { dirs: ["testing"], extensions: [".js", ".jsx"] }, { dirs: ["testing", "core"] }, { dirs: ["utilities"] }, + { dirs: ["utilities", "subscriptions", "relay"] }, + { dirs: ["utilities", "subscriptions", "urql"] }, { dirs: ["utilities", "globals"], sideEffects: true }, ]; diff --git a/package-lock.json b/package-lock.json index b9c037271f0..c03bfde63f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "@types/node-fetch": "2.6.7", "@types/react": "18.2.33", "@types/react-dom": "18.2.14", + "@types/relay-runtime": "14.1.14", "@types/use-sync-external-store": "0.0.5", "@typescript-eslint/eslint-plugin": "6.7.5", "@typescript-eslint/parser": "6.7.5", @@ -100,7 +101,7 @@ "whatwg-fetch": "3.6.19" }, "engines": { - "npm": "^7.20.3 || ^8.0.0 || ^9.0.0" + "npm": "^7.20.3 || ^8.0.0 || ^9.0.0 || ^10.0.0" }, "peerDependencies": { "graphql": "^15.0.0 || ^16.0.0", @@ -2950,6 +2951,12 @@ "@types/react": "*" } }, + "node_modules/@types/relay-runtime": { + "version": "14.1.14", + "resolved": "https://registry.npmjs.org/@types/relay-runtime/-/relay-runtime-14.1.14.tgz", + "integrity": "sha512-uG5GJhlyhqBp4j5b4xeH9LFMAr+xAFbWf1Q4ZLa0aLFJJNbjDVmHbzqzuXb+WqNpM3V7LaKwPB1m7w3NYSlCMg==", + "dev": true + }, "node_modules/@types/resolve": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", diff --git a/package.json b/package.json index c2b886572a5..ed22fbaab9d 100644 --- a/package.json +++ b/package.json @@ -127,6 +127,7 @@ "@types/node-fetch": "2.6.7", "@types/react": "18.2.33", "@types/react-dom": "18.2.14", + "@types/relay-runtime": "14.1.14", "@types/use-sync-external-store": "0.0.5", "@typescript-eslint/eslint-plugin": "6.7.5", "@typescript-eslint/parser": "6.7.5", diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index 1c48ea242e3..1d9a73e0eb7 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -478,3 +478,9 @@ Array [ "newInvariantError", ] `; + +exports[`exports of public entry points @apollo/client/utilities/subscriptions/urql 1`] = ` +Array [ + "createFetchMultipartSubscription", +] +`; diff --git a/src/__tests__/exports.ts b/src/__tests__/exports.ts index 819ec6d2c81..dc46f2498ad 100644 --- a/src/__tests__/exports.ts +++ b/src/__tests__/exports.ts @@ -32,6 +32,7 @@ import * as testing from "../testing"; import * as testingCore from "../testing/core"; import * as utilities from "../utilities"; import * as utilitiesGlobals from "../utilities/globals"; +import * as urqlUtilities from "../utilities/subscriptions/urql"; const entryPoints = require("../../config/entryPoints.js"); @@ -76,11 +77,17 @@ describe("exports of public entry points", () => { check("@apollo/client/testing/core", testingCore); check("@apollo/client/utilities", utilities); check("@apollo/client/utilities/globals", utilitiesGlobals); + check("@apollo/client/utilities/subscriptions/urql", urqlUtilities); it("completeness", () => { const { join } = require("path").posix; entryPoints.forEach((info: Record) => { const id = join("@apollo/client", ...info.dirs); + // We don't want to add a devDependency for relay-runtime, + // and our API extractor job is already validating its public exports, + // so we'll skip the utilities/subscriptions/relay entrypoing here + // since it errors on the `relay-runtime` import. + if (id === "@apollo/client/utilities/subscriptions/relay") return; expect(testedIds).toContain(id); }); }); diff --git a/src/utilities/subscriptions/relay/index.ts b/src/utilities/subscriptions/relay/index.ts new file mode 100644 index 00000000000..94a6c250e19 --- /dev/null +++ b/src/utilities/subscriptions/relay/index.ts @@ -0,0 +1,60 @@ +import { Observable } from "relay-runtime"; +import type { RequestParameters, GraphQLResponse } from "relay-runtime"; +import { + handleError, + readMultipartBody, +} from "../../../link/http/parseAndCheckHttpResponse.js"; +import { maybe } from "../../index.js"; +import { serializeFetchParameter } from "../../../core/index.js"; + +import type { OperationVariables } from "../../../core/index.js"; +import type { Body } from "../../../link/http/selectHttpOptionsAndBody.js"; +import { generateOptionsForMultipartSubscription } from "../shared.js"; +import type { CreateMultipartSubscriptionOptions } from "../shared.js"; + +const backupFetch = maybe(() => fetch); + +export function createFetchMultipartSubscription( + uri: string, + { fetch: preferredFetch, headers }: CreateMultipartSubscriptionOptions = {} +) { + return function fetchMultipartSubscription( + operation: RequestParameters, + variables: OperationVariables + ): Observable { + const body: Body = { + operationName: operation.name, + variables, + query: operation.text || "", + }; + const options = generateOptionsForMultipartSubscription(headers || {}); + + return Observable.create((sink) => { + try { + options.body = serializeFetchParameter(body, "Payload"); + } catch (parseError) { + sink.error(parseError); + } + + const currentFetch = preferredFetch || maybe(() => fetch) || backupFetch; + const observerNext = sink.next.bind(sink); + + currentFetch!(uri, options) + .then((response) => { + const ctype = response.headers?.get("content-type"); + + if (ctype !== null && /^multipart\/mixed/i.test(ctype)) { + return readMultipartBody(response, observerNext); + } + + sink.error(new Error("Expected multipart response")); + }) + .then(() => { + sink.complete(); + }) + .catch((err: any) => { + handleError(err, sink); + }); + }); + }; +} diff --git a/src/utilities/subscriptions/shared.ts b/src/utilities/subscriptions/shared.ts new file mode 100644 index 00000000000..f3706dab11e --- /dev/null +++ b/src/utilities/subscriptions/shared.ts @@ -0,0 +1,21 @@ +import { fallbackHttpConfig } from "../../link/http/selectHttpOptionsAndBody.js"; + +export type CreateMultipartSubscriptionOptions = { + fetch?: WindowOrWorkerGlobalScope["fetch"]; + headers?: Record; +}; + +export function generateOptionsForMultipartSubscription( + headers: Record +) { + const options: { headers: Record; body?: string } = { + ...fallbackHttpConfig.options, + headers: { + ...(headers || {}), + ...fallbackHttpConfig.headers, + accept: + "multipart/mixed;boundary=graphql;subscriptionSpec=1.0,application/json", + }, + }; + return options; +} diff --git a/src/utilities/subscriptions/urql/index.ts b/src/utilities/subscriptions/urql/index.ts new file mode 100644 index 00000000000..26bf4d4fb57 --- /dev/null +++ b/src/utilities/subscriptions/urql/index.ts @@ -0,0 +1,56 @@ +import { Observable } from "../../index.js"; +import { + handleError, + readMultipartBody, +} from "../../../link/http/parseAndCheckHttpResponse.js"; +import { maybe } from "../../index.js"; +import { serializeFetchParameter } from "../../../core/index.js"; +import type { Body } from "../../../link/http/selectHttpOptionsAndBody.js"; +import { generateOptionsForMultipartSubscription } from "../shared.js"; +import type { CreateMultipartSubscriptionOptions } from "../shared.js"; + +const backupFetch = maybe(() => fetch); + +export function createFetchMultipartSubscription( + uri: string, + { fetch: preferredFetch, headers }: CreateMultipartSubscriptionOptions = {} +) { + return function multipartSubscriptionForwarder({ + query, + variables, + }: { + query?: string; + variables: undefined | Record; + }) { + const body: Body = { variables, query }; + const options = generateOptionsForMultipartSubscription(headers || {}); + + return new Observable((observer) => { + try { + options.body = serializeFetchParameter(body, "Payload"); + } catch (parseError) { + observer.error(parseError); + } + + const currentFetch = preferredFetch || maybe(() => fetch) || backupFetch; + const observerNext = observer.next.bind(observer); + + currentFetch!(uri, options) + .then((response) => { + const ctype = response.headers?.get("content-type"); + + if (ctype !== null && /^multipart\/mixed/i.test(ctype)) { + return readMultipartBody(response, observerNext); + } + + observer.error(new Error("Expected multipart response")); + }) + .then(() => { + observer.complete(); + }) + .catch((err: any) => { + handleError(err, observer); + }); + }); + }; +}