From a398c254a8fa7f3e1328c6022e8c60a2719e3df7 Mon Sep 17 00:00:00 2001 From: Alessia Bellisario Date: Tue, 17 Oct 2023 15:00:41 -0400 Subject: [PATCH 1/5] feat: add multipart subscription adapters for Relay and urql --- package-lock.json | 7 ++++ package.json | 1 + src/utilities/subscriptions/relay.ts | 60 +++++++++++++++++++++++++++ src/utilities/subscriptions/shared.ts | 21 ++++++++++ src/utilities/subscriptions/urql.ts | 56 +++++++++++++++++++++++++ 5 files changed, 145 insertions(+) create mode 100644 src/utilities/subscriptions/relay.ts create mode 100644 src/utilities/subscriptions/shared.ts create mode 100644 src/utilities/subscriptions/urql.ts diff --git a/package-lock.json b/package-lock.json index 0d656a76bf1..6273046b136 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "@types/node-fetch": "2.6.5", "@types/react": "18.2.22", "@types/react-dom": "18.2.7", + "@types/relay-runtime": "^14.1.14", "@types/use-sync-external-store": "0.0.4", "@typescript-eslint/eslint-plugin": "6.7.2", "@typescript-eslint/parser": "6.7.2", @@ -2947,6 +2948,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 b8cefcee3b9..2b66f96abb8 100644 --- a/package.json +++ b/package.json @@ -127,6 +127,7 @@ "@types/node-fetch": "2.6.5", "@types/react": "18.2.22", "@types/react-dom": "18.2.7", + "@types/relay-runtime": "^14.1.14", "@types/use-sync-external-store": "0.0.4", "@typescript-eslint/eslint-plugin": "6.7.2", "@typescript-eslint/parser": "6.7.2", diff --git a/src/utilities/subscriptions/relay.ts b/src/utilities/subscriptions/relay.ts new file mode 100644 index 00000000000..bfd3fa5abdd --- /dev/null +++ b/src/utilities/subscriptions/relay.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.ts b/src/utilities/subscriptions/urql.ts new file mode 100644 index 00000000000..a3fe2d80290 --- /dev/null +++ b/src/utilities/subscriptions/urql.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); + }); + }); + }; +} From 163311b6dce76fb39dc4322be2e814f380218f83 Mon Sep 17 00:00:00 2001 From: Alessia Bellisario Date: Tue, 17 Oct 2023 15:25:05 -0400 Subject: [PATCH 2/5] chore: add changeset --- .changeset/strong-terms-perform.md | 46 ++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .changeset/strong-terms-perform.md 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, + }), + ], +}); +``` From 807e2cdc3b88d37aa83f4a563811a95249d45aad Mon Sep 17 00:00:00 2001 From: Alessia Bellisario Date: Wed, 1 Nov 2023 16:30:13 -0400 Subject: [PATCH 3/5] Move entrypoints to individual folders and update config/entryPoints.js --- config/entryPoints.js | 2 ++ .../subscriptions/{relay.ts => relay/index.ts} | 16 ++++++++-------- .../subscriptions/{urql.ts => urql/index.ts} | 14 +++++++------- 3 files changed, 17 insertions(+), 15 deletions(-) rename src/utilities/subscriptions/{relay.ts => relay/index.ts} (77%) rename src/utilities/subscriptions/{urql.ts => urql/index.ts} (75%) 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/src/utilities/subscriptions/relay.ts b/src/utilities/subscriptions/relay/index.ts similarity index 77% rename from src/utilities/subscriptions/relay.ts rename to src/utilities/subscriptions/relay/index.ts index bfd3fa5abdd..94a6c250e19 100644 --- a/src/utilities/subscriptions/relay.ts +++ b/src/utilities/subscriptions/relay/index.ts @@ -3,14 +3,14 @@ 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"; +} 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); diff --git a/src/utilities/subscriptions/urql.ts b/src/utilities/subscriptions/urql/index.ts similarity index 75% rename from src/utilities/subscriptions/urql.ts rename to src/utilities/subscriptions/urql/index.ts index a3fe2d80290..26bf4d4fb57 100644 --- a/src/utilities/subscriptions/urql.ts +++ b/src/utilities/subscriptions/urql/index.ts @@ -1,13 +1,13 @@ -import { Observable } from "../index.js"; +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"; +} 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); From 7ddbc60c6de49f5a18086e62eb1decc966d71923 Mon Sep 17 00:00:00 2001 From: Alessia Bellisario Date: Wed, 1 Nov 2023 16:51:28 -0400 Subject: [PATCH 4/5] chore: commit new .api-reports files --- ...pi-report-utilities_subscriptions_relay.md | 28 +++++++++++++++++++ ...api-report-utilities_subscriptions_urql.md | 25 +++++++++++++++++ config/apiExtractor.ts | 2 +- 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 .api-reports/api-report-utilities_subscriptions_relay.md create mode 100644 .api-reports/api-report-utilities_subscriptions_urql.md 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/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`, }, }, From 9a044537625f2771bc51023ce141d46bf11b3b1c Mon Sep 17 00:00:00 2001 From: Alessia Bellisario Date: Thu, 2 Nov 2023 09:37:46 -0400 Subject: [PATCH 5/5] chore: fix exports test --- src/__tests__/__snapshots__/exports.ts.snap | 6 ++++++ src/__tests__/exports.ts | 7 +++++++ 2 files changed, 13 insertions(+) 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); }); });