diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d3327d..bc13428 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,7 @@ on: permissions: contents: read + pull-requests: write jobs: build: @@ -31,8 +32,20 @@ jobs: - name: Lint run: npm run lint - - name: Test - run: npm run test -- --run + - name: Test with coverage + run: npm run coverage - name: Build run: npm run build + + - name: Report coverage + if: always() + uses: davelosert/vitest-coverage-report-action@v2 + + - name: Upload coverage artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage + path: coverage/ + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 692d4de..7042d5b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ lerna-debug.log* node_modules dist dist-ssr +coverage *.local # Editor directories and files diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..50ac2c4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,94 @@ +# Changelog + +## 2.0.0 + +A major release covering dependency upgrades, a reworked configuration surface, +and behavioural fixes. **All items below are breaking** — read the migration +notes before upgrading. + +### Migration notes (1.x → 2.0.0) + +#### 1. Peer dependencies & Node + +- `ky` peer bumped from `>=1.0.0` to **`>=2.0.0`**. +- `relay-runtime` peer bumped from `>=15.0.0` to **`>=17.0.0`**. +- ky 2 requires **Node.js >= 22**. + +Update your app's dependencies accordingly: + +```bash +npm add ky@^2 relay-runtime@^17 # or newer +``` + +#### 2. Queries are now sent as `GET` + +Previously every operation was sent as a `POST`. Queries are now issued as +`GET`, with the operation encoded in the query string (`query`, `operationName`, +`variables`). Mutations remain `POST`, and any operation carrying file variables +is still a `multipart/form-data` `POST`. + +**Action required:** ensure your GraphQL server accepts `GET` for queries (per +the GraphQL-over-HTTP spec). Very large queries may approach URL length limits; +switch such operations to persisted queries or mutations if needed. + +As a side effect, the default `retry` policy (`methods: ["get"]`) now actually +applies to queries — retries genuinely happen on `503`, which was silently a +no-op in 1.x (everything was `POST`, which was never in the retry method list). + +#### 3. Hooks are consolidated into a single `hooks` option + +The three separate hook arrays were replaced by one `hooks` object matching +ky's shape. + +```diff + createFetchQuery({ + url, +- beforeRequest: [myBeforeRequest], +- afterResponse: [myAfterResponse], +- beforeError: [myBeforeError], ++ hooks: { ++ beforeRequest: [myBeforeRequest], ++ afterResponse: [myAfterResponse], ++ beforeError: [myBeforeError], ++ }, + }); +``` + +Note that ky 2 also changed hook signatures: hooks now receive a single state +object (e.g. `({request, options}) => …`, `({error}) => …`) instead of +positional arguments. Update any hook implementations accordingly. The built-in +logout `beforeError` hook is still merged ahead of your `hooks.beforeError`. + +#### 4. `deleteDataIfError` is now a real config option + +In 1.x this was declared on the config but never read — the behaviour was +hardcoded on via an unreachable positional argument. It is now honoured. + +- Default is `true` (unchanged behaviour: when a payload has both `data` and + `errors`, `data` is dropped so Relay routes it through `onError`). +- Set `deleteDataIfError: false` to emit payloads verbatim. + +If you were passing this flag before, it now takes effect — verify that `true` +is what you want. + +#### 5. Static `headers` objects are now actually sent + +A precedence bug meant that passing a plain `headers` object silently discarded +it (replacing it with only the multipart preflight header). The object form now +works, and the `graphql-preflight: 1` header is merged in for multipart uploads +rather than replacing your headers. The function form +(`headers: async (request) => …`) is unchanged. + +#### 6. Network-error message changed + +Fetch-level network failures now surface as ky's `NetworkError` with the message +`Request failed due to a network error: ` instead of the raw +`Failed to fetch` / `fetch failed`. Update any code that matches on the old +message text. + +### Other changes + +- `relay-runtime` bumped to 21; `@types/relay-runtime` dropped (relay-runtime + now ships its own types). +- Test suite expanded (5 → 31 tests) and code-coverage reporting added to the + GitHub Actions CI workflow. diff --git a/README.md b/README.md index 564b273..c99b828 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,7 @@ const network = Network.create( | `handleLogout` | `() => Promise \| void` | Called when `logoutCheck` returns true. | | `deleteDataIfError` | `boolean` | When a payload has both `data` and `errors`, drop `data` so Relay routes it to `onError`. Useful for servers that return `{}` on failed mutations. | | `allowApplicationJsonContentType` | `boolean` | Accept `application/json` responses in addition to `application/graphql-response+json`. | -| `beforeRequest` | `ky` `BeforeRequestHook[]` | Passed through to `ky`. | -| `afterResponse` | `ky` `AfterResponseHook[]` | Passed through to `ky`. | -| `beforeError` | `ky` `BeforeErrorHook[]` | Passed through to `ky`. | +| `hooks` | `ky` `Hooks` | ky lifecycle hooks (`beforeRequest`, `afterResponse`, `beforeError`, ...). Merged ahead of the built-in logout `beforeError` hook. | ## Incremental delivery (`@defer` / `@stream`) diff --git a/package-lock.json b/package-lock.json index 40dd221..402226a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,35 +1,37 @@ { "name": "@stackworx.io/relay-network", - "version": "1.0.1", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@stackworx.io/relay-network", - "version": "1.0.1", - "license": "UNLICENCSED", + "version": "2.0.0", + "license": "MIT", "dependencies": { "extract-files": "^13.0.0" }, "devDependencies": { "@eslint/js": "10.0.1", "@types/node": "25.3.0", - "@types/relay-runtime": "20.1.1", "@typescript-eslint/eslint-plugin": "8.56.0", "@typescript-eslint/parser": "8.56.0", + "@vitest/coverage-v8": "4.0.18", "dprint": "0.51.1", "eslint": "10.0.0", - "ky": "1.14.3", + "ky": "2.0.2", "msw": "2.12.10", - "relay-runtime": "20.1.1", + "relay-runtime": "21.0.1", "tsdown": "^0.20.3", "typescript": "5.9.3", - "vite": "7.3.1", "vitest": "4.0.18" }, + "engines": { + "node": ">=22" + }, "peerDependencies": { - "ky": ">=1.0.0", - "relay-runtime": ">=15.0.0" + "ky": ">=2.0.0", + "relay-runtime": ">=17.0.0" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -119,6 +121,16 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@dprint/darwin-arm64": { "version": "0.51.1", "resolved": "https://registry.npmjs.org/@dprint/darwin-arm64/-/darwin-arm64-0.51.1.tgz", @@ -1790,13 +1802,6 @@ "undici-types": "~7.18.0" } }, - "node_modules/@types/relay-runtime": { - "version": "20.1.1", - "resolved": "https://registry.npmjs.org/@types/relay-runtime/-/relay-runtime-20.1.1.tgz", - "integrity": "sha512-loM2iJteknnJcsxrynmBVb7pIDkSkJUuCMnAa/oDFcrvaxkDvq8iy0J2UshMdDy14iJJocDblXeAu7c6Q1+Ucw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/statuses": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", @@ -2067,6 +2072,37 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", + "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.18", + "ast-v8-to-istanbul": "^0.3.10", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.18", + "vitest": "4.0.18" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", @@ -2286,6 +2322,25 @@ "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", @@ -2968,6 +3023,16 @@ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/headers-polyfill": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", @@ -2981,6 +3046,13 @@ "dev": true, "license": "MIT" }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3077,6 +3149,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3125,13 +3236,13 @@ } }, "node_modules/ky": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/ky/-/ky-1.14.3.tgz", - "integrity": "sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ky/-/ky-2.0.2.tgz", + "integrity": "sha512-/GmXpo9F9W+f8n4Ivr2iH+7h7wL7jLbLKWkMlpflcCRb6kGjBfTlASEXaZ9qUgNTn4VgS0P2pwxxzQ4EM6Ulgg==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=22" }, "funding": { "url": "https://github.com/sindresorhus/ky?sponsor=1" @@ -3187,6 +3298,84 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/magicast/node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/magicast/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/magicast/node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/magicast/node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", @@ -3504,9 +3693,9 @@ "license": "MIT" }, "node_modules/relay-runtime": { - "version": "20.1.1", - "resolved": "https://registry.npmjs.org/relay-runtime/-/relay-runtime-20.1.1.tgz", - "integrity": "sha512-N98ZkkyuIHdXmHaPuljihM1QbFYXATF0gxA0CESFphszsQzXs9A/zZloVfzdOI/xg3B3CfM/5nozvIoeTDIcfw==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/relay-runtime/-/relay-runtime-21.0.1.tgz", + "integrity": "sha512-OS+We56sp6gkGVEcce9VCNOeg/sMbxT9unEoerQPgNAICHowSWrXNc6LJxF2OqjypxYF7kaLDSPPiaPii2Pw1g==", "dev": true, "license": "MIT", "dependencies": { @@ -3790,6 +3979,19 @@ "node": ">=8" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", diff --git a/package.json b/package.json index 4ea2596..f5384f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@stackworx.io/relay-network", - "version": "1.0.1", + "version": "2.0.0", "description": "A Relay Network layer built on ky, with support for file uploads (GraphQL multipart request) and incremental delivery (@defer/@stream).", "license": "MIT", "author": "Stackworx (https://stackworx.io)", @@ -36,7 +36,7 @@ }, "sideEffects": false, "engines": { - "node": ">=20" + "node": ">=22" }, "publishConfig": { "access": "public" @@ -48,27 +48,27 @@ "coverage": "vitest run --coverage", "fmt": "dprint fmt", "fmt:check": "dprint check", - "lint": "eslint . --ext .ts", + "lint": "eslint .", "prepublishOnly": "npm run build" }, "devDependencies": { "@eslint/js": "10.0.1", "@types/node": "25.3.0", - "@types/relay-runtime": "20.1.1", "@typescript-eslint/eslint-plugin": "8.56.0", "@typescript-eslint/parser": "8.56.0", + "@vitest/coverage-v8": "4.0.18", "dprint": "0.51.1", "eslint": "10.0.0", - "ky": "1.14.3", + "ky": "2.0.2", "msw": "2.12.10", - "relay-runtime": "20.1.1", + "relay-runtime": "21.0.1", "tsdown": "^0.20.3", "typescript": "5.9.3", "vitest": "4.0.18" }, "peerDependencies": { - "ky": ">=1.0.0", - "relay-runtime": ">=15.0.0" + "ky": ">=2.0.0", + "relay-runtime": ">=17.0.0" }, "dependencies": { "extract-files": "^13.0.0" diff --git a/src/__tests__/defer.spec.ts b/src/__tests__/defer.spec.ts new file mode 100644 index 0000000..3dca29b --- /dev/null +++ b/src/__tests__/defer.spec.ts @@ -0,0 +1,168 @@ +import {describe, expect, test} from "vitest"; + +import {emitRelayCompatiblePayload, extractBoundary, MultipartMixedError, streamMultipartMixed} from "../defer"; + +describe("extractBoundary", () => { + test("parses a quoted boundary", () => { + expect(extractBoundary(`multipart/mixed; boundary="gc0p4Jq0M2Yt08j"`)).toBe( + "gc0p4Jq0M2Yt08j", + ); + }); + + test("parses an unquoted boundary", () => { + expect(extractBoundary("multipart/mixed; boundary=-")).toBe("-"); + }); + + test("parses a boundary regardless of parameter order", () => { + expect( + extractBoundary(`multipart/mixed; deferSpec=20220824; boundary="abc"`), + ).toBe("abc"); + }); + + test("returns null when no boundary is present", () => { + expect(extractBoundary("multipart/mixed")).toBeNull(); + }); +}); + +describe("emitRelayCompatiblePayload", () => { + test("passes through a payload without an incremental array", () => { + const payloads: unknown[] = []; + const payload = {data: {name: "Name"}, hasNext: false}; + + emitRelayCompatiblePayload(payload as any, (p) => payloads.push(p)); + + expect(payloads).toEqual([payload]); + }); + + test("splits a combined incremental payload into base + patches", () => { + const payloads: any[] = []; + + emitRelayCompatiblePayload( + { + data: {products: [{id: "1"}]}, + hasNext: true, + incremental: [ + {data: {name: "Deferred"}, label: "test", path: ["products", 0]}, + ], + } as any, + (p) => payloads.push(p), + ); + + expect(payloads).toHaveLength(2); + // Base payload is emitted without the incremental array. + expect(payloads[0]).toEqual({data: {products: [{id: "1"}]}, hasNext: true}); + expect(payloads[0]).not.toHaveProperty("incremental"); + // Each incremental item becomes its own Relay-shaped patch. + expect(payloads[1]).toMatchObject({ + data: {name: "Deferred"}, + label: "test", + path: ["products", 0], + }); + }); + + test("emits incremental items carrying only errors", () => { + const payloads: any[] = []; + + emitRelayCompatiblePayload( + { + incremental: [{errors: [{message: "boom"}], label: "e", path: ["x"]}], + } as any, + (p) => payloads.push(p), + ); + + expect(payloads).toHaveLength(1); + expect(payloads[0]).toMatchObject({errors: [{message: "boom"}], path: ["x"]}); + }); + + test("skips null items and items without data or errors", () => { + const payloads: any[] = []; + + emitRelayCompatiblePayload( + { + incremental: [null, {label: "empty", path: ["y"]}], + } as any, + (p) => payloads.push(p), + ); + + expect(payloads).toHaveLength(0); + }); +}); + +describe("streamMultipartMixed", () => { + function multipartResponse(parts: object[], boundary: string, chunkSize = 32) { + const body = parts + .map( + (part) => + `--${boundary}\r\n` + + "Content-Type: application/json; charset=utf-8\r\n\r\n" + + `${JSON.stringify(part)}\r\n`, + ) + .join("") + + `--${boundary}--\r\n`; + + const encoder = new TextEncoder(); + const bytes = encoder.encode(body); + + const stream = new ReadableStream({ + start(controller) { + for (let i = 0; i < bytes.length; i += chunkSize) { + controller.enqueue(bytes.slice(i, i + chunkSize)); + } + controller.close(); + }, + }); + + return new Response(stream, { + headers: {"Content-Type": `multipart/mixed; boundary="${boundary}"`}, + }); + } + + test("parses each part across chunk boundaries", async () => { + const payloads: any[] = []; + await streamMultipartMixed( + multipartResponse( + [{data: {a: 1}, hasNext: true}, {data: {b: 2}, hasNext: false}], + "boundary", + 16, + ), + "boundary", + (p) => payloads.push(p), + ); + + expect(payloads).toEqual([ + {data: {a: 1}, hasNext: true}, + {data: {b: 2}, hasNext: false}, + ]); + }); + + test("handles a single part delivered in one chunk", async () => { + const payloads: any[] = []; + await streamMultipartMixed( + multipartResponse([{data: {only: true}}], "-", 4096), + "-", + (p) => payloads.push(p), + ); + + expect(payloads).toEqual([{data: {only: true}}]); + }); + + test("throws MultipartMixedError when the response has no body", async () => { + await expect( + streamMultipartMixed(new Response(null), "boundary", () => {}), + ).rejects.toBeInstanceOf(MultipartMixedError); + }); + + test("routes a missing body to onError when provided", async () => { + let captured: Error | undefined; + await streamMultipartMixed( + new Response(null), + "boundary", + () => {}, + (error) => { + captured = error; + }, + ); + + expect(captured).toBeInstanceOf(MultipartMixedError); + }); +}); diff --git a/src/__tests__/main.spec.ts b/src/__tests__/main.spec.ts index 1657c67..3f06cc3 100644 --- a/src/__tests__/main.spec.ts +++ b/src/__tests__/main.spec.ts @@ -1,34 +1,61 @@ -import {graphql, HttpResponse} from "msw"; +import {graphql, http, HttpResponse} from "msw"; import {setupServer} from "msw/node"; import {Network} from "relay-runtime"; -import {afterAll, beforeAll, beforeEach, expect, test, vi} from "vitest"; +import type {RequestParameters} from "relay-runtime"; +import {afterAll, beforeAll, beforeEach, describe, expect, test, vi} from "vitest"; -import {fail} from "assert"; import {createFetchQuery} from "../main"; +function fail(message: string): never { + throw new Error(message); +} + +const GRAPHQL_JSON = "application/graphql-response+json"; + +function makeRequest(overrides: Record = {}): RequestParameters { + // RequestParameters is a union (persisted vs. text), so build loosely and cast. + return { + id: null, + cacheID: "", + name: "MyQuery", + operationKind: "query", + text: "query MyQuery { name }", + metadata: {}, + ...overrides, + } as RequestParameters; +} + +/** Execute a request to completion, collecting every emitted payload. */ +function collect( + config: Parameters[0], + request: RequestParameters, + variables: Record = {}, +): Promise { + const network = Network.create(createFetchQuery(config)); + const results: any[] = []; + return new Promise((resolve, reject) => { + network.execute(request, variables, {}, null).subscribe({ + next: (value) => results.push(value), + error: reject, + complete: () => resolve(results), + }); + }); +} + const graphqlHandlers = [ - graphql.query("MyQuery", () => { - return HttpResponse.json( - {data: {name: "Name"}}, - { - headers: { - "Content-Type": "application/graphql-response+json", - }, - }, - ); - }), + graphql.query("MyQuery", () => + HttpResponse.json({data: {name: "Name"}}, { + headers: {"Content-Type": GRAPHQL_JSON}, + })), graphql.query( "DeferQuery", (() => { const boundary = "-"; const part1 = { - data: { - products: [{id: "UHJvZHVjdAppNw=="}, {id: "UHJvZHVjdAppMjE="}], - }, + data: {products: [{id: "UHJvZHVjdAppNw=="}, {id: "UHJvZHVjdAppMjE="}]}, hasNext: true, }; - const part2 = { incremental: [ { @@ -67,234 +94,375 @@ const graphqlHandlers = [ }); return new HttpResponse(stream as any, { - headers: { - "Content-Type": `multipart/mixed; boundary="${boundary}"`, - }, + headers: {"Content-Type": `multipart/mixed; boundary="${boundary}"`}, }) as any; }) as any, ), - graphql.query("NetworkError", () => { - // MSW uses "Response.error()" semantics to simulate a network error. - // It doesn't carry MSW's strict body typing, so we intentionally cast. - return HttpResponse.error() as any; - }), + graphql.query("NetworkError", () => HttpResponse.error() as any), - graphql.query("UserCredentialsExpired", () => { - return HttpResponse.json( - {data: null}, - { - status: 403, - headers: { - "Content-Type": "text/plain", - }, - }, - ); - }), + graphql.query("UserCredentialsExpired", () => + HttpResponse.json({data: null}, { + status: 403, + headers: {"Content-Type": "text/plain"}, + })), - graphql.query("QueryWithBadContentType", () => { - return HttpResponse.json( - {data: {name: "Name"}}, - { - status: 200, - headers: { - "Content-Type": "text/plain", - }, - }, - ); - }), + graphql.query("QueryWithBadContentType", () => + HttpResponse.json({data: {name: "Name"}}, { + status: 200, + headers: {"Content-Type": "text/plain"}, + })), ]; const server = setupServer(...graphqlHandlers); -// Start server before all tests -beforeAll(() => - server.listen({ - onUnhandledRequest: "error", - }) -); - -// Close server after all tests +beforeAll(() => server.listen({onUnhandledRequest: "error"})); afterAll(() => server.close()); - -// Reset handlers after each test `important for test isolation` beforeEach(() => server.resetHandlers()); -test("query", async () => { - const network = Network.create( - createFetchQuery({ - url: `http://localhost/graphql`, - async handleLogout() {}, - allowApplicationJsonContentType: true, - }), - ); - - const result = await network - .execute( +describe("responses", () => { + test("parses an application/graphql-response+json query", async () => { + const results = await collect( + {url: "http://localhost/graphql", allowApplicationJsonContentType: true}, + makeRequest(), + ); + + expect(results).toEqual([{data: {name: "Name"}}]); + }); + + test("streams incremental defer payloads from multipart/mixed", async () => { + const results = await collect( + {url: "http://localhost/graphql", allowApplicationJsonContentType: true}, + makeRequest({name: "DeferQuery", text: "query DeferQuery { products { id } }"}), + ); + + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ + data: {products: [{id: "UHJvZHVjdAppNw=="}, {id: "UHJvZHVjdAppMjE="}]}, + hasNext: true, + }); + expect(results[1]).toMatchObject({ + data: {exportName: "Kolomela 63.5%, 8mm Fine Ore"}, + label: "test", + path: ["products", 1], + }); + }); + + test("resolves the url from a function", async () => { + server.use( + http.get("http://localhost/from-fn", () => + HttpResponse.json({data: {name: "FromFn"}}, { + headers: {"Content-Type": GRAPHQL_JSON}, + })), + ); + + const results = await collect( + {url: async () => "http://localhost/from-fn", allowApplicationJsonContentType: true}, + makeRequest(), + ); + + expect(results).toEqual([{data: {name: "FromFn"}}]); + }); + + test("sends a query as a GET request carrying the operation in search params", async () => { + let method: string | undefined; + let params: URLSearchParams | undefined; + server.use( + http.get("http://localhost/wire", ({request}) => { + method = request.method; + params = new URL(request.url).searchParams; + return HttpResponse.json({data: {name: "ok"}}, { + headers: {"Content-Type": GRAPHQL_JSON}, + }); + }), + ); + + await collect({url: "http://localhost/wire"}, makeRequest()); + + expect(method).toBe("GET"); + expect(params?.get("query")).toBe("query MyQuery { name }"); + expect(params?.get("operationName")).toBe("MyQuery"); + expect(params?.get("variables")).toBe("{}"); + }); + + test("sends a mutation as a JSON POST body", async () => { + let method: string | undefined; + let body: any; + server.use( + http.post("http://localhost/mutate", async ({request}) => { + method = request.method; + body = await request.json(); + return HttpResponse.json({data: {ok: true}}, { + headers: {"Content-Type": GRAPHQL_JSON}, + }); + }), + ); + + await collect( + {url: "http://localhost/mutate"}, + makeRequest({text: "mutation Go { ok }", operationKind: "mutation"}), + ); + + expect(method).toBe("POST"); + expect(body).toMatchObject({query: "mutation Go { ok }", variables: {}}); + }); +}); + +describe("headers", () => { + test("applies headers produced by a function", async () => { + let authHeader: string | null = null; + server.use( + http.get("http://localhost/auth", ({request}) => { + authHeader = request.headers.get("authorization"); + return HttpResponse.json({data: {name: "ok"}}, { + headers: {"Content-Type": GRAPHQL_JSON}, + }); + }), + ); + + await collect( { - id: null, - cacheID: "", - name: "myquery", - operationKind: "query", - text: "query MyQuery { name }", - metadata: {}, + url: "http://localhost/auth", + headers: async () => ({authorization: "Bearer token"}), }, - {}, - {}, - null, - ) - .toPromise(); - expect(result).toMatchObject({ - data: {name: "Name"}, + makeRequest(), + ); + + expect(authHeader).toBe("Bearer token"); }); }); -test("defer multipart/mixed streams incremental payloads", async () => { - const network = Network.create( - createFetchQuery({ - url: `http://localhost/graphql`, - async handleLogout() {}, - allowApplicationJsonContentType: true, - }), - ); +describe("content-type handling", () => { + test("rejects application/json unless explicitly allowed", async () => { + server.use( + http.get("http://localhost/json", () => HttpResponse.json({data: {name: "Name"}})), + ); - const results: any[] = []; + await expect(collect({url: "http://localhost/json"}, makeRequest())).rejects + .toThrow(/Unhandled content-type application\/json/); + }); - await new Promise((resolve, reject) => { - network - .execute( - { - id: null, - cacheID: "", - name: "myquery", - operationKind: "query", - text: "query DeferQuery { products { id } }", - metadata: {}, - }, - {}, - {}, - null, - ) - .subscribe({ - next: (value) => results.push(value), - error: reject, - complete: resolve, - }); + test("accepts application/json when allowApplicationJsonContentType is set", async () => { + server.use( + http.get("http://localhost/json", () => HttpResponse.json({data: {name: "Name"}})), + ); + + const results = await collect( + {url: "http://localhost/json", allowApplicationJsonContentType: true}, + makeRequest(), + ); + + expect(results).toEqual([{data: {name: "Name"}}]); }); - expect(results).toHaveLength(2); - expect(results[0]).toMatchObject({ - data: { - products: [{id: "UHJvZHVjdAppNw=="}, {id: "UHJvZHVjdAppMjE="}], - }, - hasNext: true, + test("throws ServerError on a 200 with an unhandled content-type", async () => { + await expect( + collect( + {url: "http://localhost/graphql"}, + makeRequest({ + name: "QueryWithBadContentType", + text: "query QueryWithBadContentType { name }", + }), + ), + ).rejects.toThrow(/Unhandled content-type text\/plain/); }); - expect(results[1]).toMatchObject({ - data: {exportName: "Kolomela 63.5%, 8mm Fine Ore"}, - label: "test", - path: ["products", 1], + + test("throws ServerError when a 200 has no content-type", async () => { + server.use( + http.get("http://localhost/empty", () => new HttpResponse(null, {status: 200})), + ); + + await expect(collect({url: "http://localhost/empty"}, makeRequest())).rejects + .toThrow(/Missing content-type/); }); }); -test("network error", async () => { - const network = Network.create( - createFetchQuery({ - url: `http://localhost/graphql`, - async handleLogout() {}, - }), - ); - - try { - await network - .execute( - { - id: null, - cacheID: "", - name: "myquery", - operationKind: "query", - text: "query NetworkError { name }", - metadata: {}, - }, - {}, - {}, - null, - ) - .toPromise(); - fail("exception not thrown"); - } catch (ex) { - if (ex instanceof Error) { - expect(ex.message).toMatch(/Failed to fetch|fetch failed/i); - } else { - throw ex; - } - } +describe("deleteDataIfError", () => { + test("drops data when a payload carries both data and errors", async () => { + server.use( + http.get("http://localhost/partial", () => + HttpResponse.json( + {data: {name: "Name"}, errors: [{message: "bad"}]}, + {headers: {"Content-Type": GRAPHQL_JSON}}, + )), + ); + + const results = await collect({url: "http://localhost/partial"}, makeRequest()); + + expect(results).toHaveLength(1); + expect(results[0]).not.toHaveProperty("data"); + expect(results[0]).toMatchObject({errors: [{message: "bad"}]}); + }); +}); + +describe("file uploads", () => { + test("sends a graphql-multipart request with a preflight header", async () => { + let operations: string | null = null; + let map: string | null = null; + let fileText: string | null = null; + let preflight: string | null = null; + + server.use( + http.post("http://localhost/upload", async ({request}) => { + preflight = request.headers.get("graphql-preflight"); + const form = await request.formData(); + operations = form.get("operations") as string; + map = form.get("map") as string; + const file = form.get("1"); + fileText = file instanceof File ? await file.text() : null; + return HttpResponse.json({data: {ok: true}}, { + headers: {"Content-Type": GRAPHQL_JSON}, + }); + }), + ); + + const results = await collect( + {url: "http://localhost/upload"}, + makeRequest({text: "mutation Upload { ok }", operationKind: "mutation"}), + {file: new File(["hello"], "hello.txt", {type: "text/plain"})}, + ); + + expect(preflight).toBe("1"); + expect(JSON.parse(operations!)).toMatchObject({query: "mutation Upload { ok }"}); + expect(JSON.parse(map!)).toEqual({"1": ["variables.file"]}); + expect(fileText).toBe("hello"); + expect(results).toEqual([{data: {ok: true}}]); + }); }); -test("network error", async () => { - const network = Network.create( - createFetchQuery({ - url: `http://localhost/graphql`, - async handleLogout() {}, - }), - ); - - try { - await network - .execute( +describe("retry", () => { + test("retries a retriable request until it succeeds", async () => { + let attempts = 0; + server.use( + http.get("http://localhost/retry", () => { + attempts += 1; + if (attempts < 2) { + return new HttpResponse("unavailable", { + status: 503, + headers: {"Content-Type": "text/plain"}, + }); + } + return HttpResponse.json({data: {name: "ok"}}, { + headers: {"Content-Type": GRAPHQL_JSON}, + }); + }), + ); + + const results = await collect( + { + url: "http://localhost/retry", + // Queries are sent as GET, so "get" is the retriable method. + retry: {limit: 3, methods: ["get"], statusCodes: [503], delay: () => 0}, + }, + makeRequest(), + ); + + expect(attempts).toBe(2); + expect(results).toEqual([{data: {name: "ok"}}]); + }); + + test("never retries mutations", async () => { + let attempts = 0; + server.use( + http.post("http://localhost/no-retry", () => { + attempts += 1; + return new HttpResponse("unavailable", { + status: 503, + headers: {"Content-Type": "text/plain"}, + }); + }), + ); + + await expect( + collect( { - id: null, - cacheID: "", - name: "myquery", - operationKind: "query", - text: "query QueryWithBadContentType { name }", - metadata: {}, + url: "http://localhost/no-retry", + retry: {limit: 3, methods: ["post"], statusCodes: [503], delay: () => 0}, }, - {}, - {}, - null, - ) - .toPromise(); - fail("exception not thrown"); - } catch (ex) { - if (ex instanceof Error) { - expect(ex.message).toMatch("Unhandled content-type text/plain on"); - } else { - throw ex; - } - } + makeRequest({text: "mutation Upload { ok }", operationKind: "mutation"}), + ), + ).rejects.toThrow(); + + expect(attempts).toBe(1); + }); }); -test("user expired", async () => { - const handleLogoutMock = vi.fn(); - const network = Network.create( - createFetchQuery({ - url: `http://localhost/graphql`, - handleLogout: handleLogoutMock, - }), - ); - - try { - await network - .execute( - { - id: null, - cacheID: "", - name: "myquery", - operationKind: "query", +describe("logout handling", () => { + test("calls handleLogout on the default 403 check", async () => { + const handleLogout = vi.fn(); + + await expect( + collect( + {url: "http://localhost/graphql", handleLogout}, + makeRequest({ + name: "UserCredentialsExpired", text: "query UserCredentialsExpired { name }", - metadata: {}, + }), + ), + ).rejects.toThrow(); + + expect(handleLogout).toHaveBeenCalledTimes(1); + }); + + test("honours a custom logoutCheck", async () => { + const handleLogout = vi.fn(); + server.use( + http.get("http://localhost/teapot", () => + new HttpResponse("nope", { + status: 418, + headers: {"Content-Type": "text/plain"}, + })), + ); + + await expect( + collect( + { + url: "http://localhost/teapot", + handleLogout, + logoutCheck: (response) => response.status === 418, }, - {}, - {}, - null, - ) - .toPromise(); - fail("exception not thrown"); - } catch (ex) { - if (ex instanceof Error) { - expect(handleLogoutMock).toHaveBeenCalledTimes(1); - } else { - throw ex; + makeRequest(), + ), + ).rejects.toThrow(); + + expect(handleLogout).toHaveBeenCalledTimes(1); + }); + + test("does not call handleLogout for unrelated error statuses", async () => { + const handleLogout = vi.fn(); + server.use( + http.get("http://localhost/bad-request", () => + new HttpResponse("bad", { + status: 400, + headers: {"Content-Type": "text/plain"}, + })), + ); + + await expect( + collect({url: "http://localhost/bad-request", handleLogout}, makeRequest()), + ).rejects.toThrow(); + + expect(handleLogout).not.toHaveBeenCalled(); + }); +}); + +describe("errors", () => { + test("wraps fetch network failures", async () => { + try { + await collect( + {url: "http://localhost/graphql"}, + makeRequest({name: "NetworkError", text: "query NetworkError { name }"}), + ); + fail("exception not thrown"); + } catch (ex) { + // ky v2 wraps fetch network failures in its own NetworkError. + expect((ex as Error).message).toMatch(/network error/i); } - } + }); + + test("rejects persisted queries (missing request text)", async () => { + await expect( + collect({url: "http://localhost/graphql"}, makeRequest({text: null})), + ).rejects.toThrow(/Persisted Queries are not supported/); + }); }); diff --git a/src/main.ts b/src/main.ts index 87db79c..0d92ac5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ -import ky, {HTTPError} from "ky"; -import type {AfterResponseHook, BeforeErrorHook, BeforeRequestHook, Options} from "ky"; +import ky, {isHTTPError} from "ky"; +import type {BeforeErrorHook, Hooks, Options} from "ky"; import type { CacheConfig, FetchFunction, @@ -14,29 +14,45 @@ import type { import extractFiles, {type ExtractableFile} from "extract-files/extractFiles.mjs"; // @ts-expect-error https://github.com/jaydenseric/extract-files/issues/28 import isExtractableFile from "extract-files/isExtractableFile.mjs"; -import type {Sink} from "relay-runtime/lib/network/RelayObservable"; +import type {Sink} from "relay-runtime/network/RelayObservable"; import {emitRelayCompatiblePayload, extractBoundary, streamMultipartMixed} from "./defer"; type Headers = Record; interface Configuration { + /** GraphQL endpoint, or a function resolving one (e.g. per-tenant routing). */ url: string | (() => Promise); + /** Static headers, or a function invoked per request to produce them. */ headers?: Headers | ((request: RequestParameters) => Promise); + /** Per-attempt request timeout in ms. See ky's `timeout` option. */ timeout?: Options["timeout"]; + /** + * Retry policy for retriable (non-mutation) operations. Queries are sent as + * GET, so the default `methods: ["get"]` applies. See ky's `retry` option. + */ retry?: Options["retry"]; - // Check if we should log the user out + /** + * Decides whether a response means the user's session is gone. Defaults to + * treating HTTP 403 as logged-out. + */ logoutCheck?(response: Response): boolean; - // Handle 401 + /** Invoked when `logoutCheck` matches, e.g. to clear the store / tokens. */ handleLogout?(): Promise | void; - // Hotchocolate will return an empty object when mutations fail - // This breaks the useMutation error handling because - // The error will arrive as the second argument to the onCompleted method instead of the onError + /** + * When a payload contains both `data` and `errors`, drop `data` so Relay + * routes the failure through `onError` instead of `onCompleted`. Defaults to + * `true` (works around servers such as Hot Chocolate that return an empty + * data object on error). Set to `false` to emit payloads verbatim. + */ deleteDataIfError?: boolean; + /** + * Accept plain `application/json` responses in addition to the spec's + * `application/graphql-response+json`. Off by default. + */ allowApplicationJsonContentType?: boolean; - beforeRequest?: BeforeRequestHook[]; - afterResponse?: AfterResponseHook[]; - beforeError?: BeforeErrorHook[]; + /** ky lifecycle hooks (beforeRequest, afterResponse, beforeError, ...). */ + hooks?: Hooks; } function defaultLogoutCheck(response: Response) { @@ -50,12 +66,12 @@ export class ServerError extends Error { } export function createFetchQuery(config: Configuration): FetchFunction { + const deleteDataIfError = config.deleteDataIfError ?? true; return function fetchQuery( request: RequestParameters, variables: Variables, cacheConfig: CacheConfig, uploadables?: UploadableMap | null, - deleteDataIfError = true, ): Subscribable { return { subscribe: (sink: Sink) => { @@ -135,12 +151,13 @@ async function doFetch( ); const multipart = files.size > 0; - const headers = typeof config.headers === "function" + const headers: Headers = typeof config.headers === "function" ? await config.headers(request) - : config.headers ?? multipart - // Enable preflight header - ? {"graphql-preflight": "1"} - : {}; + : {...config.headers}; + if (multipart) { + // Enable the preflight header for graphql-multipart uploads. + headers["graphql-preflight"] = "1"; + } let resp: Response; @@ -148,39 +165,42 @@ async function doFetch( ? config.retry ?? defaultRetry : undefined; + const logoutHook: BeforeErrorHook = async ({error}) => { + // ky v2 runs beforeError for all error types (network, timeout, ...), + // but only HTTPError carries a response to inspect. + if (isHTTPError(error)) { + const {response} = error; + if ( + config.logoutCheck + ? config.logoutCheck(response) + : defaultLogoutCheck(response) + ) { + if (config.handleLogout) { + await config.handleLogout(); + } + } + } + + return error; + }; + const options: Options = { timeout: config.timeout, retry, headers, signal, - method: request.operationKind == "query" ? "get" : "post", hooks: { + ...config.hooks, beforeError: config.handleLogout - ? [ - async (error) => { - const {response} = error; - if ( - config.logoutCheck - ? config.logoutCheck(response) - : defaultLogoutCheck(response) - ) { - if (config.handleLogout) { - await config.handleLogout(); - } - } - - return error; - }, - ...(config.beforeError ?? []), - ] - : config.beforeError ?? [], - beforeRequest: config.beforeRequest ?? [], - afterResponse: config.afterResponse ?? [], + ? [logoutHook, ...(config.hooks?.beforeError ?? [])] + : config.hooks?.beforeError, }, }; if (multipart) { resp = await postMultipart(url, options, request, variablesClone, files); + } else if (request.operationKind === "query") { + resp = await getQuery(url, options, request, variables); } else { resp = await postJson(url, options, request, variables); } @@ -231,35 +251,37 @@ async function doFetch( ); } } catch (ex) { - if (ex instanceof HTTPError) { - const contentType = ex.response.headers.get("content-type"); + if (isHTTPError(ex)) { + const {response, data} = ex; + const contentType = response.headers.get("content-type"); + // ky v2 auto-consumes the HTTPError body into `data`: a parsed object for + // JSON content types, plain text otherwise, or undefined when empty. + const asText = () => typeof data === "string" ? data : data == null ? "" : JSON.stringify(data); if (contentType == null) { - const text = await ex.response.text(); - if (!ex.response.ok) { - throw new ServerError(ex.response.status, ex.response.statusText, text); + if (!response.ok) { + throw new ServerError(response.status, response.statusText, asText()); } throw new ServerError(undefined, `Missing content-type on response`); } else if (contentType.startsWith("multipart/mixed")) { - const text = await ex.response.text(); - throw new ServerError(ex.response.status, ex.response.statusText, text); + throw new ServerError(response.status, response.statusText, asText()); } else if (contentType === "text/plain") { - throw new ServerError(undefined, await ex.response.text()); + throw new ServerError(undefined, asText()); } else if ( contentType.startsWith("application/graphql-response+json") || (contentType.startsWith("application/json") && allowApplicationJsonContentType) ) { // We got a well formed graphql response - const payload: GraphQLResponse = await ex.response.json(); + const payload = data as GraphQLResponse; if (!allowApplicationJsonContentType) { if (onPayload) { emitRelayCompatiblePayload(payload, onPayload); return; } - throw new Error(JSON.stringify(payload)); + throw new Error(JSON.stringify(payload), {cause: ex}); } - throw new Error(JSON.stringify(payload)); + throw new Error(JSON.stringify(payload), {cause: ex}); } else { throw new ServerError( undefined, @@ -272,6 +294,23 @@ async function doFetch( } } +async function getQuery( + url: string, + options: Options, + request: RequestParameters, + variables: Variables, +): Promise { + // GraphQL-over-HTTP GET: the operation is carried in the query string. + return ky.get(url, { + ...options, + searchParams: { + query: request.text ?? "", + operationName: request.name, + variables: JSON.stringify(variables), + }, + }); +} + async function postJson( url: string, options: Options, diff --git a/vitest.config.js b/vitest.config.js index 817165c..8f137cd 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -3,5 +3,11 @@ import {defineConfig} from "vitest/config"; export default defineConfig({ test: { environment: "node", + coverage: { + provider: "v8", + reporter: ["text", "text-summary", "json-summary", "json", "lcov"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.spec.ts", "src/vite-env.d.ts"], + }, }, });