Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/2026-08-25-body-lifecycle-review-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@dexpace/core": minor
---

Body lifecycle review fixes.

Security:

- Body media types are validated as header-safe at construction (`byteArrayBody`, `stringBody`, `streamBody`, and every part rendered into a multipart body), using the same predicate as outbound header-value validation (HTTP-26). A CR/LF in a media type was previously interpolated verbatim into a multipart part header, which allowed arbitrary header injection, arbitrary part content, and a forged closing boundary while the declared content length still matched the corrupted bytes (HTTP-51).
- `StreamBody.writeTo` now refuses a chunk that would carry the body past its declared `contentLength` *before* writing it, and aborts the sink rather than closing it on any length mismatch. Overrun bytes previously reached the sink and were reported only afterwards, leaving them on the socket behind a stamped `Content-Length` (HTTP-39/BODY-10).

Correctness:

- A body write failure is no longer masked by the close that follows it. All five `Body` implementations share one writer scope that aborts on failure and never lets a close error replace the primary one (RECOV-12), so retry classification still sees the I/O failure in the cause chain (RETRY-2).
- `TypedResponse.value()` memoizes a parser that throws synchronously; it previously re-ran the handler and re-read the single-use body (HTTP-44).
- `HttpStatusError.preview()` decodes with the charset declared by the response media type, falling back to UTF-8, and never throws a `RangeError` on an unknown label (HTTP-42).
- `withRequestLogging(...).materialize()` gives the new wrapper its own tap buffer instead of aliasing the original's, so one wrapper's write can no longer rewrite another's captured preview (BODY-21).
- `withResponseLogging` treats a zero-length delegate chunk as a stream-contract violation, matching `RetentionWindow` under IO-17 (BODY-25), and `snapshot()` now starts the lazy drain the way `read()` does (BODY-22).
- `Response.close()` marks the response closed only once the release actually succeeds, memoized so concurrent closers share one cancel — the shape `BufferedSink.close()` already uses (BODY-15, HTTP-43).

Public API:

- New `FormBodyValidationError`, reported by `isBodyError`. A form field whose value cannot be rendered is now raised instead of silently dropped from the body.
- `FormUrlEncodedInput` accepts the new `FormUrlEncodedValue` (`string | number | boolean | bigint | null`); primitives render rather than vanish (HTTP-38/BODY-35).
7 changes: 7 additions & 0 deletions .changeset/2026-08-25-body-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@dexpace/core": minor
---

Add the core Body domain interface and implementations (ByteArrayBody, StringBody, FormUrlEncodedBody, StreamBody, MultipartBody, materialize, TypedResponse, HttpStatusError, toHttpError, withRequestLogging, withResponseLogging).

`RequestBuilder.body` and `ResponseBuilder.body` narrow from `unknown` to `Body | undefined` and `ReadableStream<Uint8Array> | null` respectively — a breaking parameter-type change per `styleguide/typescript/10-api-design.md`. Resolving Phase 3b's open D1 finding (`docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md`, "Open Findings — Phase 3b Validation Review"): kept as **minor** rather than major because `@dexpace/core` is still pre-1.0 (`0.0.0`), where a 0.x breaking change is conventionally released as minor (semver's own carve-out for initial development, https://semver.org/#spec-item-4). Revisit at 1.0.
5 changes: 5 additions & 0 deletions .changeset/2026-08-25-io-contracts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@dexpace/core": patch
---

Internal: byte-streaming primitives for product-spec §5 (IO-1–IO-42). No public API change.
23 changes: 23 additions & 0 deletions .changeset/2026-08-26-add-the-node-runtime-conformance-suite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
---

Add the Node-runtime conformance suite.

No published package changes.

Deliberately empty — `changeset --empty` — rather than absent. Every file in this change is repository
infrastructure that ships to nobody: `test/node-conformance/`, `.github/workflows/ci.yml`, `bunfig.toml`,
`eslint.config.js`, the root `package.json` scripts, `CLAUDE.md`, and the phase docs. Zero files under
`packages/` were touched, so there is nothing for `@dexpace/core` to bump and a `patch` here would put a line
in the published changelog that means nothing to a consumer reading it.

The empty changeset records that the judgement was made, which is the difference between "this change needs no
release" and "somebody forgot a changeset". Verified before writing it:
`git show --stat --name-only e3d0b18 | grep '^packages/'` returns nothing.

