From d5424ddd0181f71442a7c079a0ab344dfa05f4b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Wed, 19 Aug 2026 16:35:04 +0200 Subject: [PATCH 1/2] Add @polyvariant/smithy-ts-runtime: OOTB transports The codegen emits transport *interfaces* and leaves the implementation to consumers, so every consuming project ends up hand-rolling the same fetch wrapper, and the same ndjson read loop wherever streaming is used. This publishes that as a library. `fetchTransport` implements both halves of the contract: unary requests, plus ndjson and binary framing in both directions for streaming ops. It returns non-2xx as a response rather than throwing, since the generated client needs the status and body to dispatch declared errors; 401 is the exception, and the generated error class can be passed in so existing `instanceof` checks keep working. Middleware (`chain` / `around` / `tap` / `withHeaders`) covers cross-cutting concerns like tracing, auth headers and error reporting, and `interceptorStack` covers the add-and-remove-later case a React effect needs. The per-call options blob the codegen already threads through untouched is what carries framework-specific knobs, so the package needs no HTTP client dependency of its own and stays dependency-free. The library imports nothing from generated code: it declares structural copies of the transport types. typecheck/src/runtimeUsage.ts compiles the two against each other so that pairing can't drift silently. TypeScript moves into a pnpm workspace (runtime/ + typecheck/); nix flake check now builds and unit-tests the library before typechecking the sample. A v* tag publishes the package to npm alongside the JVM artifacts, with the tag as the only source of version truth. Co-Authored-By: Claude Opus 5 --- .github/workflows/npm-publish.yml | 78 ++++++++ .gitignore | 4 + README.md | 85 +++++++-- nix/typecheck.nix | 68 +++++-- package.json | 10 ++ pnpm-lock.yaml | 59 +++++++ pnpm-workspace.yaml | 3 + runtime/.gitignore | 2 + runtime/README.md | 149 ++++++++++++++++ runtime/package.json | 46 +++++ runtime/src/errors.ts | 53 ++++++ runtime/src/fetch.ts | 284 ++++++++++++++++++++++++++++++ runtime/src/index.ts | 55 ++++++ runtime/src/middleware.ts | 152 ++++++++++++++++ runtime/src/ndjson.ts | 128 ++++++++++++++ runtime/src/types.ts | 88 +++++++++ runtime/test/fetch.test.ts | 228 ++++++++++++++++++++++++ runtime/test/middleware.test.ts | 135 ++++++++++++++ runtime/test/ndjson.test.ts | 95 ++++++++++ runtime/tsconfig.build.json | 15 ++ runtime/tsconfig.json | 20 +++ typecheck/package.json | 1 + typecheck/pnpm-lock.yaml | 33 ---- typecheck/src/runtimeUsage.ts | 91 ++++++++++ 24 files changed, 1820 insertions(+), 62 deletions(-) create mode 100644 .github/workflows/npm-publish.yml create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 runtime/.gitignore create mode 100644 runtime/README.md create mode 100644 runtime/package.json create mode 100644 runtime/src/errors.ts create mode 100644 runtime/src/fetch.ts create mode 100644 runtime/src/index.ts create mode 100644 runtime/src/middleware.ts create mode 100644 runtime/src/ndjson.ts create mode 100644 runtime/src/types.ts create mode 100644 runtime/test/fetch.test.ts create mode 100644 runtime/test/middleware.test.ts create mode 100644 runtime/test/ndjson.test.ts create mode 100644 runtime/tsconfig.build.json create mode 100644 runtime/tsconfig.json delete mode 100644 typecheck/pnpm-lock.yaml create mode 100644 typecheck/src/runtimeUsage.ts diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 0000000..95915dc --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,78 @@ +# Publishes `runtime/` (@polyvariant/smithy-ts-runtime) to npm on a `v*` tag. +# +# Hand-written, unlike ci.yml: that file is generated by sbt-typelevel +# (`sbt githubWorkflowGenerate`) and CI fails if it drifts, so the npm side +# lives here where it won't be clobbered. +# +# The tag is the single source of version truth — `v0.2.3` publishes 0.2.3, +# matching what sbt-typelevel derives for the JVM artifacts, so a tag ships +# both halves at the same version. package.json carries a placeholder 0.0.0 +# that is overwritten here rather than committed per release. +name: Publish npm package + +on: + push: + tags: [v*] + # Publishing is irreversible for a given version, so it can also be driven by + # hand — for a re-run after a transient failure. + workflow_dispatch: + inputs: + version: + description: Version to publish (without the leading `v`) + required: true + +concurrency: + group: npm-publish-${{ github.ref }} + +jobs: + publish: + name: Publish @polyvariant/smithy-ts-runtime + runs-on: ubuntu-22.04 + permissions: + contents: read + # Lets npm record the build's provenance, so consumers can verify the + # tarball was built from this commit by this workflow. + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + registry-url: https://registry.npmjs.org + + - name: Resolve version from the tag + id: version + run: | + if [ -n "${{ inputs.version }}" ]; then + version="${{ inputs.version }}" + else + version="${GITHUB_REF_NAME#v}" + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "Publishing $version" + + - name: Install + run: pnpm install --frozen-lockfile + + # The same check `nix flake check` runs: build, typecheck, unit tests, and + # the generated-sample typecheck that pins the transport types together. + # A tag should never publish something that doesn't pass it. + - name: Check + run: pnpm check + + - name: Set the version + working-directory: runtime + run: pnpm version "${{ steps.version.outputs.version }}" --no-git-tag-version --allow-same-version + + - name: Publish + working-directory: runtime + run: pnpm publish --access public --no-git-checks --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 4e0042c..4ced0c3 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,7 @@ node_modules/ .metals/ .bloop/ metals.sbt + +# nix build output +result +result-* diff --git a/README.md b/README.md index 4332fcb..1803349 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,10 @@ From one model, into a single `generated.ts`: `.brand<'Xxx'>()`-ed for nominal typing). 2. **Error classes** — one `XxxError extends Error` per `@error` shape, carrying the declared HTTP status and error-type name. -3. **Transport** — a small `Transport` interface plus request/response types you implement once - (over `fetch`, axios, …). The generated clients are transport-agnostic. Models with - streaming operations also get a `StreamTransport` (see [Streaming](#streaming)). +3. **Transport** — a small `Transport` interface plus request/response types. The generated + clients are transport-agnostic; a ready-made `fetch` implementation ships separately in + [`@polyvariant/smithy-ts-runtime`](runtime/). Models with streaming operations also get a + `StreamTransport` (see [Streaming](#streaming)). 4. **Service clients** — one `XxxClient` class per service, one `async` method per operation. Each method walks the operation's `@http` trait to build the request (URI labels, query, headers, body) and parses the response with the generated output schema, dispatching declared @@ -135,7 +136,8 @@ streamed response commits its status before the first element, **a mid-stream fa be an HTTP status** — model it as a member of the streamed union (the protocol expects a terminal member such as `completed` / `failed`). -Framing itself is the transport's job: implement `StreamTransport.requestStream`, which +Framing itself is the transport's job: `@polyvariant/smithy-ts-runtime` does it for you, and +a hand-rolled transport implements `StreamTransport.requestStream`, which receives `requestStreamEncoding` / `responseStreamEncoding` telling it which framing to apply, so it never has to guess from a content type. The generated code adds the per-element schema on top. Services with streaming operations take both halves — @@ -155,9 +157,42 @@ mockService(WatcherMock, { }) ``` -> **TODO:** the `Transport` / `StreamTransport` implementations are currently written by hand -> in each consuming project. They should be published as a library (a `fetch`-based transport -> with ndjson/binary framing) so consumers don't re-implement the same framing logic. +Implementations of both halves ship in +[`@polyvariant/smithy-ts-runtime`](runtime/) — see [Transports](#transports). + +## Transports + +The codegen emits the `Transport` / `StreamTransport` *interfaces*; the +implementation lives in [`@polyvariant/smithy-ts-runtime`](runtime/), published +separately so a generated file stays dependency-free. + +```sh +pnpm add @polyvariant/smithy-ts-runtime +``` + +```ts +import { chain, fetchTransport, withHeaders } from '@polyvariant/smithy-ts-runtime' +import { DirectoryClient, FeedClient } from './generated.js' + +const transport = chain( + fetchTransport({ baseUrl: '/api' }), + withHeaders(() => ({ authorization: `Bearer ${token()}` })), +) + +const directory = new DirectoryClient(transport) +const feed = new FeedClient(transport, transport) // streaming ops take both +``` + +It covers the whole contract — unary requests, ndjson and binary framing in both +directions, 401 handling, and a middleware seam (`chain` / `around` / `tap` / +`interceptorStack`) for tracing, auth headers and error reporting. The framing +primitives are exported on their own for transports it doesn't ship. + +The library imports nothing from generated code: it declares structural copies of +the transport types, which TypeScript matches by shape. `typecheck/src/runtimeUsage.ts` +compiles the two against each other, so the pairing can't drift silently. + +See [runtime/README.md](runtime/README.md) for the full API. ## Conventions & limits @@ -177,21 +212,47 @@ mockService(WatcherMock, { sbt test # unit tests sbt sbtPlugin/scripted # the sbt plugin, end to end sbt tsCodegenSample # regenerate typecheck/src/generated.ts -nix flake check # type-check the generated TypeScript with tsc +nix flake check # type-check + test the TypeScript side +pnpm check # the same, without nix (needs `pnpm install` first) ``` -`typecheck/` holds a model (`model.smithy`) exercising every construct the codegen emits, its -committed output (`src/generated.ts`), and a consumer-side `src/usage.ts` that uses the -clients, streams and mocks the way a caller would. `nix flake check` runs the real `tsc` over -both under `strict` + `erasableSyntaxOnly`. +The TypeScript lives in a pnpm workspace of two packages: + +- `runtime/` — the published transport library. `pnpm --filter @polyvariant/smithy-ts-runtime + run check` builds it, type-checks it and runs its `node:test` suite (framing round-trips, + the transport against a `fetch` double, middleware ordering). +- `typecheck/` — a model (`model.smithy`) exercising every construct the codegen emits, its + committed output (`src/generated.ts`), a consumer-side `src/usage.ts` that uses the clients, + streams and mocks the way a caller would, and `src/runtimeUsage.ts`, which drives those same + clients with the *library's* transport. That last file is what pins the library's structural + transport types to the ones the codegen emits — change one without the other and it stops + compiling. + +`nix flake check` runs both under `strict` + `erasableSyntaxOnly`. This matters because the Scala tests assert on substrings of the emitted file, which cannot catch a type error — a generator declared as `AsyncIterable`, an intersection with an empty `z.object`, a `Date` cast to a query value. After changing the generator, run `sbt tsCodegenSample` and commit the result; CI fails if it drifts. +Changing anything under `runtime/` or `typecheck/` that moves the lockfile means updating +`pnpmDeps.hash` in `nix/typecheck.nix` — build once, and nix prints the hash it wanted. +Note that `nix build` only sees git-tracked files, so `git add` new files before running it. + A `nix develop` shell provides node, pnpm, sbt and a JDK. +### Releasing + +A `v*` tag ships both halves at the same version: sbt-typelevel publishes the JVM artifacts +from the generated `ci.yml`, and `.github/workflows/npm-publish.yml` publishes +`@polyvariant/smithy-ts-runtime` to npm. The tag is the only source of version truth — +`runtime/package.json` keeps a placeholder `0.0.0` that the workflow overwrites, so there is no +version to bump by hand. + +`ci.yml` is generated (`sbt githubWorkflowGenerate`) and CI fails if it drifts; the npm +workflow is hand-written for that reason. Publishing needs an `NPM_TOKEN` secret with publish +rights on the `@polyvariant` scope. + ## Dependencies `smithy-build`, `smithy-codegen-core`, `smithy-model`, `alloy-core`, `smithy4s-protocol`, and diff --git a/nix/typecheck.nix b/nix/typecheck.nix index 1e00582..22c5302 100644 --- a/nix/typecheck.nix +++ b/nix/typecheck.nix @@ -7,13 +7,22 @@ pnpmConfigHook, }: -# `tsc` over the committed sample in typecheck/ — the only check that proves the -# generated TypeScript is valid TypeScript. +# `tsc` over the TypeScript in this repo — the only check that proves the +# generated code, and the runtime library that serves it, are valid TypeScript. # -# Hermetic: the .ts is committed rather than generated here, so no JVM or sbt is -# needed in the sandbox, and node_modules comes from a pnpm FOD. Regenerate the -# sample with `sbt tsCodegenSample`; CI runs `sbt tsCodegenSampleCheck` to fail -# if it drifts from the model. +# Two workspace packages: +# +# runtime/ the published transport library: built (so its .d.ts exist), +# typechecked, and unit-tested with node:test. +# typecheck/ the committed sample generated.ts, a consumer-side usage file, +# and runtimeUsage.ts — which is what proves the library's +# structural Transport types still match the ones the codegen +# emits. +# +# Hermetic: the .ts sample is committed rather than generated here, so no JVM or +# sbt is needed in the sandbox, and node_modules comes from a pnpm FOD. +# Regenerate the sample with `sbt tsCodegenSample`; CI runs +# `sbt tsCodegenSampleCheck` to fail if it drifts from the model. let pnpm = pnpm_10; @@ -23,15 +32,32 @@ stdenvNoCC.mkDerivation (finalAttrs: { pname = "smithy-ts-codegen-typecheck"; version = "0.0.0"; - src = lib.cleanSourceWith { - src = lib.cleanSource ../typecheck; - filter = - path: type: - let - rel = lib.removePrefix (toString ../typecheck + "/") (toString path); - in - !(lib.hasPrefix "node_modules" rel); - }; + # The workspace root: both packages plus the lockfile and workspace manifest. + # Only the JS side of the repo, so a change to the Scala tree doesn't + # invalidate this derivation. + src = + let + root = ../.; + keepTop = [ + "package.json" + "pnpm-lock.yaml" + "pnpm-workspace.yaml" + "runtime" + "typecheck" + ]; + in + lib.cleanSourceWith { + src = lib.cleanSource root; + filter = + path: _type: + let + rel = lib.removePrefix (toString root + "/") (toString path); + top = lib.head (lib.splitString "/" rel); + in + builtins.elem top keepTop + && !(lib.hasInfix "node_modules" rel) + && !(lib.hasInfix "dist" rel); + }; nativeBuildInputs = [ nodejs @@ -43,12 +69,20 @@ stdenvNoCC.mkDerivation (finalAttrs: { inherit pnpm; inherit (finalAttrs) pname version src; fetcherVersion = 3; - hash = "sha256-Ey7ZO5qu7Nnc0udSu0v1e+2FPQmb53KLtxo64jKD0hc="; + hash = "sha256-X8nNnf+hpzKtNFvXbtPx/g9rimlit2/t3AGpSrjw8rU="; }; buildPhase = '' runHook preBuild - pnpm run typecheck + + # The library first: typecheck/ imports its built .d.ts, and its own + # `typecheck` script builds as a prerequisite. + pnpm --filter @polyvariant/smithy-ts-runtime run check + + # Then the generated sample + the consumer-side usage, including the file + # that pins the library's transport types to the generated ones. + pnpm --filter smithy-ts-codegen-typecheck run typecheck + runHook postBuild ''; diff --git a/package.json b/package.json new file mode 100644 index 0000000..64ba46b --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "smithy-ts-codegen-workspace", + "private": true, + "version": "0.0.0", + "type": "module", + "packageManager": "pnpm@10.33.0", + "scripts": { + "check": "pnpm --filter @polyvariant/smithy-ts-runtime run check && pnpm --filter smithy-ts-codegen-typecheck run typecheck" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..36e63d3 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,59 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} + + runtime: + devDependencies: + '@types/node': + specifier: ^22.10.0 + version: 22.20.1 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + typecheck: + dependencies: + '@polyvariant/smithy-ts-runtime': + specifier: workspace:* + version: link:../runtime + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + +packages: + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + zod@4.4.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..762534d --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - runtime + - typecheck diff --git a/runtime/.gitignore b/runtime/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/runtime/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/runtime/README.md b/runtime/README.md new file mode 100644 index 0000000..2394355 --- /dev/null +++ b/runtime/README.md @@ -0,0 +1,149 @@ +# @polyvariant/smithy-ts-runtime + +Out-of-the-box HTTP transports for clients generated by +[smithy-ts-codegen](https://github.com/polyvariant/smithy-ts-codegen) — including +the ndjson / binary framing that streaming operations need. + +The generated `generated.ts` is transport-agnostic: it declares a `Transport` +interface (and a `StreamTransport` when the model streams) and leaves the +implementation to you. This package is that implementation, so each project +stops re-writing the same `fetch` wrapper and the same ndjson read loop. + +```sh +pnpm add @polyvariant/smithy-ts-runtime +``` + +Zero runtime dependencies. ESM only. + +## Usage + +```ts +import { fetchTransport } from '@polyvariant/smithy-ts-runtime' +import { DirectoryClient, FeedClient } from './generated.js' + +const transport = fetchTransport({ baseUrl: '/api' }) + +const directory = new DirectoryClient(transport) +const feed = new FeedClient(transport, transport) // streaming ops take both +``` + +One object implements both halves of the contract, so the same value is passed +to every client in a model. + +### It is not imported by the generated code + +This package declares its *own* copies of `Transport`, `StreamTransport` and +friends, structurally identical to what the codegen emits. Nothing imports +anything: `generated.ts` stays self-contained, and TypeScript matches the two by +shape. Upgrading one without the other is safe as long as the contract holds — +and `typecheck/src/runtimeUsage.ts` in this repo fails to compile if it stops +holding. + +## `fetchTransport` + +| Option | Default | | +| --- | --- | --- | +| `baseUrl` | `''` | Prefixed to every request path. `'/api'` or `'https://host/api'`. | +| `credentials` | `'include'` | So an HttpOnly session cookie rides along. `'omit'` for token auth. | +| `headers` | — | Merged into every request; a function is re-evaluated per request. Per-request headers (from `@httpHeader` members) win. | +| `fetch` | global | Override for tests or a Node/undici instance. | +| `init` | `{}` | Anything else `fetch` takes — `mode`, `cache`, `signal`, `redirect`. | +| `unauthenticated` | `true` | See below. | + +A non-2xx status is **returned, not thrown**: the generated client needs the +status and body to dispatch the operation's declared `errors: [...]`. + +### 401 + +401 is the exception. The auth middleware sits in front of every route, so it is +never modelled per-operation and the generated client cannot dispatch it — it +would surface as `UnexpectedResponseError`. By default the transport throws this +package's `UnauthenticatedError` instead. + +If your call sites check the *generated* class, pass it in so `instanceof` keeps +working: + +```ts +import { UnauthenticatedError } from './generated.js' + +fetchTransport({ + baseUrl: '/api', + unauthenticated: (operation) => new UnauthenticatedError(operation), +}) +``` + +`unauthenticated: false` leaves 401s alone. + +## Streaming + +`fetchTransport` implements `StreamTransport` too, applying the framing the +generator asks for — it never guesses from a content type: + +| `StreamEncoding` | Wire | Elements | +| --- | --- | --- | +| `'ndjson'` | `application/x-ndjson` | one JSON value per line | +| `'binary'` | `application/octet-stream` | `Uint8Array` chunks | + +Both directions, and both are lazy: an ndjson response is deframed one line per +pull, so a long-lived stream never buffers. The generated client validates each +element against the operation's schema on top of this. + +A **streamed request body** needs `duplex: 'half'`, which only Chromium supports +(over HTTP/2). Elsewhere the outgoing stream is buffered into a single body — +correct, just not incremental. This is detected per platform, not configured. + +Because a streamed response commits its HTTP status before the first element, a +mid-stream failure can't be a status — model it as a member of the streamed +union (the protocol's terminal `completed` / `failed`). A rejection from +`requestStream` therefore means the request failed *before* the stream started. + +The framing primitives are exported for transports this package doesn't ship +(a WebSocket bridge, a Node `http` client, a test double): `encodeNdjson`, +`decodeNdjson`, `encodeBinary`, `readableToAsyncIterable`, +`asyncIterableToReadable`, `collectBytes`. + +## Middleware + +`chain` wraps a transport, outermost first: + +```ts +import { chain, tap, withHeaders } from '@polyvariant/smithy-ts-runtime' + +const transport = chain( + fetchTransport({ baseUrl: '/api' }), + withHeaders(() => ({ authorization: `Bearer ${token()}` })), + tap({ onError: (err, req) => report(req.operation, err) }), +) +``` + +- **`around(f)`** — the building block: `f(req, next)` runs code around the + call. Tracing is one line: `around((req, next) => withSpan(req.operation, next))`. +- **`mapRequest(f)`** — rewrite a request before it goes out; `f` may be async. +- **`withHeaders(h)`** — merge headers in; per-request headers still win. +- **`tap(handlers)`** — observe `onRequest` / `onResponse` / `onError` without + changing anything. `onResponse` sees *every* response, including non-2xx. +- **`chainStream` / `aroundStream`** — the same for `StreamTransport`. + +Each request carries `operation` (`'DirectoryClient.getPerson'`) for naming a +span, and the `options` blob the codegen threads through untouched — which is +where per-call knobs like `skipErrorPopup` live. + +### Interceptors added after the fact + +When the handler closes over component state, it has to be registered later and +removed on unmount, the way a response-interceptor registry works: + +```ts +const interceptors = interceptorStack() +const transport = chain(fetchTransport({ baseUrl: '/api' }), interceptors.middleware) + +// `use` returns its own remover, so it is an effect cleanup directly +useEffect(() => interceptors.use({ onError: (err) => showPopup(err) }), []) +``` + +Handlers run in registration order, and a handler that ejects itself mid-request +doesn't disturb that request. + +## License + +Apache 2.0. diff --git a/runtime/package.json b/runtime/package.json new file mode 100644 index 0000000..6dae01a --- /dev/null +++ b/runtime/package.json @@ -0,0 +1,46 @@ +{ + "name": "@polyvariant/smithy-ts-runtime", + "version": "0.0.0", + "description": "Out-of-the-box HTTP transports (unary + ndjson/binary streaming) for smithy-ts-codegen clients", + "license": "Apache-2.0", + "author": "Jakub Kozłowski", + "repository": { + "type": "git", + "url": "git+https://github.com/polyvariant/smithy-ts-codegen.git", + "directory": "runtime" + }, + "homepage": "https://github.com/polyvariant/smithy-ts-codegen/tree/main/runtime#readme", + "keywords": [ + "smithy", + "codegen", + "transport", + "ndjson", + "streaming", + "fetch" + ], + "type": "module", + "packageManager": "pnpm@10.33.0", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "types": "./dist/index.d.ts", + "main": "./dist/index.js", + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "pnpm run build && tsc -p tsconfig.json", + "test": "node --test --experimental-strip-types test/*.test.ts", + "check": "pnpm run typecheck && pnpm run test" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.9.3" + } +} diff --git a/runtime/src/errors.ts b/runtime/src/errors.ts new file mode 100644 index 0000000..e704d45 --- /dev/null +++ b/runtime/src/errors.ts @@ -0,0 +1,53 @@ +/** Errors this package throws. They mirror the ones the codegen emits into + * `generated.ts`, but are *distinct classes* — an `instanceof` check against + * the generated `UnauthenticatedError` will not match one thrown from here. + * + * That matters at call sites like `catch (e) { if (e instanceof UnauthenticatedError) ... }`. + * Import the class from this package when you use a transport from this + * package, or pass your generated class via `unauthenticated` (see + * `TransportErrors`) to keep the generated one. + */ + +/** Thrown by these transports for any 401. The auth middleware sits in front of + * every route and isn't modelled per-operation, so 401 never appears in an + * operation's `errors: [...]` and the generated client cannot dispatch it. */ +export class UnauthenticatedError extends Error { + readonly operation: string + constructor(operation: string) { + super(operation + ' -> 401 Unauthorized') + this.name = 'UnauthenticatedError' + this.operation = operation + } +} + +/** Thrown when a streaming request failed before the stream began — the status + * was non-2xx and the operation declares no error for it, or the response + * carried no readable body to deframe. Once a stream has started, its status is + * already committed; failures after that arrive as elements of the streamed + * union (the protocol's terminal `completed` / `failed` member). */ +export class StreamRequestError extends Error { + readonly operation: string + readonly status: number + readonly body: unknown + constructor(operation: string, status: number, body: unknown) { + super(operation + ' -> stream request failed with ' + status) + this.name = 'StreamRequestError' + this.operation = operation + this.status = status + this.body = body + } +} + +/** Thrown while reading an ndjson response whose line was not valid JSON. + * Schema validation is the generated client's job (it throws + * `StreamDecodeError`); this is the framing layer failing earlier. */ +export class NdjsonParseError extends Error { + readonly operation: string + readonly line: string + constructor(operation: string, line: string, cause: unknown) { + super(operation + ' -> could not parse an ndjson line', { cause }) + this.name = 'NdjsonParseError' + this.operation = operation + this.line = line + } +} diff --git a/runtime/src/fetch.ts b/runtime/src/fetch.ts new file mode 100644 index 0000000..f36cfba --- /dev/null +++ b/runtime/src/fetch.ts @@ -0,0 +1,284 @@ +import { StreamRequestError, UnauthenticatedError } from './errors.js' +import { + asyncIterableToReadable, + collectBytes, + decodeNdjson, + encodeBinary, + encodeNdjson, + readableToAsyncIterable, +} from './ndjson.js' +import type { + FullTransport, + StreamTransportRequest, + StreamTransportResponse, + TransportRequest, + TransportResponse, +} from './types.js' + +export interface FetchTransportOptions { + /** Prefixed to every request URL. The generated `url` is an absolute path + * (`/people/{id}` after label substitution), so this is joined as a prefix, + * not resolved as a URL — `'/api'` and `'https://host/api'` both work. + * Defaults to `''` (same origin, paths as generated). */ + baseUrl?: string + /** Passed straight to `fetch`. Defaults to `'include'` so an HttpOnly session + * cookie rides along, which is what every same-origin SPA setup wants. Use + * `'omit'` for a token-authenticated API. */ + credentials?: RequestCredentials + /** Merged into every request; per-request headers (from `@httpHeader` + * members) win. Re-evaluated per request when a function. */ + headers?: Record | (() => Record | Promise>) + /** Override the `fetch` implementation — a test double, or a Node/undici + * instance. Defaults to the global `fetch` (looked up per call, so patching + * the global after construction still works). */ + fetch?: typeof globalThis.fetch + /** Escape hatch for anything else `fetch` takes (`mode`, `cache`, `signal`, + * `redirect`). Applied before the fields this transport controls. */ + init?: Omit + /** Turn a 401 into a thrown error rather than returning it as a response. + * + * The auth middleware sits in front of every route, so 401 is never in an + * operation's `errors: [...]` and the generated client can't dispatch it — + * it would surface as `UnexpectedResponseError`. Defaults to `true`, + * throwing this package's {@link UnauthenticatedError}. + * + * Pass your generated `UnauthenticatedError` class here to keep call sites' + * `instanceof` checks against the generated one working: + * `unauthenticated: op => new GeneratedUnauthenticatedError(op)`. + * Pass `false` to leave 401s alone. */ + unauthenticated?: boolean | ((operation: string) => unknown) +} + +const joinUrl = (baseUrl: string, url: string): string => { + if (baseUrl === '') return url + const base = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl + return url.startsWith('/') ? base + url : base + '/' + url +} + +/** `?a=1&b=2` for the defined entries, or `''`. Booleans and numbers are + * stringified; `undefined` members are dropped rather than sent as the string + * `"undefined"`. */ +const queryString = (query: TransportRequest['query']): string => { + if (query === undefined) return '' + const params = new URLSearchParams() + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) params.append(key, String(value)) + } + const qs = params.toString() + return qs === '' ? '' : '?' + qs +} + +/** Lowercase the header names, as `TransportResponse.headers` promises — the + * generated client's `x-error-type` lookup depends on it. */ +const collectHeaders = (headers: Headers): Record => { + const out: Record = {} + headers.forEach((value, key) => { + out[key.toLowerCase()] = value + }) + return out +} + +/** Parse a response body as JSON, tolerating an empty one (204, or an + * operation with no output members). A non-JSON error body (an HTML error page + * from a proxy) is surfaced as the raw text rather than throwing, so the + * status still reaches the caller. */ +const readBody = async (res: Response): Promise => { + const text = await res.text() + if (text.length === 0) return undefined + try { + return JSON.parse(text) + } catch { + return text + } +} + +const resolveHeaders = async ( + headers: FetchTransportOptions['headers'], +): Promise> => { + if (headers === undefined) return {} + return typeof headers === 'function' ? await headers() : headers +} + +const checkUnauthenticated = ( + res: { status: number }, + operation: string, + unauthenticated: FetchTransportOptions['unauthenticated'], +): void => { + if (res.status !== 401 || unauthenticated === false) return + if (typeof unauthenticated === 'function') throw unauthenticated(operation) + throw new UnauthenticatedError(operation) +} + +/** A `fetch`-backed transport implementing both the unary and the streaming + * halves of the generated contract. + * + * ```ts + * const transport = fetchTransport({ baseUrl: '/api' }) + * const directory = new DirectoryClient(transport) + * const feed = new FeedClient(transport, transport) // streaming too + * ``` + * + * It never converts a non-2xx status into a rejection: the generated client + * needs the status and body to dispatch the operation's declared errors. The + * one exception is 401 (see `unauthenticated`). + */ +export const fetchTransport = (options: FetchTransportOptions = {}): FullTransport => { + const { + baseUrl = '', + credentials = 'include', + headers: defaultHeaders, + init = {}, + unauthenticated = true, + } = options + const doFetch: typeof globalThis.fetch = (input, requestInit) => + (options.fetch ?? globalThis.fetch)(input, requestInit) + + const request = async (req: TransportRequest): Promise => { + const res = await doFetch(joinUrl(baseUrl, req.url) + queryString(req.query), { + ...init, + method: req.method, + credentials, + headers: { + ...(await resolveHeaders(defaultHeaders)), + ...(req.body !== undefined ? { 'content-type': 'application/json' } : {}), + ...(req.headers ?? {}), + }, + // Spread rather than assign `undefined`: with + // `exactOptionalPropertyTypes`, `body: undefined` is not a valid + // `RequestInit` (and a GET must have no body at all). + ...(req.body !== undefined ? { body: JSON.stringify(req.body) } : {}), + }) + + checkUnauthenticated(res, req.operation, unauthenticated) + + return { status: res.status, body: await readBody(res), headers: collectHeaders(res.headers) } + } + + const requestStream = async (req: StreamTransportRequest): Promise => { + const outgoing = await streamBody(req) + + const res = await doFetch(joinUrl(baseUrl, req.url) + queryString(req.query), { + ...init, + method: req.method, + credentials, + headers: { + ...(await resolveHeaders(defaultHeaders)), + ...requestContentType(req), + // Tell the server which framing we want back, so it doesn't have to + // infer it from the operation alone. + ...responseAccept(req), + ...(req.headers ?? {}), + }, + ...(outgoing.body !== undefined ? { body: outgoing.body } : {}), + // Required by Chromium to stream a request body at all; harmless + // elsewhere, and absent from the DOM lib types, hence the cast. + ...(outgoing.duplex ? ({ duplex: 'half' } as RequestInit) : {}), + }) + + checkUnauthenticated(res, req.operation, unauthenticated) + + const headers = collectHeaders(res.headers) + + // A streamed response commits its status before the first element, so + // anything non-2xx here failed *before* the stream began: read the body so + // the generated client can dispatch a declared error against it. + if (!res.ok || req.responseStreamEncoding === undefined) { + return { status: res.status, headers, body: await readBody(res) } + } + + if (res.body === null) { + throw new StreamRequestError(req.operation, res.status, undefined) + } + + const bytes = readableToAsyncIterable(res.body) + return { + status: res.status, + headers, + stream: + req.responseStreamEncoding === 'ndjson' ? decodeNdjson(bytes, req.operation) : bytes, + } + } + + return { request, requestStream } +} + +const requestContentType = (req: StreamTransportRequest): Record => { + switch (req.requestStreamEncoding) { + case 'ndjson': + return { 'content-type': 'application/x-ndjson' } + case 'binary': + return { 'content-type': 'application/octet-stream' } + default: + return req.body !== undefined ? { 'content-type': 'application/json' } : {} + } +} + +const responseAccept = (req: StreamTransportRequest): Record => { + switch (req.responseStreamEncoding) { + case 'ndjson': + return { accept: 'application/x-ndjson' } + case 'binary': + return { accept: 'application/octet-stream' } + default: + return {} + } +} + +/** Build the `fetch` body for a (possibly streaming) request. + * + * A streamed request body needs `duplex: 'half'` and is only supported over + * HTTP/2 in Chromium; Firefox and Safari don't support it at all. Rather than + * fail there, we buffer the stream into a single body — correct, just not + * incremental. `streamRequests` is decided once per call via feature + * detection. + */ +const streamBody = async ( + req: StreamTransportRequest, +): Promise<{ body: BodyInit | undefined; duplex: boolean }> => { + if (req.requestStreamEncoding === undefined || req.stream === undefined) { + return { + body: req.body !== undefined ? JSON.stringify(req.body) : undefined, + duplex: false, + } + } + + const framed = + req.requestStreamEncoding === 'ndjson' ? encodeNdjson(req.stream) : encodeBinary(req.stream) + + if (supportsRequestStreams()) { + return { body: asyncIterableToReadable(framed), duplex: true } + } + const buffered = await collectBytes(framed) + return { body: buffered as unknown as BodyInit, duplex: false } +} + +/** Whether this platform can send a `ReadableStream` as a request body. + * Detected the way the platform docs prescribe: constructing a `Request` with a + * stream body throws (or never reads `duplex`) where it isn't supported. The + * result is cached — it can't change within a page. */ +let requestStreamSupport: boolean | undefined + +const supportsRequestStreams = (): boolean => { + if (requestStreamSupport !== undefined) return requestStreamSupport + if (typeof Request === 'undefined' || typeof ReadableStream === 'undefined') { + requestStreamSupport = false + return false + } + let duplexAccessed = false + try { + const probe = new Request('https://example.invalid', { + method: 'POST', + body: new ReadableStream(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get duplex() { + duplexAccessed = true + return 'half' + }, + } as RequestInit) + // If `duplex` was read and no content-type was inferred, streaming is live. + requestStreamSupport = duplexAccessed && !probe.headers.has('content-type') + } catch { + requestStreamSupport = false + } + return requestStreamSupport +} diff --git a/runtime/src/index.ts b/runtime/src/index.ts new file mode 100644 index 0000000..cfbcb46 --- /dev/null +++ b/runtime/src/index.ts @@ -0,0 +1,55 @@ +/** Out-of-the-box transports for clients generated by `smithy-ts-codegen`. + * + * The generated file stays self-contained — it declares its own `Transport` / + * `StreamTransport` interfaces and imports nothing from here. This package's + * types are structural copies of those, so a transport built here satisfies a + * generated client by shape: + * + * ```ts + * import { fetchTransport } from '@polyvariant/smithy-ts-runtime' + * import { DirectoryClient, FeedClient } from './generated.js' + * + * const transport = fetchTransport({ baseUrl: '/api' }) + * const directory = new DirectoryClient(transport) + * const feed = new FeedClient(transport, transport) // streaming operations + * ``` + */ + +export { fetchTransport } from './fetch.js' +export type { FetchTransportOptions } from './fetch.js' + +export { + around, + aroundStream, + chain, + chainStream, + interceptorStack, + mapRequest, + tap, + withHeaders, +} from './middleware.js' +export type { Middleware, StreamMiddleware } from './middleware.js' + +export { + asyncIterableToReadable, + collectBytes, + decodeNdjson, + encodeBinary, + encodeNdjson, + readableToAsyncIterable, +} from './ndjson.js' + +export { NdjsonParseError, StreamRequestError, UnauthenticatedError } from './errors.js' + +export type { + FullTransport, + HttpMethod, + StreamEncoding, + StreamTransport, + StreamTransportRequest, + StreamTransportResponse, + Transport, + TransportOptions, + TransportRequest, + TransportResponse, +} from './types.js' diff --git a/runtime/src/middleware.ts b/runtime/src/middleware.ts new file mode 100644 index 0000000..ccad4bf --- /dev/null +++ b/runtime/src/middleware.ts @@ -0,0 +1,152 @@ +import type { + StreamTransport, + StreamTransportRequest, + StreamTransportResponse, + Transport, + TransportRequest, + TransportResponse, +} from './types.js' + +/** Wraps a transport, returning a new one. Compose with {@link chain}. */ +export type Middleware = (inner: Transport) => Transport +export type StreamMiddleware = (inner: StreamTransport) => StreamTransport + +/** Apply middleware left-to-right, so the first entry is the outermost layer + * and sees the request first: + * + * ```ts + * chain(transport, tracing, retry) // tracing wraps retry wraps transport + * ``` + */ +export const chain = (base: Transport, ...layers: Middleware[]): Transport => + layers.reduceRight((inner, layer) => layer(inner), base) + +export const chainStream = ( + base: StreamTransport, + ...layers: StreamMiddleware[] +): StreamTransport => layers.reduceRight((inner, layer) => layer(inner), base) + +/** Middleware from a function that wraps the call. The building block for + * tracing, logging, timing — anything that needs the operation name and the + * chance to run code around the request: + * + * ```ts + * const tracing = around((req, next) => withSpan(req.operation, next)) + * ``` + */ +export const around = + ( + f: (req: TransportRequest, next: () => Promise) => Promise, + ): Middleware => + (inner) => ({ + request: (req) => f(req, () => inner.request(req)), + }) + +export const aroundStream = + ( + f: ( + req: StreamTransportRequest, + next: () => Promise, + ) => Promise, + ): StreamMiddleware => + (inner) => ({ + requestStream: (req) => f(req, () => inner.requestStream(req)), + }) + +/** Rewrite each request before it goes out — add auth headers, stamp a + * correlation id, rewrite the URL. `f` may be async (e.g. to await a token). */ +export const mapRequest = + (f: (req: TransportRequest) => TransportRequest | Promise): Middleware => + (inner) => ({ + request: async (req) => inner.request(await f(req)), + }) + +/** Merge headers into every request. Header names should be lowercase. + * `headers` is re-evaluated per request when it's a function, so a rotating + * token is picked up without rebuilding the transport. */ +export const withHeaders = ( + headers: Record | (() => Record | Promise>), +): Middleware => + mapRequest(async (req) => ({ + ...req, + // Per-request headers win: they come from the operation's `@httpHeader` + // members, which are part of the contract. + headers: { ...(typeof headers === 'function' ? await headers() : headers), ...(req.headers ?? {}) }, + })) + +/** Observe every outcome without changing it — the fetch-side equivalent of an + * a response interceptor. `onResponse` sees *every* response including + * non-2xx (this layer never converts a status into a rejection), and `onError` + * sees genuine failures: network errors, and anything a lower layer threw + * (e.g. `UnauthenticatedError`). Both are re-thrown/returned unchanged. + */ +export const tap = (handlers: { + onRequest?: (req: TransportRequest) => void + onResponse?: (res: TransportResponse, req: TransportRequest) => void + onError?: (err: unknown, req: TransportRequest) => void +}): Middleware => + around(async (req, next) => { + handlers.onRequest?.(req) + try { + const res = await next() + handlers.onResponse?.(res, req) + return res + } catch (err) { + handlers.onError?.(err, req) + throw err + } + }) + +/** A mutable stack of `tap` handlers that can be added and removed after the + * transport is built — the register/unregister pair a response-interceptor + * registry provides, and what a React effect needs when the handler closes + * over component state. + * + * ```ts + * const interceptors = interceptorStack() + * const transport = chain(fetchTransport(...), interceptors.middleware) + * + * useEffect(() => interceptors.use({ onError: err => setPopup(err) }), []) + * ``` + * + * `use` returns its own remover, so it can be returned directly as an effect + * cleanup. Handlers run in registration order. + */ +export const interceptorStack = (): { + middleware: Middleware + use: (handlers: { + onRequest?: (req: TransportRequest) => void + onResponse?: (res: TransportResponse, req: TransportRequest) => void + onError?: (err: unknown, req: TransportRequest) => void + }) => () => void +} => { + type Handlers = { + onRequest?: (req: TransportRequest) => void + onResponse?: (res: TransportResponse, req: TransportRequest) => void + onError?: (err: unknown, req: TransportRequest) => void + } + const registered = new Set() + + return { + middleware: around(async (req, next) => { + // Snapshot: a handler that ejects itself mid-flight shouldn't perturb + // the iteration for this request. + const current = [...registered] + for (const h of current) h.onRequest?.(req) + try { + const res = await next() + for (const h of current) h.onResponse?.(res, req) + return res + } catch (err) { + for (const h of current) h.onError?.(err, req) + throw err + } + }), + use: (handlers) => { + registered.add(handlers) + return () => { + registered.delete(handlers) + } + }, + } +} diff --git a/runtime/src/ndjson.ts b/runtime/src/ndjson.ts new file mode 100644 index 0000000..429e70d --- /dev/null +++ b/runtime/src/ndjson.ts @@ -0,0 +1,128 @@ +import { NdjsonParseError } from './errors.js' + +/** Framing for the two encodings the protocol defines. Both directions are + * handled here so a transport only has to move bytes. + * + * These are exported because a transport this package doesn't ship (a + * WebSocket bridge, a test double, a Node `http` client) still needs exactly + * this framing to interoperate with the generated clients. + */ + +/** Serialise values as ndjson: one `JSON.stringify` per element, each + * newline-terminated. The reverse of {@link decodeNdjson}. */ +export const encodeNdjson = async function* ( + source: AsyncIterable, +): AsyncGenerator { + const encoder = new TextEncoder() + for await (const element of source) { + yield encoder.encode(JSON.stringify(element) + '\n') + } +} + +/** Pass `Uint8Array` chunks through unchanged. Chunk boundaries carry no + * meaning in either direction, so binary framing is the identity — it exists + * so both encodings can be handled uniformly. */ +export const encodeBinary = async function* ( + source: AsyncIterable, +): AsyncGenerator { + for await (const chunk of source) { + yield chunk as Uint8Array + } +} + +/** Split a byte stream on newlines and `JSON.parse` each non-empty line, + * lazily — one line is decoded per pull, so a long-lived stream never buffers + * more than the partial line in flight. + * + * A line that isn't valid JSON throws {@link NdjsonParseError} at that line + * rather than truncating the stream silently. Schema validation happens a layer + * up, in the generated client's `decodeStream`. + */ +export const decodeNdjson = async function* ( + source: AsyncIterable, + operation: string, +): AsyncGenerator { + const decoder = new TextDecoder() + let buffer = '' + + const parse = (line: string): unknown => { + try { + return JSON.parse(line) + } catch (err) { + throw new NdjsonParseError(operation, line, err) + } + } + + for await (const chunk of source) { + buffer += decoder.decode(chunk, { stream: true }) + let newlineAt = buffer.indexOf('\n') + while (newlineAt >= 0) { + const line = buffer.slice(0, newlineAt) + buffer = buffer.slice(newlineAt + 1) + // Tolerate CRLF, and skip blank lines (heartbeats often ride as those). + const trimmed = line.endsWith('\r') ? line.slice(0, -1) : line + if (trimmed !== '') yield parse(trimmed) + newlineAt = buffer.indexOf('\n') + } + } + // Flush whatever the decoder held back, then the last unterminated line — + // a server that ends the body without a trailing newline still delivers it. + buffer += decoder.decode() + const rest = buffer.endsWith('\r') ? buffer.slice(0, -1) : buffer + if (rest !== '') yield parse(rest) +} + +/** Read a `ReadableStream` (what `fetch` gives you) as an + * `AsyncIterable`. Node 18+ and Deno make `ReadableStream` itself async + * iterable, but browsers still don't, so we go through the reader. The reader + * is released when iteration ends for any reason, including `break`. */ +export const readableToAsyncIterable = async function* ( + body: ReadableStream, +): AsyncGenerator { + const reader = body.getReader() + try { + for (;;) { + const { value, done } = await reader.read() + if (done) break + if (value !== undefined) yield value + } + } finally { + reader.releaseLock() + } +} + +/** Turn an `AsyncIterable` into a `ReadableStream` for `fetch`'s + * `body`. Used only where the platform can't take an async iterable directly. */ +export const asyncIterableToReadable = ( + source: AsyncIterable, +): ReadableStream => { + const iterator = source[Symbol.asyncIterator]() + return new ReadableStream({ + async pull(controller) { + const { value, done } = await iterator.next() + if (done) controller.close() + else controller.enqueue(value) + }, + async cancel(reason) { + await iterator.return?.(reason) + }, + }) +} + +/** Concatenate a byte stream into one `Uint8Array` — the fallback for a + * platform that won't stream a request body (see `duplex` support). */ +export const collectBytes = async (source: AsyncIterable): Promise => { + const chunks: Uint8Array[] = [] + let total = 0 + for await (const chunk of source) { + chunks.push(chunk) + total += chunk.length + } + const out = new Uint8Array(total) + let at = 0 + for (const chunk of chunks) { + out.set(chunk, at) + at += chunk.length + } + return out +} diff --git a/runtime/src/types.ts b/runtime/src/types.ts new file mode 100644 index 0000000..2017a4d --- /dev/null +++ b/runtime/src/types.ts @@ -0,0 +1,88 @@ +/** The transport contract the generated clients are abstract over. + * + * These declarations are structural copies of what `smithy-ts-codegen` emits + * into every `generated.ts`. They are deliberately *not* imported from the + * generated file: this package has no idea which model you generated from, and + * a generated file stays self-contained. TypeScript matches them by shape, so + * a transport built here satisfies the generated `Transport` interface without + * either side knowing about the other. + * + * Keep them in sync with the codegen's emitted transport block. + */ + +/** Per-operation transport options, threaded straight through by the generated + * client without inspection. Concrete transports and middleware read whatever + * keys they define (e.g. a `skipErrorPopup` flag read by an error-reporting + * middleware). */ +export interface TransportOptions { + [key: string]: unknown +} + +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + +export interface TransportRequest { + /** `.` — stable per operation. Middleware can use + * it to name a span; the HTTP layer ignores it. */ + operation: string + method: HttpMethod + url: string + query?: Record + headers?: Record + body?: unknown + options?: TransportOptions +} + +export interface TransportResponse { + status: number + body: unknown + /** Lowercased header names → values: the generated client's error dispatch + * looks up `x-error-type` in lowercase. */ + headers: Record +} + +export interface Transport { + request(req: TransportRequest): Promise +} + +/** How a streaming direction is framed on the wire. Derived by the generator + * from the shape of the `@streaming` member, so a transport never guesses: + * + * - `'ndjson'` — one JSON value per line (`application/x-ndjson`). + * - `'binary'` — the body verbatim (`application/octet-stream`), as + * `Uint8Array` chunks whose boundaries carry no meaning. + */ +export type StreamEncoding = 'ndjson' | 'binary' + +export interface StreamTransportRequest extends TransportRequest { + /** Framing for the request body when it streams; `undefined` means an + * ordinary JSON body carried by `body`. */ + requestStreamEncoding?: StreamEncoding + /** Framing the response body is expected in when it streams; `undefined` + * means an ordinary JSON response body. */ + responseStreamEncoding?: StreamEncoding + /** The outgoing stream, present exactly when `requestStreamEncoding` is. + * Overrides `body`. */ + stream?: AsyncIterable +} + +export interface StreamTransportResponse { + status: number + headers: Record + /** The deframed response body — present exactly when the operation streams + * its output and the status was 2xx. Already `JSON.parse`d for `'ndjson'`, + * raw `Uint8Array` chunks for `'binary'`. The generated client validates + * ndjson elements against the operation's schema. */ + stream?: AsyncIterable + /** The fully-read body, for a non-2xx response (so declared errors can be + * parsed and thrown) or an operation with a unary response body. */ + body?: unknown +} + +export interface StreamTransport { + requestStream(req: StreamTransportRequest): Promise +} + +/** A transport that serves both unary and streaming operations. The generated + * client takes whichever halves it needs, so one object satisfying this can be + * passed to every client in a model. */ +export interface FullTransport extends Transport, StreamTransport {} diff --git a/runtime/test/fetch.test.ts b/runtime/test/fetch.test.ts new file mode 100644 index 0000000..756c109 --- /dev/null +++ b/runtime/test/fetch.test.ts @@ -0,0 +1,228 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { fetchTransport } from '../dist/fetch.js' +import { UnauthenticatedError } from '../dist/errors.js' +import type { TransportRequest } from '../dist/types.js' + +type Call = { url: string; init: RequestInit } + +/** A `fetch` double recording what it was called with. */ +const fakeFetch = ( + respond: (call: Call) => Response, +): { fetch: typeof globalThis.fetch; calls: Call[] } => { + const calls: Call[] = [] + return { + calls, + fetch: (async (input: RequestInfo | URL, init: RequestInit = {}) => { + const call = { url: String(input), init } + calls.push(call) + return respond(call) + }) as typeof globalThis.fetch, + } +} + +const req = (over: Partial = {}): TransportRequest => ({ + operation: 'DirectoryClient.getPerson', + method: 'GET', + url: '/people/abc', + ...over, +}) + +const json = (status: number, body: unknown, headers: Record = {}): Response => + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }) + +test('joins baseUrl and builds a query string, dropping undefined', async () => { + const f = fakeFetch(() => json(200, { ok: true })) + const t = fetchTransport({ baseUrl: '/api', fetch: f.fetch }) + await t.request(req({ query: { verbose: true, limit: 10, skip: undefined } })) + assert.equal(f.calls[0]!.url, '/api/people/abc?verbose=true&limit=10') +}) + +test('a trailing slash on baseUrl does not double up', async () => { + const f = fakeFetch(() => json(200, {})) + await fetchTransport({ baseUrl: '/api/', fetch: f.fetch }).request(req()) + assert.equal(f.calls[0]!.url, '/api/people/abc') +}) + +test('lowercases response header names', async () => { + const f = fakeFetch(() => json(409, {}, { 'X-Error-Type': 'Conflict' })) + const res = await fetchTransport({ fetch: f.fetch }).request(req()) + assert.equal(res.headers['x-error-type'], 'Conflict') +}) + +test('a non-2xx status is returned, not thrown, so declared errors can dispatch', async () => { + const f = fakeFetch(() => json(404, { message: 'nope' })) + const res = await fetchTransport({ fetch: f.fetch }).request(req()) + assert.equal(res.status, 404) + assert.deepEqual(res.body, { message: 'nope' }) +}) + +test('401 throws UnauthenticatedError carrying the operation', async () => { + const f = fakeFetch(() => json(401, {})) + await assert.rejects( + () => fetchTransport({ fetch: f.fetch }).request(req()), + (err: unknown) => + err instanceof UnauthenticatedError && err.operation === 'DirectoryClient.getPerson', + ) +}) + +test('unauthenticated: false leaves the 401 as a response', async () => { + const f = fakeFetch(() => json(401, {})) + const res = await fetchTransport({ fetch: f.fetch, unauthenticated: false }).request(req()) + assert.equal(res.status, 401) +}) + +test('unauthenticated as a function throws the caller-supplied error', async () => { + class Generated extends Error {} + const f = fakeFetch(() => json(401, {})) + await assert.rejects( + () => fetchTransport({ fetch: f.fetch, unauthenticated: () => new Generated() }).request(req()), + Generated, + ) +}) + +test('an empty body decodes as undefined, not a JSON error', async () => { + // 204 must carry a null body — that is exactly the case an operation with no + // output members produces. + const f = fakeFetch(() => new Response(null, { status: 204 })) + const res = await fetchTransport({ fetch: f.fetch }).request(req()) + assert.equal(res.status, 204) + assert.equal(res.body, undefined) +}) + +test('a 200 with an empty body also decodes as undefined', async () => { + const f = fakeFetch(() => new Response('', { status: 200 })) + const res = await fetchTransport({ fetch: f.fetch }).request(req()) + assert.equal(res.body, undefined) +}) + +test('a non-JSON error body is surfaced as text rather than throwing', async () => { + const f = fakeFetch(() => new Response('502', { status: 502 })) + const res = await fetchTransport({ fetch: f.fetch }).request(req()) + assert.equal(res.status, 502) + assert.equal(res.body, '502') +}) + +test('per-request headers win over the transport defaults', async () => { + const f = fakeFetch(() => json(200, {})) + const t = fetchTransport({ fetch: f.fetch, headers: { 'x-tenant': 'default', 'x-app': 'a' } }) + await t.request(req({ headers: { 'x-tenant': 'override' } })) + const sent = f.calls[0]!.init.headers as Record + assert.equal(sent['x-tenant'], 'override') + assert.equal(sent['x-app'], 'a') +}) + +test('a body is JSON-encoded and content-type set', async () => { + const f = fakeFetch(() => json(200, {})) + await fetchTransport({ fetch: f.fetch }).request( + req({ method: 'POST', body: { name: 'x' } }), + ) + const init = f.calls[0]!.init + assert.equal(init.body, '{"name":"x"}') + assert.equal((init.headers as Record)['content-type'], 'application/json') +}) + +test('a GET sends no body and no content-type', async () => { + const f = fakeFetch(() => json(200, {})) + await fetchTransport({ fetch: f.fetch }).request(req()) + const init = f.calls[0]!.init + assert.equal(init.body, undefined) + assert.equal((init.headers as Record)['content-type'], undefined) +}) + +// --- streaming --- + +const ndjsonResponse = (lines: unknown[]): Response => + new Response(lines.map((l) => JSON.stringify(l) + '\n').join(''), { + status: 200, + headers: { 'content-type': 'application/x-ndjson' }, + }) + +test('an ndjson response is deframed into parsed elements', async () => { + const f = fakeFetch(() => ndjsonResponse([{ item: 1 }, { completed: {} }])) + const res = await fetchTransport({ fetch: f.fetch }).requestStream( + { ...req({ method: 'POST' }), responseStreamEncoding: 'ndjson' }, + ) + const got: unknown[] = [] + for await (const element of res.stream!) got.push(element) + assert.deepEqual(got, [{ item: 1 }, { completed: {} }]) +}) + +test('a streamed response asks for the framing it expects', async () => { + const f = fakeFetch(() => ndjsonResponse([])) + await fetchTransport({ fetch: f.fetch }).requestStream({ + ...req({ method: 'POST' }), + responseStreamEncoding: 'ndjson', + }) + assert.equal((f.calls[0]!.init.headers as Record)['accept'], 'application/x-ndjson') +}) + +test('a binary response is passed through as chunks', async () => { + const f = fakeFetch(() => new Response(new Uint8Array([1, 2, 3]), { status: 200 })) + const res = await fetchTransport({ fetch: f.fetch }).requestStream({ + ...req({ method: 'POST' }), + responseStreamEncoding: 'binary', + }) + const chunks: Uint8Array[] = [] + for await (const chunk of res.stream as AsyncIterable) chunks.push(chunk) + assert.deepEqual(Array.from(chunks.flatMap((c) => Array.from(c))), [1, 2, 3]) +}) + +test('a streamed request sends ndjson and its content-type', async () => { + const f = fakeFetch(() => json(200, { stored: 2 })) + const outgoing = (async function* () { + yield { a: 1 } + yield { a: 2 } + })() + await fetchTransport({ fetch: f.fetch }).requestStream({ + ...req({ method: 'POST' }), + requestStreamEncoding: 'ndjson', + stream: outgoing, + }) + const init = f.calls[0]!.init + assert.equal( + (init.headers as Record)['content-type'], + 'application/x-ndjson', + ) + const sent = await new Response(init.body as BodyInit).text() + assert.equal(sent, '{"a":1}\n{"a":2}\n') +}) + +test('a failure before the stream starts returns status and body, with no stream', async () => { + const f = fakeFetch(() => json(409, { message: 'conflict' })) + const res = await fetchTransport({ fetch: f.fetch }).requestStream({ + ...req({ method: 'POST' }), + responseStreamEncoding: 'ndjson', + }) + assert.equal(res.status, 409) + assert.deepEqual(res.body, { message: 'conflict' }) + assert.equal(res.stream, undefined) +}) + +test('a streaming op with a unary response returns a body, not a stream', async () => { + const f = fakeFetch(() => json(200, { stored: 3 })) + const res = await fetchTransport({ fetch: f.fetch }).requestStream({ + ...req({ method: 'POST' }), + requestStreamEncoding: 'binary', + stream: (async function* () { + yield new Uint8Array([1]) + })(), + }) + assert.deepEqual(res.body, { stored: 3 }) + assert.equal(res.stream, undefined) +}) + +test('401 on a streaming request throws too', async () => { + const f = fakeFetch(() => json(401, {})) + await assert.rejects( + () => + fetchTransport({ fetch: f.fetch }).requestStream({ + ...req({ method: 'POST' }), + responseStreamEncoding: 'ndjson', + }), + UnauthenticatedError, + ) +}) diff --git a/runtime/test/middleware.test.ts b/runtime/test/middleware.test.ts new file mode 100644 index 0000000..b425386 --- /dev/null +++ b/runtime/test/middleware.test.ts @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { around, chain, interceptorStack, mapRequest, tap, withHeaders } from '../dist/middleware.js' +import type { Transport, TransportRequest, TransportResponse } from '../dist/types.js' + +const ok = (body: unknown = {}): TransportResponse => ({ status: 200, body, headers: {} }) + +const recording = (): { transport: Transport; seen: TransportRequest[] } => { + const seen: TransportRequest[] = [] + return { + seen, + transport: { + request: async (req) => { + seen.push(req) + return ok() + }, + }, + } +} + +const req = (over: Partial = {}): TransportRequest => ({ + operation: 'S.op', + method: 'GET', + url: '/x', + ...over, +}) + +test('chain applies layers outermost-first', async () => { + const order: string[] = [] + const layer = (name: string) => + around(async (_r, next) => { + order.push('enter ' + name) + const res = await next() + order.push('exit ' + name) + return res + }) + const base = recording() + await chain(base.transport, layer('a'), layer('b')).request(req()) + assert.deepEqual(order, ['enter a', 'enter b', 'exit b', 'exit a']) +}) + +test('withHeaders merges defaults but per-request headers win', async () => { + const base = recording() + const t = chain(base.transport, withHeaders({ authorization: 'Bearer t', 'x-a': '1' })) + await t.request(req({ headers: { authorization: 'Bearer override' } })) + assert.deepEqual(base.seen[0]!.headers, { authorization: 'Bearer override', 'x-a': '1' }) +}) + +test('withHeaders re-evaluates a function per request', async () => { + let token = 'first' + const base = recording() + const t = chain(base.transport, withHeaders(() => ({ authorization: token }))) + await t.request(req()) + token = 'second' + await t.request(req()) + assert.equal(base.seen[0]!.headers!['authorization'], 'first') + assert.equal(base.seen[1]!.headers!['authorization'], 'second') +}) + +test('mapRequest can rewrite the url', async () => { + const base = recording() + await chain(base.transport, mapRequest((r) => ({ ...r, url: '/v2' + r.url }))).request(req()) + assert.equal(base.seen[0]!.url, '/v2/x') +}) + +test('tap observes non-2xx responses without converting them to errors', async () => { + const seen: number[] = [] + const base: Transport = { request: async () => ({ status: 500, body: {}, headers: {} }) } + const res = await chain(base, tap({ onResponse: (r) => seen.push(r.status) })).request(req()) + assert.deepEqual(seen, [500]) + assert.equal(res.status, 500) +}) + +test('tap observes a thrown error and rethrows it', async () => { + const boom = new Error('network') + const base: Transport = { + request: () => Promise.reject(boom), + } + const seen: unknown[] = [] + await assert.rejects( + () => chain(base, tap({ onError: (err) => seen.push(err) })).request(req()), + (err) => err === boom, + ) + assert.deepEqual(seen, [boom]) +}) + +test('tap sees the per-operation options blob the codegen threads through', async () => { + const base = recording() + let skipped: unknown + await chain( + base.transport, + tap({ onRequest: (r) => (skipped = r.options?.['skipErrorPopup']) }), + ).request(req({ options: { skipErrorPopup: true } })) + assert.equal(skipped, true) +}) + +test('interceptorStack handlers fire, and stop after their remover runs', async () => { + const base = recording() + const interceptors = interceptorStack() + const t = chain(base.transport, interceptors.middleware) + + const seen: string[] = [] + const remove = interceptors.use({ onResponse: () => seen.push('hit') }) + await t.request(req()) + remove() + await t.request(req()) + assert.deepEqual(seen, ['hit']) +}) + +test('interceptorStack runs handlers in registration order', async () => { + const base = recording() + const interceptors = interceptorStack() + const t = chain(base.transport, interceptors.middleware) + const seen: string[] = [] + interceptors.use({ onResponse: () => seen.push('first') }) + interceptors.use({ onResponse: () => seen.push('second') }) + await t.request(req()) + assert.deepEqual(seen, ['first', 'second']) +}) + +test('a handler ejecting itself mid-request does not disturb that request', async () => { + const base = recording() + const interceptors = interceptorStack() + const t = chain(base.transport, interceptors.middleware) + const seen: string[] = [] + const remove = interceptors.use({ + onRequest: () => { + seen.push('request') + remove() + }, + onResponse: () => seen.push('response'), + }) + await t.request(req()) + assert.deepEqual(seen, ['request', 'response']) +}) diff --git a/runtime/test/ndjson.test.ts b/runtime/test/ndjson.test.ts new file mode 100644 index 0000000..90cea90 --- /dev/null +++ b/runtime/test/ndjson.test.ts @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + asyncIterableToReadable, + collectBytes, + decodeNdjson, + encodeNdjson, + readableToAsyncIterable, +} from '../dist/ndjson.js' +import { NdjsonParseError } from '../dist/errors.js' + +const bytes = (...parts: string[]): AsyncGenerator => { + const encoder = new TextEncoder() + return (async function* () { + for (const part of parts) yield encoder.encode(part) + })() +} + +const collect = async (source: AsyncIterable): Promise => { + const out: T[] = [] + for await (const element of source) out.push(element) + return out +} + +test('decodes one value per line', async () => { + const got = await collect(decodeNdjson(bytes('{"a":1}\n{"a":2}\n'), 'Op.m')) + assert.deepEqual(got, [{ a: 1 }, { a: 2 }]) +}) + +test('reassembles a value split across chunks', async () => { + const got = await collect(decodeNdjson(bytes('{"a"', ':1}\n{"b":', '2}\n'), 'Op.m')) + assert.deepEqual(got, [{ a: 1 }, { b: 2 }]) +}) + +test('emits a final line with no trailing newline', async () => { + const got = await collect(decodeNdjson(bytes('{"a":1}\n{"a":2}'), 'Op.m')) + assert.deepEqual(got, [{ a: 1 }, { a: 2 }]) +}) + +test('skips blank lines and tolerates CRLF', async () => { + const got = await collect(decodeNdjson(bytes('{"a":1}\r\n\r\n{"a":2}\r\n'), 'Op.m')) + assert.deepEqual(got, [{ a: 1 }, { a: 2 }]) +}) + +test('reassembles a multi-byte character split across chunks', async () => { + // 'ł' is two bytes; cut between them. + const encoded = new TextEncoder().encode('{"a":"ł"}\n') + const source = (async function* () { + yield encoded.slice(0, 7) + yield encoded.slice(7) + })() + assert.deepEqual(await collect(decodeNdjson(source, 'Op.m')), [{ a: 'ł' }]) +}) + +test('a malformed line throws at that line, after earlier ones were yielded', async () => { + const iterator = decodeNdjson(bytes('{"a":1}\nnot json\n'), 'Op.m')[Symbol.asyncIterator]() + assert.deepEqual((await iterator.next()).value, { a: 1 }) + await assert.rejects(() => iterator.next(), NdjsonParseError) +}) + +test('decoding is lazy — nothing is read before the first pull', async () => { + let pulled = 0 + const source = (async function* () { + const encoder = new TextEncoder() + for (const line of ['{"a":1}\n', '{"a":2}\n']) { + pulled++ + yield encoder.encode(line) + } + })() + const iterator = decodeNdjson(source, 'Op.m')[Symbol.asyncIterator]() + assert.equal(pulled, 0) + await iterator.next() + assert.equal(pulled, 1) +}) + +test('encode is the inverse of decode', async () => { + const values = [{ a: 1 }, { b: 'two' }, { c: [3] }] + const round = await collect( + decodeNdjson( + encodeNdjson( + (async function* () { + for (const v of values) yield v + })(), + ), + 'Op.m', + ), + ) + assert.deepEqual(round, values) +}) + +test('ReadableStream round-trips through both adapters', async () => { + const source = bytes('a', 'bc') + const back = await collectBytes(readableToAsyncIterable(asyncIterableToReadable(source))) + assert.equal(new TextDecoder().decode(back), 'abc') +}) diff --git a/runtime/tsconfig.build.json b/runtime/tsconfig.build.json new file mode 100644 index 0000000..4c23b88 --- /dev/null +++ b/runtime/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + // The published artifact must not depend on @types/node — it targets the + // browser as much as the server. + "types": [] + }, + "include": ["src/**/*.ts"] +} diff --git a/runtime/tsconfig.json b/runtime/tsconfig.json new file mode 100644 index 0000000..85e63d7 --- /dev/null +++ b/runtime/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + // Matches typecheck/tsconfig.json: the library is checked under at least as + // strict a regime as the generated code it has to satisfy. + "strict": true, + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/typecheck/package.json b/typecheck/package.json index c31f22e..775eff5 100644 --- a/typecheck/package.json +++ b/typecheck/package.json @@ -8,6 +8,7 @@ "typecheck": "tsc -p tsconfig.json" }, "dependencies": { + "@polyvariant/smithy-ts-runtime": "workspace:*", "zod": "^4.4.3" }, "devDependencies": { diff --git a/typecheck/pnpm-lock.yaml b/typecheck/pnpm-lock.yaml deleted file mode 100644 index a12f968..0000000 --- a/typecheck/pnpm-lock.yaml +++ /dev/null @@ -1,33 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - zod: - specifier: ^4.4.3 - version: 4.4.3 - devDependencies: - typescript: - specifier: ^5.9.3 - version: 5.9.3 - -packages: - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} - -snapshots: - - typescript@5.9.3: {} - - zod@4.4.3: {} diff --git a/typecheck/src/runtimeUsage.ts b/typecheck/src/runtimeUsage.ts new file mode 100644 index 0000000..62565be --- /dev/null +++ b/typecheck/src/runtimeUsage.ts @@ -0,0 +1,91 @@ +// Proof that `@polyvariant/smithy-ts-runtime` satisfies the *generated* +// transport interfaces. +// +// The library declares its own structural copies of `Transport` / +// `StreamTransport` and imports nothing from generated code. That only works +// as long as the two stay shape-compatible — which is precisely what this file +// checks, against the real generated.ts rather than a hand-written stand-in. +// +// If the codegen changes the transport contract and the library isn't updated +// to match, this file stops compiling. That is the intended failure. + +import { + chain, + fetchTransport, + interceptorStack, + tap, + withHeaders, +} from '@polyvariant/smithy-ts-runtime' +import { + type StreamTransport, + type Transport, + DirectoryClient, + FeedClient, + UnauthenticatedError, +} from './generated.js' + +// --- The library's transport is assignable to the generated interfaces --- + +const base = fetchTransport({ baseUrl: '/api' }) + +const transport: Transport = base +const streamTransport: StreamTransport = base + +// --- ...and drives the generated clients, unary and streaming alike --- + +export const unary = async (): Promise => { + const directory = new DirectoryClient(transport) + const { person } = await directory.getPerson({ id: 'abc', verbose: true }) + return person.name +} + +export const streaming = async (): Promise => { + const feed = new FeedClient(transport, streamTransport) + const { events } = await feed.watch({ id: 'abc', since: new Date() }) + let seen = 0 + for await (const event of events) { + if ('item' in event) seen++ + } + + const bytes = async function* (): AsyncGenerator { + yield new Uint8Array([1, 2, 3]) + } + await feed.upload({ id: 'abc', body: bytes() }) + return seen +} + +// --- Middleware composes and still yields a generated `Transport` --- + +const interceptors = interceptorStack() + +export const layered: Transport = chain( + fetchTransport({ baseUrl: '/api' }), + interceptors.middleware, + withHeaders(() => ({ 'x-session-id': 'abc' })), + tap({ + onResponse: (res) => { + void res.status + }, + onError: (err) => { + void err + }, + }), +) + +// A handler registered after the fact, the way a React effect would — `use` +// returns its own remover, usable directly as the effect cleanup. +export const subscribe = (onFailure: (message: string) => void): (() => void) => + interceptors.use({ + onError: (err) => { + onFailure(err instanceof Error ? err.message : String(err)) + }, + }) + +// --- 401 can be mapped to the *generated* error class --- +// +// The library throws its own `UnauthenticatedError` by default; a project whose +// call sites check the generated class passes that class in instead. +export const withGeneratedAuthError: Transport = fetchTransport({ + baseUrl: '/api', + unauthenticated: (operation) => new UnauthenticatedError(operation), +}) From 89b0042e9b45d06397efd7909faacde88ce60154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Wed, 19 Aug 2026 16:52:33 +0200 Subject: [PATCH 2/2] Use npm trusted publishing instead of a stored token OIDC means no NPM_TOKEN secret to create, store or rotate. The publish step moves from pnpm to npm because the OIDC exchange is implemented in the npm CLI; everything before it still runs under pnpm. Co-Authored-By: Claude Opus 5 --- .github/workflows/npm-publish.yml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 95915dc..0c0cb0e 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -30,8 +30,10 @@ jobs: runs-on: ubuntu-22.04 permissions: contents: read - # Lets npm record the build's provenance, so consumers can verify the - # tarball was built from this commit by this workflow. + # Trusted publishing: npm exchanges this workflow's OIDC identity for a + # short-lived publish credential, so there is no token to store or + # rotate. It also backs the provenance attestation, which lets consumers + # verify the tarball was built from this commit by this workflow. id-token: write steps: - name: Checkout @@ -71,8 +73,12 @@ jobs: working-directory: runtime run: pnpm version "${{ steps.version.outputs.version }}" --no-git-tag-version --allow-same-version + # `npm publish`, not `pnpm publish`: the OIDC exchange that backs trusted + # publishing is implemented in the npm CLI. `--provenance` is implied by + # trusted publishing, but stays explicit so a fallback to a token still + # attests. npm >= 11.5.1 is required for OIDC. - name: Publish working-directory: runtime - run: pnpm publish --access public --no-git-checks --provenance - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + npm install -g npm@latest + npm publish --access public --provenance