From 5d87073b699bab966263f5732054ff54828bbbaf Mon Sep 17 00:00:00 2001 From: rejifald Date: Fri, 7 Aug 2026 18:53:24 +0300 Subject: [PATCH] fix(core): axiosAdapter(axios) typechecks against real axios (#708) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The snippet in the adapter's own file header — `axiosAdapter(axios)` — did not compile. Against axios 1.19.0 under `packages/core/tsconfig.json`, tsc 5.9.3 rejected the call: error TS2345: Argument of type 'AxiosStatic' is not assignable to parameter of type 'AxiosLike'. Types of property 'request' are incompatible. ... Types of property 'responseType' are incompatible. Type 'string' is not assignable to type 'ResponseType'. One word caused it. `AxiosLikeConfig.responseType` was `string`; axios types the same field as its own `ResponseType` union. A wider type in a parameter position makes `AxiosLikeConfig` unassignable to `AxiosRequestConfig`, which makes the whole client unassignable to `AxiosLike` — so `axios`, the one client the seam exists to accept, was the one client it rejected. The repo's strict `exactOptionalPropertyTypes` is what surfaces it rather than causing it. Narrowing to `'arraybuffer' | 'json' | 'text'` costs nothing: the adapter only ever sends `'arraybuffer'`, and the field is otherwise reachable only through `defaults`, where a caller-supplied `responseType` is overwritten anyway. All three call forms — `axiosAdapter(axios)`, `axiosAdapter(axios.create())`, `axiosAdapter(axios, { timeout: 5 })` — now compile, and `src/**` stays clean. The type-level test could not have caught this, by construction: const client = null as unknown as AxiosLike; // test-d/adapter-capabilities expectAssignable(axiosAdapter(client)); The cast ASSERTS the client is an `AxiosLike` instead of testing whether axios is one — it asserted away the exact assignability that was broken. It now asserts against real `axios` types and pins all three call forms; the structural hand-rolled client stays alongside it, since the seam is deliberately open. Reverting the one-word fix turns that file red with the original TS2345 on all three lines, so the guard is load-bearing rather than decorative. axios becomes a devDependency of `packages/core` (where `zod` already sits for the same reason — a type-only test-d dependency). It is resolved offline from the shared pnpm store; the shipped runtime stays zero-dependency, and nothing under `src/` imports it. `Refs`, not `Closes`: #708 §2-§5 are untouched and stay open. Refs #708 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 6 +++ packages/core/package.json | 1 + packages/core/src/axios-adapter.ts | 6 ++- .../test-d/adapter-capabilities.test-d.ts | 12 +++++ pnpm-lock.yaml | 54 +++++++++++++++++++ 5 files changed, 78 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbc0b623..ebb322e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -566,6 +566,12 @@ npm release are grouped under the in-development version that introduced them. ### Fixed +- **`axiosAdapter(axios)` — the adapter's own documented snippet — now typechecks against real + axios.** ([#708](https://github.com/rejifald/StitchAPI/issues/708)) `AxiosLikeConfig.responseType` + was `string`, which axios types as its narrower `ResponseType` union, so passing `axios` (or + `axios.create()`) failed with `TS2345` under `exactOptionalPropertyTypes`. The type-level test + missed it by casting a client through `AxiosLike`; it now asserts against real `axios` types. + - **Adding `cache: { ttl }` no longer turns a handled vendor failure into a process exit.** ([#670](https://github.com/rejifald/StitchAPI/issues/670)) A cached stitch whose vendor returned `503` emitted an **unhandled promise rejection**, which under Node's default diff --git a/packages/core/package.json b/packages/core/package.json index c2cb9948..3976d5ec 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -328,6 +328,7 @@ "@types/node": "^22.10.0", "@vitest/coverage-v8": "^4.1.10", "@vitest/eslint-plugin": "^1.6.24", + "axios": "^1.19.0", "esbuild": "^0.28.1", "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", diff --git a/packages/core/src/axios-adapter.ts b/packages/core/src/axios-adapter.ts index 193888a4..070d7b0e 100644 --- a/packages/core/src/axios-adapter.ts +++ b/packages/core/src/axios-adapter.ts @@ -34,7 +34,11 @@ export interface AxiosLikeConfig { method: string; headers?: Record; data?: unknown; - responseType?: string; + // Narrower than `string` on purpose: axios types this as its own `ResponseType` union, and a + // plain `string` makes `AxiosLikeConfig` unassignable to `AxiosRequestConfig` — which made + // `axiosAdapter(axios)` itself fail to typecheck against real axios (#708). The adapter only + // ever sends 'arraybuffer' (below), so narrowing to a subset of axios's union costs nothing. + responseType?: 'arraybuffer' | 'json' | 'text'; signal?: AbortSignal; validateStatus?: ((status: number) => boolean) | null; onUploadProgress?: (e: AxiosLikeProgressEvent) => void; diff --git a/packages/core/test-d/adapter-capabilities.test-d.ts b/packages/core/test-d/adapter-capabilities.test-d.ts index 8a093022..684e0be3 100644 --- a/packages/core/test-d/adapter-capabilities.test-d.ts +++ b/packages/core/test-d/adapter-capabilities.test-d.ts @@ -12,6 +12,7 @@ import type { AxiosLike, } from '../src'; +import axios from 'axios'; import { expectAssignable, expectError, expectType } from 'tsd'; // A plain transport function — no `capabilities` — still satisfies Adapter (backward compatible). @@ -33,9 +34,20 @@ expectType(fetchAdapter().capabilities); // Every built-in resolves to Adapter. expectAssignable(fetchAdapter()); expectAssignable(xhrAdapter()); + +// The axios seam is structural, so any hand-rolled client that satisfies it is accepted... const client = null as unknown as AxiosLike; expectAssignable(axiosAdapter(client)); +// ...but the assertion that matters is against REAL axios, because that is what the documented +// snippet passes. The `as unknown as AxiosLike` cast above cannot make it: it ASSERTS the client is +// an AxiosLike rather than testing whether axios actually is one, so #708 — `AxiosLikeConfig` being +// unassignable to axios's own `AxiosRequestConfig`, via a too-wide `responseType?: string` — passed +// this file while `axiosAdapter(axios)` failed to compile for every user. Pin all three call forms. +expectAssignable(axiosAdapter(axios)); +expectAssignable(axiosAdapter(axios.create())); +expectAssignable(axiosAdapter(axios, { timeout: 5 })); + // `supports` is a list of the known capability tags; `name` is optional. expectAssignable({ supports: [] }); expectAssignable({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55c0e77c..055d176a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -378,6 +378,9 @@ importers: '@vitest/eslint-plugin': specifier: ^1.6.24 version: 1.6.24(@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)(vitest@4.1.10) + axios: + specifier: ^1.19.0 + version: 1.19.0 esbuild: specifier: ^0.28.1 version: 0.28.1 @@ -4464,6 +4467,10 @@ packages: resolution: {integrity: sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==} engines: {node: '>=14.0'} + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -4632,6 +4639,9 @@ packages: resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==} engines: {node: '>=4'} + axios@1.19.0: + resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -5923,6 +5933,15 @@ packages: flow-enums-runtime@0.0.6: resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + fontfaceobserver@2.3.0: resolution: {integrity: sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==} @@ -6344,6 +6363,10 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -7894,6 +7917,10 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -12702,6 +12729,12 @@ snapshots: adm-zip@0.6.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + agent-base@7.1.4: {} ai@6.0.208(zod@4.4.3): @@ -12886,6 +12919,16 @@ snapshots: axe-core@4.12.1: {} + axios@1.19.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + axobject-query@4.1.0: {} babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7): @@ -14470,6 +14513,8 @@ snapshots: flow-enums-runtime@0.0.6: {} + follow-redirects@1.16.0: {} + fontfaceobserver@2.3.0: {} for-each@0.3.5: @@ -14954,6 +14999,13 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -16840,6 +16892,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@2.1.0: {} + punycode@2.3.1: {} pure-rand@6.1.0: {}