What the change does, for anyone reading this file from the repository rather than the changelog: `bun test`
runs the unit suite on Bun and proves nothing about the runtime the SDK ships to. 319 of 516 unit tests
exercise a runtime-divergent surface — Web Streams, `AbortSignal`, async iteration, `ByteQueue`'s `Uint8Array`
handling — against two assertions of Node coverage that touched none of it. `test/node-conformance/` adds 30
`node --test` cases over the built artifact, wired as `test:node` and run by CI as a matrix over the declared
`engines.node` floor and current LTS. Closes checkpoint §5.9 / roadmap finding E5.
51 changes: 51 additions & 0 deletions .changeset/2026-08-26-phase3-conformance-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
"@dexpace/core": minor
---

Phase 3 conformance fixes, from a review of the shipped `io/` and `body/` layers against the phase 3a/3b plans.

Correctness:

- The request-body logging tee now forwards **both** teardown paths to the sink it was handed. Its adapter stream
declared `write` and `close` but no `abort`, and a `WritableStream`'s default abort algorithm is a no-op — so a
delegate failure aborted the adapter and stopped there, leaving the caller's sink open, still locked, and never
told the message was broken. A truncated body could be committed downstream as a complete one. `writeTo` also
releases the writer when a delegate refuses before ever touching the adapter, which is what a `ConsumedBodyError`
on a second write does (BODY-17, RECOV-12).
- `StreamBody.writeTo` no longer cancels the caller's stream when the sink fails. The unknown-length path used
`pipeTo`'s default `preventCancel: false`, which cancels the *source* on a destination failure — taking
cancellation ownership away from the caller on exactly the failure path, and disagreeing with the
declared-length path, which only releases its reader. Both paths now leave the caller's stream alone (BODY-8).
- Every `Body` variant is frozen at construction. `readonly` is erased at run time, so `contentLength` could be
reassigned after construction and desynchronized from the bytes `writeTo` emits — the same declared-length drift
`MultipartBody` shares one framing routine to prevent, left open on the field a transport stamps into
`Content-Length` (HTTP-1, XCUT-15, HTTP-51).
- `Response` regained the private constructor and `createResponse` friend hook that the body-lifecycle rewrite
dropped. `Response` is exported as a value, so a public field-wise constructor let a caller construct around
`build()`'s required-field validation, and it appeared in the published `.d.ts` (HTTP-2).
- `TeeSink.write` validates its count. `IO-3`'s guard existed as three byte-for-byte copies and the tee — the
fourth size-taking surface — had none, so a negative count was rejected only indirectly, and not at all on its
`count === 0` and short-source early returns. The guard is now single-sourced in `io/limits.ts`.
- `withResponseLogging` enforces the zero-length-chunk contract on the exceeds-cap tail path as well as the drain.
A rule held in one regime and not the other made the same violating upstream pass or fail depending only on how
big the body happened to be (BODY-25).

Public API:

- `Response` and the response-body logging wrapper no longer declare `[Symbol.asyncDispose]`; `close()` is the only
teardown interface, matching every other resource-owning class in the package. The symbol postdates the declared
`engines.node` floor (`>=18.17`), where it evaluates to `undefined` and binds the method to the string
`"undefined"`, and its type reached the package only through a dev-only global — so a consumer compiling against
the published `.d.ts` on this package's own declared `lib` failed with
`TS2550: Property 'asyncDispose' does not exist on type 'SymbolConstructor'`. It returns, on all seven resource
owners at once, when the runtime floor moves.
- Every public symbol now carries TSDoc. The committed API report had accumulated 62 `(undocumented)` members,
including 11 of `Response`/`ResponseBuilder`'s own that a wholesale file rewrite had dropped; it is back to zero.

Internal:

- `http/charset.ts`'s `decodeText` is renamed `decodeBodyText`. It shares a name with `io/text-codec.ts`'s
`decodeText` while deliberately disagreeing with it: this one delegates every label to `TextDecoder` (so
`iso-8859-1` follows the WHATWG mapping onto windows-1252) and consumes a leading BOM, which is right for a whole
message body; the other implements true ISO-8859-1 for IO-13's round-trip and sets `ignoreBOM` so a mid-stream
BOM survives as ordinary data (SSE-12). Reaching for the wrong one silently changes bytes.
44 changes: 44 additions & 0 deletions .changeset/2026-08-26-phase3-review-pass-2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
"@dexpace/core": minor
---

Phase 3 review pass 2. Five defects, each in the same class as one pass 1 already fixed — the earlier fixes
were correct but did not reach every site the same reasoning applies to.

Correctness:

- `Response.bytes()`, `Response.text()` and `toHttpError()` now acquire the body reader **inside** the try, so
the response is closed even when the read cannot start. `getReader()` itself throws a `TypeError` when an
external consumer already holds the lock — which `BODY-15` explicitly forbids assuming away, and which
`Response.close()` was already hardened for — so the one failure `BODY-16`'s close guarantee most needs to
cover was the one that skipped the close entirely and held the connection open.
- `MultipartBody.writeTo` verifies the bytes it writes against its own declared `contentLength`. The shared
framing routine keeps the framing consistent but takes each part's own `contentLength` on trust, and
`MultipartPart.body` is the public `Body` interface — so a caller implementation reporting one length and
writing another desynchronized the value a transport stamps into `Content-Length` from what reaches the
socket. An overrunning chunk is now refused before it is written, and a short total raises inside the writer
scope so the sink is aborted rather than cleanly closed (HTTP-51, same shape as `StreamBody`'s HTTP-39 check).
- `withRequestLogging` closes the primary sink when a delegate resolves without closing the adapter. It is the
only place that takes a writer on behalf of someone else's `Body`, so a delegate that ignored `writeTo`'s
close-the-sink contract stranded the caller's sink open and locked with nothing thrown to notice it by.
- A foreign primitive source that over-reports its transferred count now raises `SourceContractViolationError`.
It previously surfaced as `EndOfStreamError: delivered 2 of 99 bytes` — a foreign source's broken accounting
reported as an exhausted stream, which is the exact confusion `IO-17` forbids and which the under-report
direction was already guarded against (IO-17).

Documentation:

- `multipartBody`'s `boundary` parameter and `MultipartBodyBuilder.boundary` now state the obligation a
caller-supplied delimiter carries. RFC 2046 requires the sender to pick a boundary that appears in no part,
and that half cannot be checked here — a `StreamBody` part's bytes do not exist until the write, and a partial
scan would read as a complete guarantee. The generated default (32 random characters from Web Crypto) is the
mitigation, and is why it is the default.

Tooling:

- New blocking gate `verify:consumer-types`: compiles a throwaway consumer against the built `.d.ts` using the
`lib` and `target` read from `tsconfig.base.json`, with `types: []`. This is the gate whose absence let pass
1's `Symbol.asyncDispose` defect ship — `typecheck` passes on dev-only ambient globals, `build` emits
regardless, `api` only compares a report, `lint:publish` checks resolution and export shape rather than
whether declarations resolve, and `verify:dual-consumption` runs `node`, not `tsc`. Verified to fail on the
reintroduced defect and pass once reverted.
39 changes: 39 additions & 0 deletions .changeset/2026-08-26-raise-the-node-floor-to-20-3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
"@dexpace/core": minor
---

Raise `engines.node` from `>=18.17` to `>=20.3`, and `lib`/`target` from `ES2022` to `ES2023` with it.

The declared floor was not real. `MultipartBody` generates its boundary from `crypto.getRandomValues`, and Node
exposes `globalThis.crypto` unflagged only from **19.0.0** — never to an ES module on any 18.x release, verified
on both 18.17.0 and 18.20.8. Every `multipartBody(...)` call threw `ReferenceError: crypto is not defined` on the
version `engines.node` promised. `bun test` could not see it, because Bun supplies the global; the Node
conformance suite caught it the first time it ran the built artifact on the pinned floor.

The floor is `>=20.3` rather than `>=20.0` because `AbortSignal.any()` — `composeSignal`'s own floor-defining
call, backported to 18.17.0 — reached the 20.x line only in 20.3.0. Confirmed by running the suite against a
pinned 20.0.0, where `composeSignal` fails with `AbortSignal.any is not a function`.

Raising the floor was chosen over the two alternatives that keep Node 18. A `node:crypto` fallback puts a
Node-only specifier in a package documented as running on browsers, Deno, Bun and Workers, and cannot be reached
synchronously from the constructor that needs it. A non-crypto fallback RNG silently downgrades the
unguessable-boundary mitigation `HTTP-51` leans on against multipart injection, on exactly the runtime CI pins.
Node 18 reached end of life in April 2025, so no supported runtime is dropped.

Also in this change:

- `verify:runtime-floor`'s pairing table moves its `es2023` row to `>=20.3`, with the built-ins the SDK calls —
not the syntax it emits — named as the reason the floor sits above the language level's own minimum.
- The `node-conformance` CI matrix pins `20.3.0` in place of `18.17.0`.
- The conformance suite gains a case asserting `globalThis.crypto.getRandomValues` is a function **in ESM**, so
this floor cannot regress silently. Node 18 exposed `crypto` to CommonJS while leaving it undefined in ES
modules, so a CJS probe would have reported the old floor as satisfied.
- `seams.test.mjs` holds the event loop open with a ref'd deadline while awaiting an `AbortSignal.timeout()`
abort. That timer is unref'd on every Node version by design, so with nothing else scheduled the loop drained
before it fired and Node 18.17.0's test runner cancelled the rest of the file. Newer runners kept the loop
alive through handles of their own, which is why this passed on current LTS and failed only on the floor.
- `sdk-design-nodejs/02`'s runtime-requirement line is corrected; it had claimed Node ≥18.17 supplies
`globalThis.crypto.subtle`.

`Symbol.asyncDispose` is still not declared anywhere. The symbols reached the 20.x line in 20.4.0, one patch
above this floor, and re-adding them remains checkpoint §5.4's job across all seven resource owners at once.
21 changes: 17 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ jobs:
- name: Dual JS/TS consumption check
run: bun run verify:dual-consumption

- name: Consumer typecheck against the published .d.ts
run: bun run verify:consumer-types

- name: SEAM-1 zero-dependency check
run: bun run verify:seam-1

Expand All @@ -56,9 +59,19 @@ jobs:
- name: Dependency audit
run: bun run audit

node-floor-conformance:
node-conformance:
needs: ci
runs-on: ubuntu-latest
strategy:
# Report both versions rather than stopping at the first failure: "broken on the floor" and
# "broken on LTS" are different diagnoses and the matrix exists to tell them apart.
fail-fast: false
matrix:
# The declared floor AND current LTS, which is the "in addition to current LTS" half of
# sdk-design-nodejs/09:52-54 that a floor-only pin left unexercised (checkpoint 5.9).
# `lts/*` resolves at run time, so this does not go stale as LTS moves.
node: ['20.3.0', 'lts/*']
name: node-conformance (${{ matrix.node }})
steps:
- uses: actions/checkout@v4

Expand All @@ -74,7 +87,7 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: 18.17.0
node-version: ${{ matrix.node }}

- name: Verify the built artifact against the declared minimum Node version (NFR-10/NFR-17)
run: node scripts/verify-node-floor.mjs
- name: Node-runtime conformance against the built artifact (NFR-10/NFR-17)
run: bun run test:node
16 changes: 15 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,15 @@ bun run lint # gts lint . — formatting AND type-aware rules; fatal
bun run fix # gts fix . — autofixes formatting/lint
bun run build # tsc -p packages/core/tsconfig.build.json → dist/
bun test # coverage is on by default (bunfig.toml), 80% line floor
bun run test:node # Node-runtime conformance against the BUILT artifact; needs `build` first
```

`bun test` runs the unit suite on **Bun** and is scoped to `packages/` (`bunfig.toml`'s `[test] root`).
`test:node` is a separate, thin layer under `test/node-conformance/` that runs the same built package under
`node --test`, because Bun's Web Streams / `AbortSignal` / `Uint8Array` behavior is an independent
implementation of Node's and `src/io/` is where they diverge. **A phase that touches a runtime-divergent
surface adds a case there, not only to `bun test`** — see `test/node-conformance/README.md`.

Single test file or single test:

```bash
Expand All @@ -44,6 +51,8 @@ Release-shape and invariant gates:
```bash
bun run lint:publish # publint + attw against the built package
bun run verify:dual-consumption # plain `node` imports the built package and runs it
bun run verify:consumer-types # the built .d.ts compiles on the declared `lib` with types: []
bun run test:node # CI runs this as a matrix over engines.node's floor and current LTS
bun run verify:seam-1 # asserts @dexpace/core has zero runtime dependencies
bun run verify:runtime-floor # tsconfig target vs package engines.node consistency
bun run audit # bun audit --audit-level=high --prod
Expand Down Expand Up @@ -151,7 +160,12 @@ Anything the barrel exports needs a TSDoc block with `@public`, plus `@throws` n
class on operations that throw. `api-extractor` will otherwise flag it, and the committed report records it as
`(undocumented)`. After changing exports: rebuild, run `api:local`, and commit the regenerated report.

Consumer-facing changes need a changeset (`bunx changeset`).
Consumer-facing changes need a changeset — `bun run changeset`, not `bunx changeset`. The wrapper
(`scripts/changeset.mjs`) forwards every argument to the CLI, then renames the file it generates from
`@changesets/write`'s random `human-id` name to `YYYY-MM-DD-<slug>.md`, matching
`docs/superpowers/{specs,plans}`. The slug is prompted for, defaulting to the changeset's own first
sentence. Nothing reads the filename back — the CLI globs `.changeset/*.md` and decides from the
frontmatter — so a hand-written changeset just needs to be named the same way.

## Phase workflow

Expand Down
Loading
Loading