From 7af53e7b03d2b4aa6a5613f55552b345c981087c Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh <78609166+Wahbeh-Mohammad@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:59:43 +0300 Subject: [PATCH 1/8] =?UTF-8?q?feat(core):=20add=20I/O=20contracts=20?= =?UTF-8?q?=E2=80=94=20ByteQueue,=20BufferedSource/Sink,=20view.=20TeeSink?= =?UTF-8?q?.=20(#31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(core): add I/O contracts — ByteQueue, BufferedSource/Sink, views, TeeSink. * test(core): close per-file coverage gaps in invariant, io/errors, rejection helper. * fix(core): resolve I/O contract review findings — copy semantics, lifecycle, and encoding symmetry (IO-1..IO-42). --- .changeset/io-contracts.md | 5 + bun.lock | 3 + ...26-07-24-phase3a-io-contracts-checklist.md | 9 +- .../2026-07-24-phase3a-io-contracts-design.md | 10 +- package.json | 1 + packages/core/src/invariant.test.ts | 29 ++ packages/core/src/invariant.ts | 31 ++ packages/core/src/io/buffered-sink.test.ts | 314 ++++++++++++++++ packages/core/src/io/buffered-sink.ts | 172 +++++++++ packages/core/src/io/buffered-source.test.ts | 160 ++++++++ .../core/src/io/buffered-source.text.test.ts | 203 ++++++++++ packages/core/src/io/buffered-source.ts | 355 ++++++++++++++++++ .../core/src/io/buffered-source.views.test.ts | 193 ++++++++++ packages/core/src/io/byte-queue.bench.ts | 61 +++ .../core/src/io/byte-queue.property.test.ts | 77 ++++ packages/core/src/io/byte-queue.test.ts | 280 ++++++++++++++ packages/core/src/io/byte-queue.ts | 321 ++++++++++++++++ packages/core/src/io/errors.test.ts | 78 ++++ packages/core/src/io/errors.ts | 92 +++++ packages/core/src/io/factories.test.ts | 135 +++++++ packages/core/src/io/factories.ts | 117 ++++++ packages/core/src/io/index.ts | 37 ++ packages/core/src/io/limits.test.ts | 53 +++ packages/core/src/io/limits.ts | 41 ++ packages/core/src/io/pump.test.ts | 45 +++ packages/core/src/io/pump.ts | 32 ++ packages/core/src/io/retention-window.test.ts | 221 +++++++++++ packages/core/src/io/retention-window.ts | 202 ++++++++++ packages/core/src/io/sink.ts | 47 +++ packages/core/src/io/tee-sink.test.ts | 277 ++++++++++++++ packages/core/src/io/tee-sink.ts | 155 ++++++++ .../core/src/io/test-support/fake-stream.ts | 149 ++++++++ .../src/io/test-support/rejection.test.ts | 40 ++ .../core/src/io/test-support/rejection.ts | 22 ++ packages/core/src/io/text-codec.ts | 103 +++++ packages/core/tsconfig.build.json | 4 +- tsconfig.base.json | 3 +- 37 files changed, 4070 insertions(+), 7 deletions(-) create mode 100644 .changeset/io-contracts.md create mode 100644 packages/core/src/invariant.test.ts create mode 100644 packages/core/src/invariant.ts create mode 100644 packages/core/src/io/buffered-sink.test.ts create mode 100644 packages/core/src/io/buffered-sink.ts create mode 100644 packages/core/src/io/buffered-source.test.ts create mode 100644 packages/core/src/io/buffered-source.text.test.ts create mode 100644 packages/core/src/io/buffered-source.ts create mode 100644 packages/core/src/io/buffered-source.views.test.ts create mode 100644 packages/core/src/io/byte-queue.bench.ts create mode 100644 packages/core/src/io/byte-queue.property.test.ts create mode 100644 packages/core/src/io/byte-queue.test.ts create mode 100644 packages/core/src/io/byte-queue.ts create mode 100644 packages/core/src/io/errors.test.ts create mode 100644 packages/core/src/io/errors.ts create mode 100644 packages/core/src/io/factories.test.ts create mode 100644 packages/core/src/io/factories.ts create mode 100644 packages/core/src/io/index.ts create mode 100644 packages/core/src/io/limits.test.ts create mode 100644 packages/core/src/io/limits.ts create mode 100644 packages/core/src/io/pump.test.ts create mode 100644 packages/core/src/io/pump.ts create mode 100644 packages/core/src/io/retention-window.test.ts create mode 100644 packages/core/src/io/retention-window.ts create mode 100644 packages/core/src/io/sink.ts create mode 100644 packages/core/src/io/tee-sink.test.ts create mode 100644 packages/core/src/io/tee-sink.ts create mode 100644 packages/core/src/io/test-support/fake-stream.ts create mode 100644 packages/core/src/io/test-support/rejection.test.ts create mode 100644 packages/core/src/io/test-support/rejection.ts create mode 100644 packages/core/src/io/text-codec.ts diff --git a/.changeset/io-contracts.md b/.changeset/io-contracts.md new file mode 100644 index 0000000..36054cf --- /dev/null +++ b/.changeset/io-contracts.md @@ -0,0 +1,5 @@ +--- +"@dexpace/core": patch +--- + +Internal: byte-streaming primitives for product-spec §5 (IO-1–IO-42). No public API change. diff --git a/bun.lock b/bun.lock index 5ea441b..c138d38 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "fast-check": "^3", "globals": "^17.8.0", "gts": "^7", + "mitata": "^1", "publint": "^0.3", "typescript": "^5.8", "typescript-eslint": "^8", @@ -483,6 +484,8 @@ "minimist-options": ["minimist-options@4.1.0", "", { "dependencies": { "arrify": "^1.0.1", "is-plain-obj": "^1.1.0", "kind-of": "^6.0.3" } }, "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A=="], + "mitata": ["mitata@1.0.34", "", {}, "sha512-Mc3zrtNBKIMeHSCQ0XqRLo1vbdIx1wvFV9c8NJAiyho6AjNfMY8bVhbS12bwciUdd1t4rj8099CH3N3NFahaUA=="], + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], diff --git a/docs/superpowers/plans/2026-07-24-phase3a-io-contracts-checklist.md b/docs/superpowers/plans/2026-07-24-phase3a-io-contracts-checklist.md index ff74490..066b0ed 100644 --- a/docs/superpowers/plans/2026-07-24-phase3a-io-contracts-checklist.md +++ b/docs/superpowers/plans/2026-07-24-phase3a-io-contracts-checklist.md @@ -35,7 +35,7 @@ requirement ID in `docs/product-spec/05-i-o-contracts.md`, as dispositioned by |---|---|---|---|---| | IO-11 | MUST | `exhausted()`, single-byte read, count-less read of all remaining (empty when exhausted) | ✅ | Task 6 | | IO-12 | MUST | Exact-count read returns exactly N or fails; never short | ✅ | Task 6, asserted across chunk boundaries and on the short path | -| IO-13 | MUST | UTF-8 and explicit-charset reads, with symmetric write-side encodings | ✅ (read) / ⚠️ (write, bounded) | Task 7 (read: any `TextDecoder` label, ISO-8859-1 round-trip per the requirement's own conformance note), Task 9 (write: **UTF-8 and ISO-8859-1 only**). `TextEncoder` is UTF-8-only and `SEAM-1` forbids an encoding dependency, so full symmetry is unreachable; any other label throws rather than silently re-encoding. Ledgered deviation | +| IO-13 | MUST | UTF-8 and explicit-charset reads, with symmetric write-side encodings | ✅ (read) / ⚠️ (write, bounded) | Task 7 (read: any `TextDecoder` label), Task 9 (write: **UTF-8 and ISO-8859-1 only**), plus two `fast-check` round-trip property tests in `buffered-sink.test.ts` — sink-out/source-back through UTF-8, and through ISO-8859-1 asserting one byte per code point, which is what distinguishes an honored charset from a silent UTF-8 re-encoding. `TeeSink`'s own `writeUtf8`/`writeString` are asserted to mirror the primary's exact encoded bytes and to refuse an unsupported label identically. `TextEncoder` is UTF-8-only and `SEAM-1` forbids an encoding dependency, so full symmetry is unreachable; any other label throws rather than silently re-encoding. Ledgered deviation | | IO-14 | MUST | Line read consumes the terminator; `\n` and `\r\n` both terminate; lone `\r` is content; final unterminated line as-is; absent when exhausted first | ✅ | Task 7, including a `fast-check` property test with **adversarially generated chunk boundaries**, so a terminator straddling two stream chunks is covered — the case the requirement's rationale names and hand-picked examples miss | | IO-15 | MUST | Skip advances exactly N, fails if fewer remain; `skip(0)` a no-op even at/after EOF | ✅ | Task 6 | | IO-16 | SHOULD | Read-only host-native byte-stream bridge; symmetric writable bridge; closing the bridge closes the owner | ✅ | Task 12. Host-native means `ReadableStream`/`WritableStream` for this port, per `sdk-design/03` §3.1 — no `node:` import; Task 13 Step 9 greps to enforce that | @@ -61,7 +61,7 @@ requirement ID in `docs/product-spec/05-i-o-contracts.md`, as dispositioned by | IO-26 | MUST | Tap capacity limit; default effectively unbounded; a limit of 0 mirrors nothing while forwarding everything | ✅ | Task 10 (`Number.POSITIVE_INFINITY` default, spelled as a value rather than a magic number); all three cases asserted | | IO-27 | MUST | Mirror BEFORE forwarding; clear staging even on a failed write so no stale bytes prepend | ✅ | Task 10, both clauses asserted; staging cleared in a `finally` so it holds on the throwing path | | IO-28 | MUST | No direct backing-buffer handle; attempting it fails, directing callers at the typed writes | ✅ | Task 10 (`get buffer(): never`) | -| IO-29 | MUST | Tee's own flush/close/emit forward to the PRIMARY only, leaving the tap intact | ✅ | Task 10, with snapshot-after-close asserted | +| IO-29 | MUST | Tee's own flush/close/emit forward to the PRIMARY only, leaving the tap intact | ✅ | Task 10. All three asserted: `close` with snapshot-after-close, and `flush`/`emit` both by returning the tee with the tap intact and — the observable proof they are not swallowed by the decorator — by rejecting with `ClosedResourceError` once the primary is closed, which only the primary can raise | ## 5.6 Provider factories, timeouts, and thread-safety @@ -80,10 +80,11 @@ requirement ID in `docs/product-spec/05-i-o-contracts.md`, as dispositioned by | Nothing enters the published API surface | Design decision (styleguide 10.3, Phase 2's `Serde` precedent) | ✅ | Task 13 Step 8 — `git diff --exit-code packages/core/etc/core.api.md` must produce no output. Mechanical proof, not a review promise | | No runtime dependency added | `SEAM-1` | ✅ | Task 13 Step 7 runs `verify:seam-1`; `mitata` is a root devDependency only | | No `node:` import in core | `sdk-design/03` §3.1, runtime-agnosticism | ✅ | Task 13 Step 9 greps `packages/core/src/` and fails on any match | -| Property tests where invariants exist | styleguide 11.5 | ✅ | Task 4 (`ByteQueue` ×4), Task 7 (`readUtf8Line`), Task 8 (views ×2), Task 10 (`TeeSink` wire payload) | +| Property tests where invariants exist | styleguide 11.5 | ✅ | Task 4 (`ByteQueue` ×4), Task 7 (`readUtf8Line`), Task 8 (views ×2), Task 9 (charset round-trips ×2), Task 10 (`TeeSink` wire payload) | +| Rejection assertions are awaited and attributable | styleguide 11.9 | ✅ | `test-support/rejection.ts`. bun types `.rejects.toThrow()` as `void`, so the plan's `await expect(…).rejects` form fails `@typescript-eslint/await-thenable`; the helper awaits the promise and returns the reason instead, with no `eslint-disable`. Ledgered | | Negative-space and cleanup assertions | styleguide 11.9, 13.9 | ✅ | Idempotent close (Tasks 4, 5, 6, 9), both IO-42 directions (Tasks 4, 6), parent-close invalidation (Task 8), failed-write tap capture (Task 10) | | Determinism — no fake clocks needed | styleguide 11.8 | ✅ | IO-40 means this layer owns no timer; every stream under test is built from an in-memory array | -| Fakes over `mock.module` | styleguide 11.3 | ✅ | Task 5's `test-support/fake-stream.ts`, excluded from the build via `tsconfig.build.json` | +| Fakes over `mock.module` | styleguide 11.3 | ✅ | Task 5's `test-support/fake-stream.ts` and `test-support/rejection.ts`, both excluded from the build via `tsconfig.build.json`'s `src/io/test-support/**` | | No type-level tests | styleguide 11.6 | ✅ (correctly absent) | 11.6 requires them for public generics and conditional types; this phase publishes neither. Stated rather than manufactured | | Committed baseline bench | styleguide 15.6 | ✅ | Task 13, `byte-queue.bench.ts`. Baseline only — no optimization applied, no 15.10 ledger notes, per 15.1/15.6's "do not tune ahead of a profile" | | 80% aggregate coverage floor | `NFR-5` | ✅ | Task 13 Step 7 | diff --git a/docs/superpowers/specs/2026-07-24-phase3a-io-contracts-design.md b/docs/superpowers/specs/2026-07-24-phase3a-io-contracts-design.md index a61994c..79906b3 100644 --- a/docs/superpowers/specs/2026-07-24-phase3a-io-contracts-design.md +++ b/docs/superpowers/specs/2026-07-24-phase3a-io-contracts-design.md @@ -399,6 +399,10 @@ layer where the temptation to dump the offending bytes into the message is stron | `TeeSink` as a sink decorator | `sdk-design/03` §3.1 phrasing | `TransformStream` queueing muddies `IO-27`'s mirror-before-forward ordering; §3.1's substantive point is untouched | | `IO-30` resolution half, `IO-39` not built | product-spec §5.6 | No registry exists — same class as `SEAM-5`–`SEAM-10` | | `IO-38` not applicable | product-spec §5.4 | The requirement is about a close on one thread invalidating a slice being read on another, so it presupposes an instance can reach a second thread. None can. **Class instances are not structured-cloneable at all** — `postMessage`/`structuredClone` preserve neither prototypes nor `#private` fields, so a `ByteQueue` or `BufferedSource` sent to a worker arrives as a plain object with no methods and no close state to observe. `BufferedSource` is doubly excluded: a `ReadableStreamDefaultReader` is neither cloneable nor transferable. A raw `ArrayBuffer` *can* be transferred, but it carries no close state and derives no slices, so the hazard has no subject | +| `"DOM.AsyncIterable"` added to `tsconfig.base.json`'s `lib` | Phase 2's `lib: ["ES2022", "DOM"]` baseline | `IO-16`'s `toReadableStream()` returns a `ReadableStream`, and asserting it with `for await (const chunk of …)` needs the async-iteration declarations, which TypeScript ships in a separate `lib` entry from `DOM`. Workspace-wide because the `lib` array is; no runtime effect and no new dependency (`SEAM-1` untouched), and the API report is unchanged. The alternative — driving the bridge test through `getReader()` — was rejected because async iteration is how a consumer will actually use the bridge, so the test should exercise that path | +| `packages/core/src/invariant.ts` created in this phase | The plan's prerequisite, which lists `invariant` as existing from Phase 1 | Phase 1 shipped `requireField` for HTTP-4's required-field message, not a general assertion primitive, so `invariant` (styleguide 5.6, 8.7) did not exist. `IO-3`, `IO-10`, and `IO-21` all need it, so it was added here as an `@internal` module with `InvariantViolation` as its own class. Nothing in `src/http/` was changed to route through it — Phase 1's `requireField` still owns HTTP-4's message | +| `ByteQueue.takeBytes` checks `MAX_BYTE_ARRAY_LENGTH` *before* the short-source check | The plan's Task 3 code, which checked size first | With the plan's ordering, an over-limit request on a short queue raised `EndOfStreamError`, hiding the real problem, and the plan's own `IO-9` test (`takeBytes(MAX + 1)` expects `AllocationLimitError`) could not pass. `IO-9`'s actionable-refusal requirement wins over reporting a size mismatch that is a consequence of the over-limit ask | +| Rejection assertions go through a `rejection()` test helper, not `await expect(…).rejects.toThrow(…)` | The plan's test code, which used `await expect(…).rejects` throughout | bun types `rejects` as `Matchers` whose `toThrow()` returns `void`, even though at run time it returns a promise. So the plan's form fails this repo's type-aware `@typescript-eslint/await-thenable` gate, and dropping the `await` to satisfy lint leaves the assertion racing test teardown — bun still fails the run, but the failure can attribute to a later test. `test-support/rejection.ts` awaits the promise, returns the rejection reason, and fails loudly if the promise resolves, so every assertion is awaited and attributable with no `eslint-disable` | | Write-side charsets limited to UTF-8 and ISO-8859-1 | `IO-13`'s "symmetric write-side encodings" | `TextEncoder` is UTF-8-only and `SEAM-1` forbids an encoding dependency. Read side stays fully general via `TextDecoder`; the write side covers the two encodings HTTP needs, and `IO-13`'s own conformance note names ISO-8859-1 as the non-UTF-8 case. Any other label throws rather than silently corrupting bytes. The `writeUtf8(begin, end)` substring-range overload is subsumed by `String.prototype.slice` at the call site | ## Testing @@ -416,7 +420,11 @@ invariant-bearing functions, and §5 is almost nothing else: covered; `IO-14`'s own rationale calls out surviving slice-window boundaries, and that is exactly the case hand-picked examples miss. - **`readString`/`writeString`** — round-trip through UTF-8 and through ISO-8859-1 (`IO-13`, whose conformance note - names a non-UTF-8 charset explicitly). + names a non-UTF-8 charset explicitly). The ISO-8859-1 generator excludes code points `0x80`–`0x9F`: the WHATWG + Encoding Standard maps the label `iso-8859-1` onto windows-1252, so `TextDecoder` returns U+20AC for `0x80` + rather than U+0080. That is the platform's asymmetry, not the sink's — the write side is a straight + code-point-to-byte map — and HTTP needs none of those C1 controls. Recorded here so Phase 9 does not read the + excluded band as an untested gap. - **View independence** — N views at arbitrary offsets and counts each read the same bytes a direct read at that window would, and no view's read advances another's cursor (`IO-19`, `IO-20`, `IO-23`). - **`TeeSink`** — for arbitrary write sequences and arbitrary tap caps, the primary receives the exact concatenation diff --git a/package.json b/package.json index e02384f..270a991 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "fast-check": "^3", "globals": "^17.8.0", "gts": "^7", + "mitata": "^1", "publint": "^0.3", "typescript": "^5.8", "typescript-eslint": "^8" diff --git a/packages/core/src/invariant.test.ts b/packages/core/src/invariant.test.ts new file mode 100644 index 0000000..be3fd82 --- /dev/null +++ b/packages/core/src/invariant.test.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/invariant.test.ts +// Exercises: the project's sole assertion primitive (styleguide 5.6) and its error class. +import {describe, expect, test} from 'bun:test'; +import {invariant, InvariantViolation} from './invariant.js'; + +describe('invariant', () => { + test('does not throw when the condition is truthy', () => { + expect(() => { + invariant(true, 'unreachable'); + }).not.toThrow(); + }); + + test('throws InvariantViolation with the given message when the condition is falsy', () => { + expect(() => { + invariant(false, 'broken precondition'); + }).toThrow(InvariantViolation); + expect(() => { + invariant(false, 'broken precondition'); + }).toThrow('broken precondition'); + }); + + test('InvariantViolation sets its name and descends from Error', () => { + const error = new InvariantViolation('boom'); + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('InvariantViolation'); + expect(error.message).toBe('boom'); + }); +}); diff --git a/packages/core/src/invariant.ts b/packages/core/src/invariant.ts new file mode 100644 index 0000000..f0da862 --- /dev/null +++ b/packages/core/src/invariant.ts @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/invariant.ts + +/** + * Thrown by {@link invariant} when a broken precondition or postcondition is detected. + * + * Its own class distinguishes a programmer error — a violated invariant — from an operational + * failure a caller might recover from (styleguide 5.6, 8.7). + * + * @internal + */ +export class InvariantViolation extends Error { + constructor(msg: string) { + super(msg); + this.name = 'InvariantViolation'; + } +} + +/** + * The project's single sanctioned assertion primitive (styleguide 5.6). + * + * A TypeScript assertion function: after `invariant(x !== undefined, msg)`, `x` narrows to exclude + * `undefined` for the rest of the scope. Used for preconditions and postconditions — broken + * invariants, never operational failures a caller might recover from, which go through the typed + * error tree instead. + * + * @internal + */ +export function invariant(cond: unknown, msg: string): asserts cond { + if (!cond) throw new InvariantViolation(msg); +} diff --git a/packages/core/src/io/buffered-sink.test.ts b/packages/core/src/io/buffered-sink.test.ts new file mode 100644 index 0000000..de5fa4e --- /dev/null +++ b/packages/core/src/io/buffered-sink.test.ts @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/io/buffered-sink.test.ts +// Exercises: IO-4 (exact head removal, no partial write), IO-5 (flush, closeable), +// IO-13 (symmetric write-side encodings), IO-18 (emit vs flush), IO-41 (idempotent close), +// IO-42 (rejects after close), IO-6 (wrapper owns the caller's stream), +// IO-16 (writable bridge: close closes, abort aborts) +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import {BufferedSink} from './buffered-sink.js'; +import {BufferedSource} from './buffered-source.js'; +import {ByteQueue} from './byte-queue.js'; +import {ClosedResourceError, EndOfStreamError} from './errors.js'; +import { + collectingWritableStream, + failingCloseWritableStream, + failingWritableStream, + gatedWritableStream, +} from './test-support/fake-stream.js'; +import {rejection} from './test-support/rejection.js'; + +const queueOf = (...values: number[]): ByteQueue => { + const queue = new ByteQueue(); + queue.writeBytes(Uint8Array.from(values)); + return queue; +}; + +describe('BufferedSink', () => { + test('IO-4: write removes exactly the requested count from the source head', async () => { + const {stream, written} = collectingWritableStream(); + const sink = BufferedSink.overStream(stream); + const source = queueOf(1, 2, 3, 4); + await sink.write(source, 3); + await sink.close(); + expect([...written()]).toEqual([1, 2, 3]); + expect(source.size).toBe(1); + }); + + test('IO-4: writing more than the source holds throws and transfers nothing', async () => { + const {stream, written} = collectingWritableStream(); + const sink = BufferedSink.overStream(stream); + const source = queueOf(1, 2); + expect(await rejection(sink.write(source, 3))).toBeInstanceOf( + EndOfStreamError, + ); + await sink.close(); + expect([...written()]).toEqual([]); + expect(source.size).toBe(2); + }); + + test('IO-13: writeUtf8 encodes non-ASCII text symmetrically with the read side', async () => { + const {stream, written} = collectingWritableStream(); + const sink = BufferedSink.overStream(stream); + await sink.writeUtf8('héllo ☃'); + await sink.close(); + expect(new TextDecoder('utf-8').decode(written())).toBe('héllo ☃'); + }); + + test('IO-13: writeString encodes ISO-8859-1', async () => { + const {stream, written} = collectingWritableStream(); + const sink = BufferedSink.overStream(stream); + await sink.writeString('hé', 'iso-8859-1'); + await sink.close(); + expect([...written()]).toEqual([0x68, 0xe9]); + }); + + test('IO-13: writeString rejects a code point ISO-8859-1 cannot represent', async () => { + const {stream} = collectingWritableStream(); + const sink = BufferedSink.overStream(stream); + expect( + (await rejection(sink.writeString('☃', 'iso-8859-1'))).message, + ).toContain('code point 9731 is not representable in iso-8859-1'); + }); + + test('IO-13: writeString rejects a charset the write side cannot encode', async () => { + // TextEncoder is UTF-8-only and SEAM-1 forbids an encoding dependency, so the write side covers + // exactly UTF-8 and ISO-8859-1. Anything else throws rather than silently re-encoding as UTF-8. + const {stream} = collectingWritableStream(); + const sink = BufferedSink.overStream(stream); + expect( + (await rejection(sink.writeString('x', 'shift_jis'))).message, + ).toContain( + 'unsupported write charset: shift_jis (only utf-8 and iso-8859-1 can be encoded)', + ); + }); +}); + +describe('BufferedSink charset round-trips (IO-13)', () => { + /** + * The FULL 0x00–0xFF range, C1 controls included. An earlier version of this generator carved out + * 0x80–0x9F, which is exactly the band where the platform diverges: the WHATWG Encoding Standard maps + * the label `iso-8859-1` onto windows-1252, so `TextDecoder` turns 0x80 into U+20AC. Excluding the + * divergent band made the property pass over a bug rather than find it — which is why decoding + * ISO-8859-1 is now this package's own job (see `decodeText`). + */ + const latin1Codes = fc.array(fc.integer({min: 0x00, max: 0xff}), { + maxLength: 64, + }); + + test('property: arbitrary text round-trips through the sink and back as UTF-8', async () => { + // Styleguide 11.5 names codecs explicitly, and IO-13's whole claim is that the write side is + // symmetric with the read side — a claim only a round-trip can check. + await fc.assert( + fc.asyncProperty( + fc.string({unit: 'grapheme', maxLength: 64}), + async text => { + const {stream, written} = collectingWritableStream(); + const sink = BufferedSink.overStream(stream); + await sink.writeString(text, 'utf-8'); + await sink.close(); + const source = BufferedSource.overBytes(written()); + expect(await source.readString('utf-8')).toBe(text); + }, + ), + ); + }); + + test('property: arbitrary ISO-8859-1 text round-trips as one byte per code point', async () => { + // IO-13's own conformance note names ISO-8859-1 as the non-UTF-8 charset to round-trip. The + // one-byte-per-code-point assertion is what distinguishes an honored charset from a silent + // UTF-8 re-encoding, which would widen every code point above 0x7F to two bytes. + await fc.assert( + fc.asyncProperty(latin1Codes, async codes => { + const text = codes.map(code => String.fromCharCode(code)).join(''); + const {stream, written} = collectingWritableStream(); + const sink = BufferedSink.overStream(stream); + await sink.writeString(text, 'iso-8859-1'); + await sink.close(); + expect([...written()]).toEqual(codes); + const source = BufferedSource.overBytes(written()); + expect(await source.readString('iso-8859-1')).toBe(text); + }), + ); + }); + + test('IO-13: the C1 band round-trips instead of becoming windows-1252 typography', async () => { + const {stream, written} = collectingWritableStream(); + const sink = BufferedSink.overStream(stream); + const text = '\u0080\u0091\u009f'; + await sink.writeString(text, 'iso-8859-1'); + await sink.close(); + expect([...written()]).toEqual([0x80, 0x91, 0x9f]); + const source = BufferedSource.overBytes(written()); + const decoded = await source.readString('iso-8859-1'); + // windows-1252 would give [0x20ac, 0x2018, 0x178] — EUR, curly quote, Y-diaeresis — none of which + // can be re-encoded, so the inverse direction breaks too. + expect(Array.from(decoded, c => c.codePointAt(0))).toEqual([ + 0x80, 0x91, 0x9f, + ]); + expect(decoded).toBe(text); + }); +}); + +describe('BufferedSink lifecycle (IO-18, IO-41, IO-42, IO-6)', () => { + test('IO-18: flush and emit both return the sink for chaining', async () => { + const {stream} = collectingWritableStream(); + const sink = BufferedSink.overStream(stream); + expect(await sink.emit()).toBe(sink); + expect(await sink.flush()).toBe(sink); + await sink.close(); + }); + + test('IO-18: flush waits for the destination to drain; emit does not', async () => { + // Identity assertions alone would pass with emit and flush sharing one body, which is precisely + // what IO-18 forbids: the requirement is that the two be DISTINGUISHABLE. + const {stream, delivered, release} = gatedWritableStream(); + const sink = BufferedSink.overStream(stream); + + void sink.write(queueOf(1, 2, 3, 4), 4); + const flushed = sink.flush(); + let flushSettled = false; + void flushed.then(() => { + flushSettled = true; + }); + + await Promise.resolve(); + expect(delivered()).toBe(0); + expect(flushSettled).toBe(false); + + release(); + await flushed; + expect(delivered()).toBe(4); + }); + + test('IO-18: emit surfaces a failure the underlying stream has already suffered', async () => { + // An emit that never touches the writer reports success on a dead stream, handing a caller that + // uses it as a handoff checkpoint a green light on a body that never left. + const sink = BufferedSink.overStream(failingWritableStream('boom')); + expect( + (await rejection(sink.write(queueOf(1, 2, 3, 4), 4))).message, + ).toContain('boom'); + expect((await rejection(sink.emit())).message).toContain('boom'); + expect((await rejection(sink.flush())).message).toContain('boom'); + }); + + test('IO-41: a close that FAILS reports the failure on every later call, never a silent success', async () => { + // Setting the closed flag and early-returning on it makes the retry resolve, so a destination that + // was never released is reported as closed and healthy (BODY-27 wants the failure surfaced). + const sink = BufferedSink.overStream( + failingCloseWritableStream('close failed'), + ); + expect((await rejection(sink.close())).message).toContain('close failed'); + expect((await rejection(sink.close())).message).toContain('close failed'); + expect(sink.closed).toBe(true); + }); +}); + +describe('BufferedSink write failure and empty payloads (IO-4, IO-25)', () => { + test('IO-4: a failed write leaves the caller its bytes to retry', async () => { + // Consuming from `src` before the downstream write is known to succeed destroys the payload: the + // caller catches the rejection holding nothing, and nothing reached the wire either. + const sink = BufferedSink.overStream(failingWritableStream('boom')); + const source = queueOf(1, 2, 3, 4); + expect((await rejection(sink.write(source, 4))).message).toContain('boom'); + expect(source.size).toBe(4); + expect([...source.snapshot()]).toEqual([1, 2, 3, 4]); + }); + + test('IO-4: a short source is refused before anything reaches the wire', async () => { + const {stream, written} = collectingWritableStream(); + const sink = BufferedSink.overStream(stream); + const source = queueOf(1, 2); + expect(await rejection(sink.write(source, 3))).toBeInstanceOf( + EndOfStreamError, + ); + expect(source.size).toBe(2); + expect(written().length).toBe(0); + }); + + test('an empty payload writes no chunk at all, matching the tee and the bridge', async () => { + // A zero-length chunk is the terminating chunk to an HTTP/1.1 chunked-encoding transport, so + // emitting one for `writeUtf8('')` can end a request body early. + const {stream, chunkSizes} = collectingWritableStream(); + const sink = BufferedSink.overStream(stream); + await sink.writeUtf8(''); + await sink.writeString('', 'iso-8859-1'); + expect(chunkSizes()).toEqual([]); + }); +}); + +describe('BufferedSink bridge lifecycle (IO-16)', () => { + test('IO-16: aborting the bridge aborts the sink and carries the reason', async () => { + // Collapsing an abort into a graceful close commits a cancelled body downstream as a well-formed + // complete one, so the peer cannot tell an aborted upload from a successful short one. + const {stream, isClosed, wasAborted, abortReason} = + collectingWritableStream(); + const sink = BufferedSink.overStream(stream); + const writer = sink.toWritableStream().getWriter(); + await writer.write(Uint8Array.from([1, 2, 3])); + const reason = new Error('user cancelled'); + await writer.abort(reason); + expect(wasAborted()).toBe(true); + expect(abortReason()).toBe(reason); + expect(isClosed()).toBe(false); + expect(sink.closed).toBe(true); + }); + + test('IO-16: closing the bridge closes the sink gracefully', async () => { + const {stream, isClosed, wasAborted} = collectingWritableStream(); + const sink = BufferedSink.overStream(stream); + const writable = sink.toWritableStream(); + const writer = writable.getWriter(); + await writer.write(Uint8Array.from([1])); + await writer.close(); + expect(isClosed()).toBe(true); + expect(wasAborted()).toBe(false); + expect(sink.closed).toBe(true); + }); + + test('IO-41: close is idempotent', async () => { + const {stream} = collectingWritableStream(); + const sink = BufferedSink.overStream(stream); + await sink.close(); + await sink.close(); + expect(sink.closed).toBe(true); + }); + + test('IO-42: write, flush, and emit all reject after close', async () => { + const {stream} = collectingWritableStream(); + const sink = BufferedSink.overStream(stream); + await sink.close(); + expect(await rejection(sink.write(queueOf(1), 1))).toBeInstanceOf( + ClosedResourceError, + ); + expect(await rejection(sink.flush())).toBeInstanceOf(ClosedResourceError); + expect(await rejection(sink.emit())).toBeInstanceOf(ClosedResourceError); + }); + + test('IO-6: closing the sink closes the caller stream it took ownership of', async () => { + const {stream, isClosed} = collectingWritableStream(); + const sink = BufferedSink.overStream(stream); + await sink.close(); + expect(isClosed()).toBe(true); + }); +}); + +describe('BufferedSink host-native bridge (IO-16)', () => { + test('toWritableStream forwards written chunks', async () => { + const {stream, written} = collectingWritableStream(); + const sink = BufferedSink.overStream(stream); + const bridge = sink.toWritableStream(); + const writer = bridge.getWriter(); + await writer.write(Uint8Array.from([1, 2])); + await writer.close(); + expect([...written()]).toEqual([1, 2]); + }); + + test('closing the bridge closes the sink', async () => { + const {stream} = collectingWritableStream(); + const sink = BufferedSink.overStream(stream); + const writer = sink.toWritableStream().getWriter(); + await writer.close(); + expect(sink.closed).toBe(true); + }); +}); diff --git a/packages/core/src/io/buffered-sink.ts b/packages/core/src/io/buffered-sink.ts new file mode 100644 index 0000000..1e3aa21 --- /dev/null +++ b/packages/core/src/io/buffered-sink.ts @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/io/buffered-sink.ts +import {invariant} from '../invariant.js'; +import type {ByteQueue} from './byte-queue.js'; +import {ClosedResourceError, EndOfStreamError} from './errors.js'; +import type {Sink} from './sink.js'; +import {encodeText} from './text-codec.js'; + +/** + * A buffered byte sink over a `WritableStream` (IO-4, IO-5, IO-13, IO-18). + * + * Takes no `AbortSignal` and imposes no timeout (IO-40). Not safe for concurrent use (IO-37). + * + * @internal + */ +export class BufferedSink implements Sink { + readonly #writer: WritableStreamDefaultWriter; + #closed = false; + #closing: Promise | undefined; + /** + * The most recent downstream write, settled or not. + * + * `emit()` and `flush()` await this rather than returning unconditionally. Without it neither method + * observes the writer at all: both report success on a stream that has already errored, and `flush()` + * resolves while a write started with `void sink.write(...)` is still outstanding — so IO-18's + * emit/flush distinction is unobservable in the only direction that matters. + */ + #lastWrite: Promise = Promise.resolve(); + + private constructor(writer: WritableStreamDefaultWriter) { + this.#writer = writer; + } + + /** Wrap a caller-supplied stream (IO-30). */ + static overStream(stream: WritableStream): BufferedSink { + return new BufferedSink(stream.getWriter()); + } + + get closed(): boolean { + return this.#closed; + } + + /** + * Remove exactly `count` bytes from `src`'s head and push them downstream (IO-4). Fails rather than + * writing a partial amount when `src` holds fewer. + * + * `src` is drained only AFTER the downstream write resolves. Consuming first — the obvious reading of + * "remove, then push" — destroys the payload when the write fails, leaving the caller that catches the + * rejection with nothing to retry and nothing on the wire. + */ + async write(src: ByteQueue, count: number): Promise { + assertCount(count); + this.#assertOpen(); + if (count === 0) return; + if (src.size < count) throw new EndOfStreamError(src.size, count); + await this.#push(src.copyOut(0, count)); + src.skip(count); + } + + /** Encode and write UTF-8 text (IO-13). */ + async writeUtf8(text: string): Promise { + return this.writeString(text, 'utf-8'); + } + + /** + * Encode and write text with an explicit charset (IO-13). + * + * An empty payload writes NOTHING rather than a zero-length chunk, matching `write(src, 0)`, the tee, + * and the bridge. A zero-length chunk is not inert on the wire: to an HTTP/1.1 chunked-encoding + * transport it is the terminating chunk, so emitting one for `writeUtf8('')` can end a request body + * early. + */ + async writeString(text: string, charset: string): Promise { + this.#assertOpen(); + const encoded = encodeText(text, charset); + if (encoded.length === 0) return; + await this.#push(encoded); + } + + /** + * IO-18: a full force-out toward the destination — the outstanding write must reach the destination + * AND the destination must have drained. + */ + async flush(): Promise { + this.#assertOpen(); + await this.#lastWrite; + await this.#writer.ready; + return this; + } + + /** + * IO-18: a cheap one-level handoff — hand the buffered bytes to the underlying stream and surface any + * failure, without waiting for the destination to drain. + */ + async emit(): Promise { + this.#assertOpen(); + await this.#lastWrite; + return this; + } + + /** + * IO-5, IO-41: closeable and idempotent, and the underlying resource is released at most once. + * + * Memoized rather than flag-guarded. Setting `#closed` before awaiting and early-returning on it means + * a close that FAILS is reported as a success to every later caller — `sink.closed` reads `true` for a + * destination that was never released, and the retry silently resolves. Handing every caller the same + * promise makes the failure propagate on every path (BODY-27) while still closing at most once. + */ + async close(): Promise { + this.#closing ??= this.#release(async () => this.#writer.close()); + return this.#closing; + } + + /** + * IO-42: discard the destination with a reason rather than committing what was written. Shares the + * close latch, so a sink is torn down exactly once whichever path gets there first. + */ + async abort(reason?: unknown): Promise { + this.#closing ??= this.#release(async () => this.#writer.abort(reason)); + return this.#closing; + } + + /** + * A writable host-native byte-stream bridge (IO-16). Closing the bridge closes the sink; ABORTING it + * aborts the sink, carrying the reason through. + */ + toWritableStream(): WritableStream { + return new WritableStream({ + write: async (chunk): Promise => { + if (chunk.length === 0) return; + await this.#push(chunk); + }, + close: async (): Promise => { + await this.close(); + }, + // Forwarding the reason matters: collapsing an abort into a graceful close commits a cancelled + // request body downstream as a well-formed complete one, so the peer cannot tell an aborted upload + // from a successful short one. + abort: async (reason: unknown): Promise => { + await this.abort(reason); + }, + }); + } + + /** Track the in-flight write so `emit`/`flush` can observe it, without leaking an unhandled rejection. */ + async #push(payload: Uint8Array): Promise { + const pending = this.#writer.write(payload); + this.#lastWrite = pending; + // A caller may start a write with `void sink.write(...)` and only learn of the failure at the next + // `emit()`/`flush()`. Marking the promise handled here keeps that from surfacing as an unhandled + // rejection first; `pending` itself still rejects for everyone awaiting it. + pending.catch(() => undefined); + await pending; + } + + async #release(teardown: () => Promise): Promise { + this.#closed = true; + await teardown(); + } + + /** IO-42: a stream-backed sink rejects writes, flushes, and emits after close. */ + #assertOpen(): void { + if (this.#closed) throw new ClosedResourceError('BufferedSink'); + } +} + +function assertCount(count: number): void { + invariant( + Number.isInteger(count) && count >= 0, + `count must be a non-negative integer, got ${String(count)}`, + ); +} diff --git a/packages/core/src/io/buffered-source.test.ts b/packages/core/src/io/buffered-source.test.ts new file mode 100644 index 0000000..73e3d87 --- /dev/null +++ b/packages/core/src/io/buffered-source.test.ts @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/io/buffered-source.test.ts +// Exercises: IO-1 (read protocol), IO-2 (zero-count read), IO-3 (negative count), +// IO-11 (exhausted, single-byte read, remaining-bytes read), IO-12 (exact-count read), +// IO-15 (skip), IO-41 (idempotent close), IO-42 (stream-backed rejects after close), +// IO-6 (wrapper owns the caller's stream) +import {describe, expect, test} from 'bun:test'; +import {BufferedSource} from './buffered-source.js'; +import {ByteQueue} from './byte-queue.js'; +import {ClosedResourceError, EndOfStreamError} from './errors.js'; +import {END_OF_STREAM} from './limits.js'; +import {fakeReadableStream} from './test-support/fake-stream.js'; +import {rejection} from './test-support/rejection.js'; + +const bytes = (...values: number[]): Uint8Array => Uint8Array.from(values); + +const sourceOver = (...chunks: Uint8Array[]): BufferedSource => + BufferedSource.overStream(fakeReadableStream(chunks)); + +describe('BufferedSource core reads', () => { + test('IO-1: read appends to the destination tail and returns the transferred count', async () => { + const source = sourceOver(bytes(1, 2, 3)); + const dest = new ByteQueue(); + dest.writeBytes(bytes(9)); + expect(await source.read(dest, 2)).toBe(2); + expect([...dest.snapshot()]).toEqual([9, 1, 2]); + }); + + test('IO-1: read returns END_OF_STREAM once exhausted', async () => { + const source = sourceOver(bytes(1)); + const dest = new ByteQueue(); + expect(await source.read(dest, 4)).toBe(1); + expect(await source.read(dest, 4)).toBe(END_OF_STREAM); + }); + + test('IO-2: a zero-count read returns 0 on a fresh source', async () => { + const source = sourceOver(bytes(1)); + expect(await source.read(new ByteQueue(), 0)).toBe(0); + }); + + test('IO-2: a zero-count read returns 0 — not END_OF_STREAM — on an exhausted source', async () => { + const source = sourceOver(); + expect(await source.read(new ByteQueue(), 4)).toBe(END_OF_STREAM); + expect(await source.read(new ByteQueue(), 0)).toBe(0); + }); + + test('IO-3: a negative count is rejected before any I/O', async () => { + const source = sourceOver(bytes(1, 2)); + expect( + (await rejection(source.read(new ByteQueue(), -1))).message, + ).toContain('count must be a non-negative integer, got -1'); + }); + + test('IO-11: exhausted() is false while bytes remain and true once they do not', async () => { + const source = sourceOver(bytes(1)); + expect(await source.exhausted()).toBe(false); + await source.readBytes(); + expect(await source.exhausted()).toBe(true); + }); + + test('IO-11: readByte returns the next byte, then fails at end', async () => { + const source = sourceOver(bytes(7)); + expect(await source.readByte()).toBe(7); + expect(await rejection(source.readByte())).toBeInstanceOf(EndOfStreamError); + }); + + test('IO-11: readBytes returns all remaining bytes, and empty when already exhausted', async () => { + const source = sourceOver(bytes(1, 2), bytes(3)); + expect([...(await source.readBytes())]).toEqual([1, 2, 3]); + expect([...(await source.readBytes())]).toEqual([]); + }); + + test('IO-12: readExactly returns exactly the requested count across chunk boundaries', async () => { + const source = sourceOver(bytes(1), bytes(2, 3), bytes(4)); + expect([...(await source.readExactly(3))]).toEqual([1, 2, 3]); + }); + + test('IO-12: readExactly fails rather than returning a short result', async () => { + const source = sourceOver(bytes(1, 2)); + expect(await rejection(source.readExactly(3))).toBeInstanceOf( + EndOfStreamError, + ); + }); +}); + +describe('BufferedSource skip and lifecycle (IO-15, IO-41, IO-42, IO-6)', () => { + test('IO-15: skip advances past exactly the requested count', async () => { + const source = sourceOver(bytes(1, 2, 3, 4)); + await source.skip(2); + expect([...(await source.readBytes())]).toEqual([3, 4]); + }); + + test('IO-15: skip fails when fewer bytes remain', async () => { + const source = sourceOver(bytes(1, 2)); + expect(await rejection(source.skip(3))).toBeInstanceOf(EndOfStreamError); + }); + + test('IO-15: skip(0) is a no-op, even at and after end of stream', async () => { + const source = sourceOver(bytes(1)); + await source.skip(0); + await source.readBytes(); + await source.skip(0); + expect(await source.exhausted()).toBe(true); + }); + + test('IO-41: close is idempotent', async () => { + const source = sourceOver(bytes(1)); + await source.close(); + await source.close(); + expect(source.closed).toBe(true); + }); + + test('IO-42: a stream-backed source REJECTS reads after close', async () => { + // The opposite direction from ByteQueue, which stays readable. IO-42 names both as the + // inconsistency porters get wrong; both directions are asserted, here and in Task 4. + const source = sourceOver(bytes(1, 2)); + await source.close(); + expect(await rejection(source.read(new ByteQueue(), 1))).toBeInstanceOf( + ClosedResourceError, + ); + expect(await rejection(source.readBytes())).toBeInstanceOf( + ClosedResourceError, + ); + }); + + test('overBytes wraps a byte array as an independent copy', async () => { + const input = bytes(1, 2, 3); + const source = BufferedSource.overBytes(input); + input[0] = 99; + expect([...(await source.readBytes())]).toEqual([1, 2, 3]); + }); + + test('IO-6: closing the source cancels the caller stream it took ownership of', async () => { + let cancelled = false; + const source = BufferedSource.overStream( + fakeReadableStream([bytes(1)], () => { + cancelled = true; + }), + ); + await source.close(); + expect(cancelled).toBe(true); + }); +}); + +describe('BufferedSource host-native bridge (IO-16)', () => { + test('toReadableStream yields the remaining bytes', async () => { + const source = sourceOver(bytes(1, 2), bytes(3)); + const collected: number[] = []; + for await (const chunk of source.toReadableStream()) + collected.push(...chunk); + expect(collected).toEqual([1, 2, 3]); + }); + + test('closing the bridge closes the owning source', async () => { + const source = sourceOver(bytes(1, 2, 3)); + const stream = source.toReadableStream(); + await stream.cancel(); + expect(source.closed).toBe(true); + }); +}); diff --git a/packages/core/src/io/buffered-source.text.test.ts b/packages/core/src/io/buffered-source.text.test.ts new file mode 100644 index 0000000..ed558c1 --- /dev/null +++ b/packages/core/src/io/buffered-source.text.test.ts @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/io/buffered-source.text.test.ts +// Exercises: IO-13 (UTF-8 and explicit-charset decode), IO-14 (line reads: \n and \r\n terminators, +// lone \r stays content, final unterminated line returned as-is, undefined when exhausted first) +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import {BufferedSource} from './buffered-source.js'; +import {fakeReadableStream} from './test-support/fake-stream.js'; +import {rejection} from './test-support/rejection.js'; + +const utf8 = (text: string): Uint8Array => new TextEncoder().encode(text); + +const sourceOver = (...chunks: Uint8Array[]): BufferedSource => + BufferedSource.overStream(fakeReadableStream(chunks)); + +/** Split `bytes` at the given cut points, so a terminator can straddle a chunk boundary. */ +function chunkAt(bytes: Uint8Array, cuts: readonly number[]): Uint8Array[] { + const bounded = [ + ...new Set(cuts.filter(c => c > 0 && c < bytes.length)), + ].sort((a, b) => a - b); + const out: Uint8Array[] = []; + let previous = 0; + for (const cut of bounded) { + out.push(bytes.subarray(previous, cut)); + previous = cut; + } + // An empty trailing subarray (only possible when `bytes` itself is empty) would enqueue a zero-length + // chunk, which RetentionWindow correctly rejects as an IO-17 protocol violation — a stream signals + // end-of-stream via `done`, never via a 0-byte delivery. Omitting it here keeps the fixture itself + // protocol-clean. + const last = bytes.subarray(previous); + if (last.length > 0) out.push(last); + return out; +} + +describe('BufferedSource text reads (IO-13)', () => { + test('readUtf8 decodes non-ASCII text', async () => { + expect(await sourceOver(utf8('héllo ☃')).readUtf8()).toBe('héllo ☃'); + }); + + test('readUtf8 decodes across a chunk boundary that splits a multi-byte character', async () => { + const encoded = utf8('☃'); + const source = sourceOver(encoded.subarray(0, 1), encoded.subarray(1)); + expect(await source.readUtf8()).toBe('☃'); + }); + + test('readString decodes an explicit non-UTF-8 charset', async () => { + // 0xE9 is é in ISO-8859-1 and invalid alone in UTF-8 — so this only passes if the charset is honored. + const source = sourceOver(Uint8Array.from([0x68, 0xe9])); + expect(await source.readString('iso-8859-1')).toBe('hé'); + }); + + test('readString rejects an unknown charset label', async () => { + expect( + (await rejection(sourceOver(utf8('x')).readString('not-a-charset'))) + .message, + ).toContain('unsupported charset: not-a-charset'); + }); +}); + +describe('BufferedSource line reads (IO-14)', () => { + test('splits on \\n and consumes the terminator', async () => { + const source = sourceOver(utf8('one\ntwo\n')); + expect(await source.readUtf8Line()).toBe('one'); + expect(await source.readUtf8Line()).toBe('two'); + expect(await source.readUtf8Line()).toBeUndefined(); + }); + + test('treats \\r\\n as a terminator and strips both bytes', async () => { + const source = sourceOver(utf8('one\r\ntwo\r\n')); + expect(await source.readUtf8Line()).toBe('one'); + expect(await source.readUtf8Line()).toBe('two'); + }); + + test('keeps a lone \\r not followed by \\n as line content', async () => { + const source = sourceOver(utf8('a\rb\n')); + expect(await source.readUtf8Line()).toBe('a\rb'); + }); + + test('returns a final unterminated line as-is', async () => { + const source = sourceOver(utf8('one\ntwo')); + expect(await source.readUtf8Line()).toBe('one'); + expect(await source.readUtf8Line()).toBe('two'); + expect(await source.readUtf8Line()).toBeUndefined(); + }); + + test('returns undefined when exhausted before any byte', async () => { + expect(await sourceOver().readUtf8Line()).toBeUndefined(); + }); + + test('returns an empty string for an empty line', async () => { + const source = sourceOver(utf8('\nx\n')); + expect(await source.readUtf8Line()).toBe(''); + expect(await source.readUtf8Line()).toBe('x'); + }); + + test('property: lines round-trip across adversarial chunk boundaries', async () => { + // IO-14's rationale calls out surviving slice-window boundaries; hand-picked examples miss the case + // where \r and \n land in different chunks, so the cut points are generated. + await fc.assert( + fc.asyncProperty( + fc.array(fc.stringMatching(/^[a-z \r]*$/), {maxLength: 8}), + fc.constantFrom('\n', '\r\n'), + fc.array(fc.integer({min: 0, max: 64}), {maxLength: 8}), + async (lines, terminator, cuts) => { + const encoded = utf8( + lines.map(line => `${line}${terminator}`).join(''), + ); + const source = BufferedSource.overStream( + fakeReadableStream(chunkAt(encoded, cuts)), + ); + + const read: string[] = []; + for (;;) { + const line = await source.readUtf8Line(); + if (line === undefined) break; + read.push(line); + } + // A line-content trailing \r merges with an appended \n into \r\n and is stripped by the + // reader; with a \r\n terminator only the terminator's own \r is stripped, so a content \r + // survives. The oracle mirrors exactly that rule. + const expected = lines.map(line => + terminator === '\n' ? line.replace(/\r$/, '') : line, + ); + expect(read).toEqual(expected); + }, + ), + ); + }); +}); + +describe('BufferedSource text decoding fidelity (IO-13, IO-14)', () => { + test('a BOM is preserved on every line, not silently deleted', async () => { + // A fresh TextDecoder per fragment with the default `ignoreBOM: false` strips U+FEFF wherever a + // fragment happens to begin. SSE-12 requires a mid-stream BOM to survive as ordinary data, so + // losing it here would make that requirement unimplementable in Phase 6b — the byte is gone before + // the SSE parser ever sees the line. + const source = BufferedSource.overBytes(utf8('a\n\ufeffb\n\ufeffc')); + const lines: (string | undefined)[] = []; + for (;;) { + const line = await source.readUtf8Line(); + if (line === undefined) break; + lines.push(line); + } + expect(lines).toEqual(['a', '\ufeffb', '\ufeffc']); + }); + + test('a leading BOM survives a whole-body read', async () => { + // Dropping it would silently remove a body's first three bytes, breaking content hashing, + // signature verification and exact-length assertions. + const source = BufferedSource.overBytes(utf8('\ufeffpayload')); + const text = await source.readUtf8(); + expect(text).toBe('\ufeffpayload'); + expect(text.length).toBe(8); + }); + + test('a leading BOM survives a counted read', async () => { + const source = BufferedSource.overBytes(utf8('\ufeffab')); + expect(await source.readUtf8(5)).toBe('\ufeffab'); + }); + + test('an unusable charset is refused before any byte is consumed', async () => { + const source = BufferedSource.overBytes(utf8('hello')); + expect( + (await rejection(source.readString('no-such-charset'))).message, + ).toContain('unsupported charset'); + expect(await source.readUtf8()).toBe('hello'); + }); + + test('readUtf8Line stays linear in the length of the line', async () => { + // Re-peeking the whole scanned prefix on every pulled chunk makes this quadratic in bytes copied + // with no line-length bound — and this is the primitive header and chunked-encoding parsing run + // over attacker-controlled bytes, so a peer dribbling a long newline-free line pins a CPU core. + const measure = async (length: number): Promise => { + const source = BufferedSource.overStream(dribbledLine(length)); + const started = performance.now(); + await source.readUtf8Line(); + return performance.now() - started; + }; + await measure(2000); // warm the JIT so the ratio reflects the algorithm, not compilation + const small = await measure(4000); + const large = await measure(16000); + // Quadratic would be ~16x for 4x the input. Linear is ~4x; the ceiling is loose so the test does + // not go flaky on a noisy machine, but it is far under what a quadratic scan would produce. + expect(large).toBeLessThan(Math.max(small, 1) * 10); + }); + + /** One byte per chunk, so every byte forces another scan pass. */ + function dribbledLine(length: number): ReadableStream { + let at = 0; + return new ReadableStream({ + pull(controller): void { + if (at < length) { + controller.enqueue(Uint8Array.from([0x61])); + at += 1; + return; + } + controller.enqueue(Uint8Array.from([0x0a])); + controller.close(); + }, + }); + } +}); diff --git a/packages/core/src/io/buffered-source.ts b/packages/core/src/io/buffered-source.ts new file mode 100644 index 0000000..d180978 --- /dev/null +++ b/packages/core/src/io/buffered-source.ts @@ -0,0 +1,355 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/io/buffered-source.ts +import {invariant} from '../invariant.js'; +import {ByteQueue, copyBytes} from './byte-queue.js'; +import {ClosedResourceError, EndOfStreamError} from './errors.js'; +import {assertAllocatable, END_OF_STREAM} from './limits.js'; +import {RetentionWindow, type Cursor} from './retention-window.js'; +import {assertDecodable, decodeText} from './text-codec.js'; + +/** + * A buffered, non-blocking byte source over a `ReadableStream` (IO-11–IO-24). + * + * Peek and slice views are instances of this same class over the same `RetentionWindow`, differing only in + * their cursor, their byte budget, and whether they own the window. A second class would need either + * inheritance — which styleguide 6.4 reserves for `Error` hierarchies — or ten duplicated delegating + * methods. + * + * Takes no `AbortSignal` and imposes no timeout: IO-40 assigns deadlines and prompt cancellation of + * blocked I/O to the transport that owns the real socket. Not safe for concurrent use (IO-37). + * + * @internal + */ +export class BufferedSource { + readonly #window: RetentionWindow; + readonly #cursor: Cursor; + readonly #ownsWindow: boolean; + readonly #limit: number; + readonly #startedAt: number; + #closed = false; + + // eslint-disable-next-line max-params -- private, view-internal plumbing; peek()/slice() are the public entry points (0-2 params each) + private constructor( + window: RetentionWindow, + cursor: Cursor, + ownsWindow: boolean, + limit: number, + ) { + this.#window = window; + this.#cursor = cursor; + this.#ownsWindow = ownsWindow; + this.#limit = limit; + this.#startedAt = cursor.at; + } + + /** Wrap a caller-supplied stream (IO-30). */ + static overStream(stream: ReadableStream): BufferedSource { + const window = new RetentionWindow(stream.getReader()); + return new BufferedSource( + window, + window.register(0), + true, + Number.POSITIVE_INFINITY, + ); + } + + /** Wrap a byte array as an independent copy (IO-30). */ + static overBytes(bytes: Uint8Array): BufferedSource { + const copy = copyBytes(bytes); + return BufferedSource.overStream( + new ReadableStream({ + start(controller): void { + if (copy.length > 0) controller.enqueue(copy); + controller.close(); + }, + }), + ); + } + + /** + * Whether this source can still be read. + * + * Must consider the window, not just this instance's own flag: a peek/slice view is invalidated when + * its parent closes the window (IO-22) without anything touching the view's flag, so reading `#closed` + * alone reports an unusable view as open — making the natural guard `if (!view.closed) …` take the + * throwing branch every time, which is the opposite of what exposing the flag is for. + */ + get closed(): boolean { + return this.#closed || this.#window.closed; + } + + /** Read up to `count` bytes onto `dest`'s tail (IO-1, IO-2, IO-3). */ + async read(dest: ByteQueue, count: number): Promise { + assertCount(count); + this.#assertOpen(); + // IO-2 before any exhaustion determination — a zero-count read is 0, never END_OF_STREAM. + if (count === 0) return 0; + const want = Math.min(count, this.#remainingBudget()); + if (want <= 0) return END_OF_STREAM; + const available = await this.#window.pullThrough(this.#cursor.at + 1); + if (!available) return END_OF_STREAM; + return this.#window.readInto(this.#cursor, dest, want); + } + + /** True exactly when no more bytes are available (IO-11). */ + async exhausted(): Promise { + this.#assertOpen(); + if (this.#remainingBudget() <= 0) return true; + return !(await this.#window.pullThrough(this.#cursor.at + 1)); + } + + /** The next byte, or a failure at end of stream (IO-11). */ + async readByte(): Promise { + const [value] = await this.readExactly(1); + invariant(value !== undefined, 'readExactly(1) returned an empty array'); + return value; + } + + /** Every remaining byte; empty when already exhausted (IO-11). */ + async readBytes(): Promise { + this.#assertOpen(); + const staging = new ByteQueue(); + while ((await this.read(staging, READ_CHUNK)) !== END_OF_STREAM) { + // IO-9: check as we go, not at the `snapshot()` at the end. A count-less read cannot know the + // total up front, but deferring the check until materialization means a multi-gigabyte body is + // fully buffered first — so the process is far likelier to die of a low-level allocation failure + // than to reach the actionable refusal IO-9 exists to produce. + assertAllocatable(staging.size); + } + return staging.snapshot(); + } + + /** Exactly `count` bytes, or a failure — never a short result (IO-12). */ + async readExactly(count: number): Promise { + assertCount(count); + this.#assertOpen(); + // IO-9: refuse eagerly with an actionable error. Routing this through ByteQueue would raise + // EndOfStreamError instead, since takeBytes checks its size before it ever tries to allocate. + assertAllocatable(count); + const staging = new ByteQueue(); + while (staging.size < count) { + const read = await this.read(staging, count - staging.size); + if (read === END_OF_STREAM) + throw new EndOfStreamError(staging.size, count); + } + return staging.takeBytes(count); + } + + /** Decode `count` bytes (or every remaining byte) as UTF-8 (IO-13). */ + async readUtf8(count?: number): Promise { + return this.readString('utf-8', count); + } + + /** Decode `count` bytes (or every remaining byte) with an explicit charset (IO-13). */ + async readString(charset: string, count?: number): Promise { + this.#assertOpen(); + // Reject an unusable label BEFORE consuming bytes, so a bad charset does not also destroy the body. + assertDecodable(charset); + const raw = + count === undefined + ? await this.readBytes() + : await this.readExactly(count); + return decodeText(raw, charset); + } + + /** + * The next line as UTF-8, with its terminator consumed (IO-14). + * + * Both `\n` and `\r\n` terminate. A lone `\r` not followed by `\n` stays line content, which falls out + * of scanning only for `\n`. Returns the final unterminated line as-is, and `undefined` when the source + * is exhausted before any byte — `undefined` rather than the spec's language-agnostic "null", per + * styleguide 3.5. + * + * Scans with a NON-CONSUMING peek before reading, deliberately. Reading first and pushing back the + * over-read cannot work: every read advances this cursor and `RetentionWindow.readInto` then trims the + * queue head to the slowest cursor, so the bytes past the terminator are already discarded by the time + * anything could rewind over them. Peeking leaves the cursor still, so the bytes stay retained, and the + * subsequent `readExactly` consumes exactly the line plus its terminator. + */ + async readUtf8Line(): Promise { + this.#assertOpen(); + const at = await this.#scanForNewline(); + if (at === END_OF_STREAM) { + const rest = await this.readBytes(); + return rest.length === 0 ? undefined : decodeText(rest, 'utf-8'); + } + const line = await this.readExactly(at + 1); + const end = at > 0 && line[at - 1] === CARRIAGE_RETURN ? at - 1 : at; + // Decoding goes through `decodeText`, which sets `ignoreBOM`. A per-line decoder with the default + // would strip U+FEFF from the front of EVERY line, not just the stream's first — see the note on + // `decodeText`, and SSE-12, which requires a mid-stream BOM to survive as ordinary data. + return decodeText(line.subarray(0, end), 'utf-8'); + } + + /** + * Offset of the next `\n` relative to this cursor, or `END_OF_STREAM` if the source ends first. + * Never advances the cursor. Retention grows by one line's length, which is what IO-14 requires and + * all it requires. + */ + async #scanForNewline(): Promise { + let searched = 0; + for (;;) { + const available = Math.min( + this.#window.availableFrom(this.#cursor), + this.#remainingBudget(), + ); + if (available > searched) { + // Peek ONLY the bytes pulled since the last pass. Re-peeking the whole scanned prefix each time + // makes this quadratic in bytes copied, with no line-length bound — and this is the primitive + // header and chunked-encoding parsing run over attacker-controlled bytes, so a peer that + // dribbles a long newline-free line would pin a CPU core. + const tail = this.#window.peekBytes( + this.#cursor, + searched, + available - searched, + ); + const found = tail.indexOf(NEWLINE); + if (found >= 0) return searched + found; + searched = available; + } + if (searched >= this.#remainingBudget()) return END_OF_STREAM; + if (!(await this.#window.pullThrough(this.#cursor.at + searched + 1))) + return END_OF_STREAM; + } + } + + /** + * A non-consuming view over the whole remaining source (IO-19). Reads from it never advance this + * source's cursor. + * + * Deliberately uncapped: §5 bounds nothing, and every buffering cap the product spec mandates lives in + * §6 (Phase 3b). See `RetentionWindow` for why a cap here would partially fail IO-19. + */ + peek(): BufferedSource { + this.#assertOpen(); + return new BufferedSource( + this.#window, + this.#window.register(this.#cursor.at), + false, + this.#remainingBudget(), + ); + } + + /** + * A non-consuming, length-bounded view exposing at most `count` bytes starting `offset` ahead of this + * cursor (IO-20). + * + * Offset overflow is detected LAZILY — an offset past the source size constructs fine and surfaces as + * an empty read (IO-21) — because callers may slice speculatively before the body length is known. A + * negative offset or count is rejected eagerly. A slice of a slice composes additively and caps at the + * outer slice's remaining budget (IO-23). + */ + slice(offset: number, count: number): BufferedSource { + invariant( + Number.isInteger(offset) && offset >= 0, + `offset must be a non-negative integer, got ${String(offset)}`, + ); + assertCount(count); + this.#assertOpen(); + const budget = Math.max( + 0, + Math.min(count, this.#remainingBudget() - offset), + ); + return new BufferedSource( + this.#window, + this.#window.register(this.#cursor.at + offset), + false, + budget, + ); + } + + /** Advance past exactly `count` bytes; `skip(0)` is a no-op even at end of stream (IO-15). */ + async skip(count: number): Promise { + assertCount(count); + this.#assertOpen(); + if (count === 0) return; + const staging = new ByteQueue(); + let skipped = 0; + while (skipped < count) { + const read = await this.read(staging, count - skipped); + if (read === END_OF_STREAM) throw new EndOfStreamError(skipped, count); + skipped += read; + staging.clear(); + } + } + + /** + * IO-41: idempotent. A view releases only its own cursor and never closes its parent or moves the + * parent's cursor (IO-22); the owning source closes the window, which invalidates every outstanding + * view. + */ + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + if (!this.#ownsWindow) { + this.#window.release(this.#cursor); + return; + } + // Awaited, so the promise settles only once the underlying reader is really cancelled and its lock + // released — and rejects if that teardown fails, rather than reporting a success that never happened. + await this.#window.close(); + } + + /** IO-42: a stream-backed source rejects reads after close, unlike an in-memory `ByteQueue`. */ + #assertOpen(): void { + if (this.#closed) throw new ClosedResourceError('BufferedSource'); + this.#window.assertUsable(); + } + + /** + * A read-only host-native byte-stream bridge (IO-16). Closing the bridge closes the owning source. + * + * For this port the host-native byte stream IS `ReadableStream` — that is `sdk-design/03` §3.1's whole + * premise, and it keeps core free of any `node:` import. A consumer wanting a Node `Readable` calls + * `Readable.fromWeb()` at their own edge. + */ + toReadableStream(): ReadableStream { + return new ReadableStream({ + pull: async (controller): Promise => { + const staging = new ByteQueue(); + let read: number; + try { + read = await this.read(staging, BRIDGE_CHUNK); + } catch (e: unknown) { + // A mid-stream read failure errors the bridge, and `cancel` is NOT invoked on an errored + // stream — so without this the reader lock and the retention window are both stranded on + // exactly the failure path that matters most for a connection-backed source. + await this.close().catch(() => undefined); + throw e; + } + if (read === END_OF_STREAM) { + // Close the BRIDGE only. IO-16 requires that closing the bridge close the owning source, and + // `cancel` below does that; auto-closing at natural EOF is an extra step that would tear down + // the whole RetentionWindow and invalidate every outstanding peek/slice view — defeating + // IO-19's stated purpose (previews, replay) for its most natural usage: take a preview, hand + // the bridge to the transport, read the preview afterwards. + controller.close(); + return; + } + controller.enqueue(staging.snapshot()); + }, + cancel: async (): Promise => { + await this.close(); + }, + }); + } + + #remainingBudget(): number { + if (this.#limit === Number.POSITIVE_INFINITY) + return Number.POSITIVE_INFINITY; + return Math.max(0, this.#limit - (this.#cursor.at - this.#startedAt)); + } +} + +/** How much a bulk drain asks for per iteration. Not a retention bound — `read` transfers, never buffers. */ +const READ_CHUNK = 16 * 1024; +const BRIDGE_CHUNK = 16 * 1024; +const NEWLINE = 0x0a; +const CARRIAGE_RETURN = 0x0d; + +function assertCount(count: number): void { + invariant( + Number.isInteger(count) && count >= 0, + `count must be a non-negative integer, got ${String(count)}`, + ); +} diff --git a/packages/core/src/io/buffered-source.views.test.ts b/packages/core/src/io/buffered-source.views.test.ts new file mode 100644 index 0000000..5886a78 --- /dev/null +++ b/packages/core/src/io/buffered-source.views.test.ts @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/io/buffered-source.views.test.ts +// Exercises: IO-19 (peek is non-consuming over the whole remaining source), IO-20 (bounded slice), +// IO-21 (lazy offset overflow, eager negative rejection), IO-22 (closing a slice does not close the +// parent; closing the parent invalidates slices), IO-23 (independence, additive composition), +// IO-24 (reading a closed slice is a state error, distinct from EOF), +// IO-16 (the readable bridge: cancel closes the source, EOF does not, a failed read does) +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import {BufferedSource} from './buffered-source.js'; +import {ClosedResourceError} from './errors.js'; +import {drainStream, fakeReadableStream} from './test-support/fake-stream.js'; +import {rejection} from './test-support/rejection.js'; + +const bytes = (...values: number[]): Uint8Array => Uint8Array.from(values); + +const sourceOver = (...chunks: Uint8Array[]): BufferedSource => + BufferedSource.overStream(fakeReadableStream(chunks)); + +describe('BufferedSource views', () => { + test('IO-19: reads from a peek do not advance the original cursor', async () => { + const source = sourceOver(bytes(1, 2, 3)); + const peek = source.peek(); + expect([...(await peek.readBytes())]).toEqual([1, 2, 3]); + expect([...(await source.readBytes())]).toEqual([1, 2, 3]); + }); + + test('IO-20: a slice exposes at most count bytes starting offset ahead', async () => { + const source = sourceOver(bytes(1, 2, 3, 4, 5)); + const slice = source.slice(1, 3); + expect([...(await slice.readBytes())]).toEqual([2, 3, 4]); + }); + + test('IO-20: reading past the window behaves as end-of-window, and never advances the parent', async () => { + const source = sourceOver(bytes(1, 2, 3)); + const slice = source.slice(0, 2); + expect([...(await slice.readBytes())]).toEqual([1, 2]); + expect([...(await slice.readBytes())]).toEqual([]); + expect([...(await source.readBytes())]).toEqual([1, 2, 3]); + }); + + test('IO-21: an offset past the source size succeeds at construction and reads as empty', async () => { + const source = sourceOver(bytes(1, 2)); + const slice = source.slice(100, 4); + expect([...(await slice.readBytes())]).toEqual([]); + }); + + test('IO-21: a negative offset or count is rejected eagerly at construction', () => { + const source = sourceOver(bytes(1, 2)); + expect(() => source.slice(-1, 2)).toThrow( + 'offset must be a non-negative integer, got -1', + ); + expect(() => source.slice(0, -2)).toThrow( + 'count must be a non-negative integer, got -2', + ); + }); +}); + +describe('BufferedSource view lifecycle and independence (IO-22, IO-23, IO-24)', () => { + test('IO-22: closing a slice neither closes the parent nor advances its cursor', async () => { + const source = sourceOver(bytes(1, 2, 3)); + const slice = source.slice(0, 2); + await slice.readBytes(); + await slice.close(); + expect(source.closed).toBe(false); + expect([...(await source.readBytes())]).toEqual([1, 2, 3]); + }); + + test('IO-22: closing the parent invalidates outstanding slices', async () => { + const source = sourceOver(bytes(1, 2, 3)); + const slice = source.slice(0, 2); + await source.close(); + expect(await rejection(slice.readBytes())).toBeInstanceOf( + ClosedResourceError, + ); + }); + + test('IO-24: reading an explicitly closed slice fails loudly, distinct from a normal EOF', async () => { + const source = sourceOver(bytes(1, 2, 3)); + const slice = source.slice(0, 2); + await slice.close(); + expect(await rejection(slice.readBytes())).toBeInstanceOf( + ClosedResourceError, + ); + }); + + test('IO-23: two slices of one source have independent cursors and budgets', async () => { + const source = sourceOver(bytes(1, 2, 3, 4)); + const first = source.slice(0, 2); + const second = source.slice(2, 2); + expect([...(await second.readBytes())]).toEqual([3, 4]); + expect([...(await first.readBytes())]).toEqual([1, 2]); + }); + + test('IO-23: a slice of a slice composes offsets additively and caps at the outer remainder', async () => { + const source = sourceOver(bytes(1, 2, 3, 4, 5, 6)); + const outer = source.slice(1, 4); // 2,3,4,5 + const inner = outer.slice(1, 10); // starts at 3, capped to 3 bytes: 3,4,5 + expect([...(await inner.readBytes())]).toEqual([3, 4, 5]); + }); +}); + +describe('BufferedSource view properties', () => { + test('property: an arbitrary slice reads exactly the bytes at its window', async () => { + await fc.assert( + fc.asyncProperty( + fc.uint8Array({minLength: 1, maxLength: 64}), + fc.integer({min: 0, max: 64}), + fc.integer({min: 0, max: 64}), + async (data, offset, count) => { + const source = BufferedSource.overStream(fakeReadableStream([data])); + const slice = source.slice(offset, count); + const expected = [...data.subarray(offset, offset + count)]; + expect([...(await slice.readBytes())]).toEqual(expected); + }, + ), + ); + }); + + test('property: no view read advances any other view or the parent', async () => { + await fc.assert( + fc.asyncProperty( + fc.uint8Array({minLength: 1, maxLength: 32}), + async data => { + const source = BufferedSource.overStream(fakeReadableStream([data])); + const first = source.peek(); + const second = source.peek(); + await first.readBytes(); + expect([...(await second.readBytes())]).toEqual([...data]); + expect([...(await source.readBytes())]).toEqual([...data]); + }, + ), + ); + }); +}); + +describe('BufferedSource closed reporting and bridge lifecycle (IO-16, IO-19, IO-22)', () => { + const sourceOf = (...values: number[]): BufferedSource => + BufferedSource.overBytes(bytes(...values)); + + test('IO-22: a view invalidated by its parent reports itself closed', async () => { + // Reading only the view's own flag makes the natural guard `if (!view.closed) await view.read()` + // take the throwing branch every time, which defeats the point of exposing the flag. + const source = sourceOf(1, 2, 3); + const view = source.peek(); + await source.close(); + expect(source.closed).toBe(true); + expect(view.closed).toBe(true); + }); + + test('IO-19: draining the bridge to EOF leaves outstanding previews readable', async () => { + // IO-16 requires that closing the BRIDGE close the source; auto-closing at natural EOF is an extra + // step that tears down the whole window and invalidates every peek — defeating IO-19's stated + // rationale (previews, replay) for its most natural usage. + const source = sourceOf(1, 2, 3); + const preview = source.peek(); + await drainStream(source.toReadableStream()); + expect([...(await preview.readBytes())]).toEqual([1, 2, 3]); + expect(source.closed).toBe(false); + await source.close(); + }); + + test('IO-16: cancelling the bridge closes the owning source', async () => { + const source = sourceOf(1, 2, 3); + const bridge = source.toReadableStream(); + await bridge.cancel(); + expect(source.closed).toBe(true); + }); + + test('IO-16: a mid-stream read failure closes the source instead of stranding it', async () => { + // `cancel` is NOT invoked on an errored stream, so without an explicit close here the reader lock + // and the retention window are both stranded on the failure path that matters most for a + // connection-backed source. + const stream = new ReadableStream({ + start(controller): void { + controller.enqueue(bytes(1, 2, 3)); + }, + pull(): never { + throw new Error('mid-stream read failure'); + }, + }); + const source = BufferedSource.overStream(stream); + expect(stream.locked).toBe(true); + expect( + (await rejection(drainStream(source.toReadableStream()))).message, + ).toContain('mid-stream read failure'); + expect(source.closed).toBe(true); + // The lock is what actually leaks. The underlying `cancel` callback is deliberately NOT asserted: + // per the Streams spec, cancelling an ALREADY-ERRORED stream rejects with the stored error without + // ever invoking the underlying source's cancel, so there is nothing left to observe there. + expect(stream.locked).toBe(false); + }); +}); diff --git a/packages/core/src/io/byte-queue.bench.ts b/packages/core/src/io/byte-queue.bench.ts new file mode 100644 index 0000000..5519f36 --- /dev/null +++ b/packages/core/src/io/byte-queue.bench.ts @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/io/byte-queue.bench.ts +// Baseline only — no optimization has been applied and none is justified yet (styleguide 15.1, 15.6: +// do not tune ahead of a profile). This exists so Phases 6 and 8 inherit a regression floor on the +// SDK's hottest data structure. mitata measures a warm JIT in isolation, not end-to-end throughput. +import {bench, run} from 'mitata'; +import {ByteQueue} from './byte-queue.js'; + +const SMALL = new Uint8Array(64).fill(1); +const LARGE = new Uint8Array(64 * 1024).fill(1); + +bench( + 'ByteQueue writeBytes x1000 small chunks (warm-JIT, not end-to-end)', + () => { + const queue = new ByteQueue(); + for (let i = 0; i < 1000; i += 1) queue.writeBytes(SMALL); + }, +); + +// A pool of pre-filled queues, so the two benches below measure only the operation they are named +// after. `writeBytes` COPIES, so a 64 KiB fill inside the timed closure costs about as much as the read +// it is setting up — roughly halving the sensitivity of the regression floor Phases 6 and 8 diff +// against. mitata has no per-iteration setup hook, so the fill is hoisted and the pool re-primed in +// batches instead. Only the first bench measures `writeBytes`, deliberately. +const POOL_SIZE = 256; + +function primedPool(): ByteQueue[] { + return Array.from({length: POOL_SIZE}, () => { + const queue = new ByteQueue(); + queue.writeBytes(LARGE); + return queue; + }); +} + +let readPool = primedPool(); +let readAt = 0; +const sinkQueue = new ByteQueue(); + +bench('ByteQueue read of 64 KiB, pre-filled (warm-JIT, not end-to-end)', () => { + if (readAt >= POOL_SIZE) { + readPool = primedPool(); + readAt = 0; + } + const source = readPool[readAt]; + readAt += 1; + if (source === undefined) return; + sinkQueue.clear(); + source.read(sinkQueue, source.size); +}); + +const snapshotQueue = new ByteQueue(); +snapshotQueue.writeBytes(LARGE); + +bench( + 'ByteQueue snapshot of 64 KiB, pre-filled (warm-JIT, not end-to-end)', + () => { + snapshotQueue.snapshot(); + }, +); + +await run(); diff --git a/packages/core/src/io/byte-queue.property.test.ts b/packages/core/src/io/byte-queue.property.test.ts new file mode 100644 index 0000000..d6ea9e4 --- /dev/null +++ b/packages/core/src/io/byte-queue.property.test.ts @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/io/byte-queue.property.test.ts +// Exercises: IO-7 (FIFO order across arbitrary chunk splits), IO-8 (snapshot independence), +// IO-10 (copyTo is non-consuming) +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import {ByteQueue} from './byte-queue.js'; + +const chunks = fc.array(fc.uint8Array({maxLength: 32}), {maxLength: 16}); + +describe('ByteQueue properties', () => { + test('IO-7: writing arbitrary chunks then reading back preserves byte order exactly', () => { + fc.assert( + fc.property(chunks, input => { + const queue = new ByteQueue(); + for (const chunk of input) queue.writeBytes(chunk); + const expected = input.flatMap(chunk => [...chunk]); + expect(queue.size).toBe(expected.length); + expect([...queue.snapshot()]).toEqual(expected); + }), + ); + }); + + test('IO-7: reading in arbitrary increments yields the same bytes as reading all at once', () => { + fc.assert( + fc.property( + chunks, + fc.array(fc.integer({min: 0, max: 8}), {maxLength: 32}), + (input, steps) => { + const source = new ByteQueue(); + for (const chunk of input) source.writeBytes(chunk); + const expected = input.flatMap(chunk => [...chunk]); + + const dest = new ByteQueue(); + for (const step of steps) source.read(dest, step); + source.read(dest, source.size); + + expect([...dest.snapshot()]).toEqual(expected); + }, + ), + ); + }); + + test('IO-8: a snapshot is every written byte, leaves size alone, and survives later writes', () => { + fc.assert( + fc.property(chunks, fc.uint8Array({maxLength: 16}), (input, later) => { + const queue = new ByteQueue(); + for (const chunk of input) queue.writeBytes(chunk); + const before = queue.snapshot(); + const sizeBefore = queue.size; + + // Comparing `before` against a copy of ITSELF is the trap here: both sides derive from the same + // array, so the assertion holds for any implementation — a `snapshot()` returning an empty array + // passes it. Pin the CONTENT against the input instead, so the property can actually fail. + expect([...before]).toEqual(input.flatMap(chunk => [...chunk])); + expect(queue.size).toBe(sizeBefore); + + queue.writeBytes(later); + expect([...before]).toEqual(input.flatMap(chunk => [...chunk])); + expect(queue.size).toBe(sizeBefore + later.length); + }), + ); + }); + + test('IO-10: copyTo never changes the source size', () => { + fc.assert( + fc.property(chunks, fc.integer({min: 0, max: 16}), (input, offset) => { + const source = new ByteQueue(); + for (const chunk of input) source.writeBytes(chunk); + fc.pre(offset <= source.size); + const sizeBefore = source.size; + source.copyTo(new ByteQueue(), offset); + expect(source.size).toBe(sizeBefore); + }), + ); + }); +}); diff --git a/packages/core/src/io/byte-queue.test.ts b/packages/core/src/io/byte-queue.test.ts new file mode 100644 index 0000000..2027c29 --- /dev/null +++ b/packages/core/src/io/byte-queue.test.ts @@ -0,0 +1,280 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/io/byte-queue.test.ts +// Exercises: IO-1 (tail-append, transferred count, EOF sentinel), IO-2 (zero-count read), +// IO-3 (negative count rejected before any I/O), IO-4 (exact head removal, no partial write), +// IO-7 (FIFO buffer that is simultaneously source and sink), IO-8 (snapshot/copyOut independence), +// IO-30 (a wrapped byte array is an independent copy) +import {describe, expect, test} from 'bun:test'; +import {ByteQueue} from './byte-queue.js'; +import {AllocationLimitError, EndOfStreamError} from './errors.js'; +import {END_OF_STREAM, MAX_BYTE_ARRAY_LENGTH} from './limits.js'; + +const bytes = (...values: number[]): Uint8Array => Uint8Array.from(values); + +const drain = (queue: ByteQueue): number[] => [...queue.snapshot()]; + +describe('ByteQueue read (IO-1, IO-2, IO-7)', () => { + test('starts empty', () => { + expect(new ByteQueue().size).toBe(0); + }); + + test('IO-7: bytes written through the sink surface read back through the source surface in order', () => { + const source = new ByteQueue(); + source.writeBytes(bytes(1, 2, 3)); + source.writeBytes(bytes(4, 5)); + const dest = new ByteQueue(); + expect(source.read(dest, 5)).toBe(5); + expect(drain(dest)).toEqual([1, 2, 3, 4, 5]); + expect(source.size).toBe(0); + }); + + test('IO-1: read appends to the TAIL of a non-empty destination, never overwriting', () => { + const dest = new ByteQueue(); + dest.writeBytes(bytes(9, 9)); + const source = new ByteQueue(); + source.writeBytes(bytes(1, 2)); + expect(source.read(dest, 2)).toBe(2); + expect(drain(dest)).toEqual([9, 9, 1, 2]); + }); + + test('IO-1: read never returns more than requested, and returns at least 1 when not exhausted', () => { + const source = new ByteQueue(); + source.writeBytes(bytes(1, 2, 3, 4)); + const dest = new ByteQueue(); + expect(source.read(dest, 2)).toBe(2); + expect(source.size).toBe(2); + }); + + test('IO-1: read of a partial source returns what it has, then END_OF_STREAM', () => { + const source = new ByteQueue(); + source.writeBytes(bytes(1, 2)); + const dest = new ByteQueue(); + expect(source.read(dest, 8)).toBe(2); + expect(source.read(dest, 8)).toBe(END_OF_STREAM); + }); + + test('IO-2: a zero-count read returns 0 on a non-empty source', () => { + const source = new ByteQueue(); + source.writeBytes(bytes(1)); + expect(source.read(new ByteQueue(), 0)).toBe(0); + }); + + test('IO-2: a zero-count read returns 0 — NOT end-of-stream — on an exhausted source', () => { + expect(new ByteQueue().read(new ByteQueue(), 0)).toBe(0); + }); + + test('IO-3: a negative count is rejected before any transfer', () => { + const source = new ByteQueue(); + source.writeBytes(bytes(1, 2, 3)); + const dest = new ByteQueue(); + expect(() => source.read(dest, -1)).toThrow( + 'count must be a non-negative integer, got -1', + ); + expect(source.size).toBe(3); + expect(dest.size).toBe(0); + }); +}); + +describe('ByteQueue write (IO-3, IO-4)', () => { + test('IO-4: write removes exactly the requested count from the source HEAD, in order', () => { + const source = new ByteQueue(); + source.writeBytes(bytes(1, 2, 3)); + const dest = new ByteQueue(); + dest.write(source, 3); + expect(source.size).toBe(0); + expect(drain(dest)).toEqual([1, 2, 3]); + }); + + test('IO-4: writing more than the source holds throws instead of writing partially', () => { + const source = new ByteQueue(); + source.writeBytes(bytes(1, 2, 3)); + const dest = new ByteQueue(); + expect(() => { + dest.write(source, 4); + }).toThrow(EndOfStreamError); + expect(source.size).toBe(3); + expect(dest.size).toBe(0); + }); + + test('IO-3: write rejects a negative count', () => { + expect(() => { + new ByteQueue().write(new ByteQueue(), -2); + }).toThrow('count must be a non-negative integer, got -2'); + }); + + test('writeBytes copies, so mutating the caller input afterwards does not change the queue', () => { + const input = bytes(1, 2, 3); + const queue = new ByteQueue(); + queue.writeBytes(input); + input[0] = 99; + expect(drain(queue)).toEqual([1, 2, 3]); + }); + + test('a transfer that straddles chunk boundaries preserves order', () => { + const source = new ByteQueue(); + source.writeBytes(bytes(1, 2)); + source.writeBytes(bytes(3, 4)); + source.writeBytes(bytes(5, 6)); + const dest = new ByteQueue(); + expect(source.read(dest, 3)).toBe(3); + expect(drain(dest)).toEqual([1, 2, 3]); + expect(source.size).toBe(3); + const rest = new ByteQueue(); + expect(source.read(rest, 3)).toBe(3); + expect(drain(rest)).toEqual([4, 5, 6]); + }); +}); + +describe('ByteQueue snapshot and copyTo (IO-8, IO-9, IO-10)', () => { + test('IO-8: snapshot does not consume or mutate', () => { + const queue = new ByteQueue(); + queue.writeBytes(bytes(1, 2, 3)); + expect([...queue.snapshot()]).toEqual([1, 2, 3]); + expect(queue.size).toBe(3); + }); + + test('IO-8: a snapshot is independent of later mutations, in both directions', () => { + const queue = new ByteQueue(); + queue.writeBytes(bytes(1, 2, 3)); + const first = queue.snapshot(); + queue.writeBytes(bytes(4)); + expect([...first]).toEqual([1, 2, 3]); + first[0] = 99; + expect([...queue.snapshot()]).toEqual([1, 2, 3, 4]); + }); + + test('IO-9: materializing past the limit fails with an actionable error, not an allocation crash', () => { + const queue = new ByteQueue(); + queue.writeBytes(bytes(1, 2, 3)); + expect(() => queue.takeBytes(MAX_BYTE_ARRAY_LENGTH + 1)).toThrow( + AllocationLimitError, + ); + }); + + test('IO-10: copyTo copies a window without consuming or mutating the source', () => { + const source = new ByteQueue(); + source.writeBytes(bytes(1, 2, 3, 4, 5)); + const dest = new ByteQueue(); + source.copyTo(dest, 1, 3); + expect([...dest.snapshot()]).toEqual([2, 3, 4]); + expect(source.size).toBe(5); + }); + + test('IO-10: copyTo defaults to offset-through-end', () => { + const source = new ByteQueue(); + source.writeBytes(bytes(1, 2, 3, 4)); + const dest = new ByteQueue(); + source.copyTo(dest, 2); + expect([...dest.snapshot()]).toEqual([3, 4]); + }); + + test('IO-10: copyTo rejects an out-of-range window', () => { + const source = new ByteQueue(); + source.writeBytes(bytes(1, 2, 3)); + expect(() => { + source.copyTo(new ByteQueue(), 2, 5); + }).toThrow('copy window 2..7 exceeds size 3'); + expect(() => { + source.copyTo(new ByteQueue(), -1); + }).toThrow('offset must be a non-negative integer, got -1'); + }); + + test('IO-10: clear discards every byte', () => { + const queue = new ByteQueue(); + queue.writeBytes(bytes(1, 2, 3)); + queue.clear(); + expect(queue.size).toBe(0); + expect([...queue.snapshot()]).toEqual([]); + }); +}); + +describe('ByteQueue takeBytes and skip', () => { + test('takeBytes consumes exactly the requested count', () => { + const queue = new ByteQueue(); + queue.writeBytes(bytes(1, 2, 3, 4)); + expect([...queue.takeBytes(2)]).toEqual([1, 2]); + expect(queue.size).toBe(2); + }); + + test('takeBytes past the end throws rather than returning short', () => { + const queue = new ByteQueue(); + queue.writeBytes(bytes(1, 2)); + expect(() => queue.takeBytes(3)).toThrow(EndOfStreamError); + }); + + test('skip discards from the head and returns how many it discarded', () => { + const queue = new ByteQueue(); + queue.writeBytes(bytes(1, 2, 3, 4)); + expect(queue.skip(2)).toBe(2); + expect([...queue.snapshot()]).toEqual([3, 4]); + expect(queue.skip(9)).toBe(2); + expect(queue.size).toBe(0); + }); +}); + +describe('ByteQueue close (IO-41, IO-42)', () => { + test('IO-41: close is idempotent — a second close does not throw', () => { + const queue = new ByteQueue(); + queue.close(); + expect(() => { + queue.close(); + }).not.toThrow(); + expect(queue.closed).toBe(true); + }); + + test('IO-42: a purely in-memory buffer stays readable and writable after close', () => { + // IO-42 carves this out explicitly, and Phase 3b depends on it: snapshot-after-close is how + // post-mortem body logging works. Making an in-memory buffer throw here is one of the two + // directions IO-42 names as the porter's trap; the other is Task 6's stream-backed source, which + // MUST reject after close. + const queue = new ByteQueue(); + queue.writeBytes(bytes(1, 2, 3)); + queue.close(); + expect([...queue.snapshot()]).toEqual([1, 2, 3]); + expect(() => { + queue.writeBytes(bytes(4)); + }).not.toThrow(); + expect(queue.read(new ByteQueue(), 1)).toBe(1); + }); +}); + +describe('ByteQueue input independence (IO-8, IO-30)', () => { + test('IO-30: a Node Buffer is COPIED, not aliased, so mutating it afterwards changes nothing', () => { + // `Buffer.prototype.slice` is an alias for `subarray`, so `bytes.slice()` would hand the queue a + // view over the caller's memory. A Buffer — very often a pooled one from a socket read — is the + // most likely input type in a Node SDK, which makes this the case IO-30 most needs to hold. + const buffer = Buffer.from('SECRET'); + const queue = new ByteQueue(); + queue.writeBytes(buffer); + buffer.fill(0x58); + expect(new TextDecoder().decode(queue.snapshot())).toBe('SECRET'); + }); + + test('IO-30: a retained chunk survives its pooled backing buffer being reused', () => { + const pool = Buffer.allocUnsafe(32); + pool.write('FIRSTCHUNK', 0, 'latin1'); + const queue = new ByteQueue(); + queue.writeBytes(pool.subarray(0, 10)); + pool.write('OVERWRITTEN', 0, 'latin1'); + expect(new TextDecoder().decode(queue.snapshot())).toBe('FIRSTCHUNK'); + }); + + test('copyOut returns an independent window without consuming', () => { + const queue = new ByteQueue(); + queue.writeBytes(bytes(1, 2, 3)); + queue.writeBytes(bytes(4, 5)); + expect([...queue.copyOut(1, 3)]).toEqual([2, 3, 4]); + expect([...queue.copyOut(3)]).toEqual([4, 5]); + expect(queue.size).toBe(5); + const window = queue.copyOut(0, 2); + window[0] = 99; + expect([...queue.copyOut(0, 2)]).toEqual([1, 2]); + }); + + test('copyOut rejects a window past the end', () => { + const queue = new ByteQueue(); + queue.writeBytes(bytes(1, 2)); + expect(() => queue.copyOut(0, 3)).toThrow(); + expect(() => queue.copyOut(-1, 1)).toThrow(); + }); +}); diff --git a/packages/core/src/io/byte-queue.ts b/packages/core/src/io/byte-queue.ts new file mode 100644 index 0000000..161948b --- /dev/null +++ b/packages/core/src/io/byte-queue.ts @@ -0,0 +1,321 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/io/byte-queue.ts +import {invariant} from '../invariant.js'; +import {AllocationLimitError, EndOfStreamError} from './errors.js'; +import { + assertAllocatable, + END_OF_STREAM, + MAX_BYTE_ARRAY_LENGTH, +} from './limits.js'; + +/** + * One node in the queue's chunk list. `bytes` is never mutated after the node is linked in, which is what + * makes zero-copy `subarray` transfers between queues safe; `start` is the first byte not yet consumed. + */ +interface Chunk { + readonly bytes: Uint8Array; + start: number; + next: Chunk | undefined; +} + +/** + * A FIFO byte queue that is simultaneously a source and a sink (IO-7). + * + * Synchronous throughout: pure memory has nothing to wait for, so making it async would allocate a Promise + * on the SDK's hottest data structure (styleguide 15.4) and force every downstream synchronous consumer to + * become async for no I/O reason. `BufferedSource`/`BufferedSink` are the async surfaces. + * + * Not safe for concurrent use (IO-37); callers serialize access. + * + * @internal + */ +export class ByteQueue { + #head: Chunk | undefined = undefined; + #tail: Chunk | undefined = undefined; + #size = 0; + #closed = false; + /** Bumped whenever bytes leave the head, invalidating the memoized seek position. */ + #generation = 0; + #seekChunk: Chunk | undefined = undefined; + #seekStart = 0; + #seekGeneration = -1; + + /** Bytes currently held (IO-7). */ + get size(): number { + return this.#size; + } + + /** Whether `close()` has been called. */ + get closed(): boolean { + return this.#closed; + } + + /** + * Append an independent copy of `bytes` to the tail. The copy is what lets IO-30's byte-array-wrapping + * factory promise that mutating the caller's input afterwards does not change the source, and it is + * what makes the `Chunk.bytes` "never mutated after linked in" invariant — and therefore `#moveTo`'s + * zero-copy `subarray` transfers — safe. + */ + writeBytes(bytes: Uint8Array): void { + if (bytes.length === 0) return; + this.#append(copyBytes(bytes)); + } + + /** + * Move up to `count` bytes from this queue's head onto `dest`'s tail (IO-1). + * + * Returns the number transferred: at least 1 when `count` is positive and the queue is not exhausted, + * exactly 0 when `count` is 0, `END_OF_STREAM` at end, and never more than requested. + */ + read(dest: ByteQueue, count: number): number { + assertCount(count); + // IO-2 is checked BEFORE exhaustion, deliberately: a zero-count read returns 0 even on an exhausted + // queue, and must never collapse to END_OF_STREAM. Reordering these two lines breaks IO-2. + if (count === 0) return 0; + if (this.#size === 0) return END_OF_STREAM; + const take = Math.min(count, this.#size); + this.#moveTo(dest, take); + return take; + } + + /** + * Move exactly `count` bytes from `src`'s head onto this queue's tail (IO-4). Fails rather than + * transferring a partial amount when `src` holds fewer. + */ + write(src: ByteQueue, count: number): void { + assertCount(count); + if (src.#size < count) throw new EndOfStreamError(src.#size, count); + src.#moveTo(this, count); + } + + /** + * A fresh, independent copy of the current contents, without consuming or mutating (IO-8). Later + * mutations do not affect a returned snapshot, and vice versa. + */ + snapshot(): Uint8Array { + return this.#materialize(0, this.#size); + } + + /** + * A fresh, independent copy of the window `[offset, offset + count)`, without consuming or mutating. + * + * The single-copy counterpart of `copyTo` for callers that want bytes rather than another queue: + * `copyTo(staging, ...)` followed by `staging.snapshot()` materializes the same window twice, which is + * what made `BufferedSource`'s newline scan quadratic. + */ + copyOut(offset: number, count?: number): Uint8Array { + invariant( + Number.isInteger(offset) && offset >= 0, + `offset must be a non-negative integer, got ${String(offset)}`, + ); + const length = count ?? this.#size - offset; + assertCount(length); + invariant( + offset + length <= this.#size, + `copy window ${String(offset)}..${String(offset + length)} exceeds size ${String(this.#size)}`, + ); + return this.#materialize(offset, length); + } + + /** + * Copy the window `[offset, offset + count)` into `dest` WITHOUT consuming or mutating this queue + * (IO-10). `count` defaults to "from offset through end". An out-of-range window is rejected. + */ + copyTo(dest: ByteQueue, offset: number, count?: number): void { + invariant( + Number.isInteger(offset) && offset >= 0, + `offset must be a non-negative integer, got ${String(offset)}`, + ); + const length = count ?? this.#size - offset; + assertCount(length); + invariant( + offset + length <= this.#size, + `copy window ${String(offset)}..${String(offset + length)} exceeds size ${String(this.#size)}`, + ); + if (length === 0) return; + dest.#append(this.#materialize(offset, length)); + } + + /** Consume and return exactly `count` bytes, failing rather than returning short. */ + takeBytes(count: number): Uint8Array { + assertCount(count); + // IO-9 before IO-4/IO-12's short-source check: an over-limit request is refused with an actionable + // AllocationLimitError even when the queue also happens to be short, rather than surfacing as an + // ordinary EndOfStreamError that hides the real problem. + assertAllocatable(count); + if (count > this.#size) throw new EndOfStreamError(this.#size, count); + const out = this.#materialize(0, count); + this.#discard(count); + return out; + } + + /** Discard up to `count` bytes from the head; returns how many were actually discarded. */ + skip(count: number): number { + assertCount(count); + const dropped = Math.min(count, this.#size); + this.#discard(dropped); + return dropped; + } + + /** Discard every byte (IO-10). */ + clear(): void { + this.#head = undefined; + this.#tail = undefined; + this.#size = 0; + this.#generation += 1; + } + + /** + * Copy `count` bytes starting `offset` from the head into one contiguous array (IO-9-bounded). + * + * Parameter order matches `copyTo(dest, offset, count)` deliberately: two adjacent `number`s in + * opposite orders across two methods is exactly the transposition hazard styleguide 5.5 names. + */ + #materialize(offset: number, count: number): Uint8Array { + assertAllocatable(count); + const out = allocate(count); + const seek = this.#seek(offset); + let chunk = seek.chunk; + let from = offset - seek.chunkStart; + let at = 0; + while (chunk !== undefined && at < count) { + const available = chunk.bytes.length - chunk.start; + const take = Math.min(available - from, count - at); + const start = chunk.start + from; + out.set(chunk.bytes.subarray(start, start + take), at); + at += take; + from = 0; + chunk = chunk.next; + } + return out; + } + + /** + * Position at the chunk holding logical `offset`, resuming from the last seek when it is still valid. + * + * Walking from the head every time makes a SEQUENTIAL scan quadratic in the number of chunks, which is + * how `readUtf8Line` stayed quadratic even after it was changed to peek only the newly pulled tail: a + * peer dribbling one byte per chunk produces one chunk per byte, and each peek re-walked all of them. + * Bytes are only ever appended at the tail, so a remembered position stays correct until something is + * removed from the head — which `#generation` detects. + */ + #seek(offset: number): {chunk: Chunk | undefined; chunkStart: number} { + let chunk = this.#head; + let chunkStart = 0; + if ( + this.#seekChunk !== undefined && + this.#seekGeneration === this.#generation && + this.#seekStart <= offset + ) { + chunk = this.#seekChunk; + chunkStart = this.#seekStart; + } + while (chunk !== undefined) { + const available = chunk.bytes.length - chunk.start; + if (offset < chunkStart + available) break; + chunkStart += available; + chunk = chunk.next; + } + this.#seekChunk = chunk; + this.#seekStart = chunkStart; + this.#seekGeneration = this.#generation; + return {chunk, chunkStart}; + } + + #discard(count: number): void { + if (count > 0) this.#generation += 1; + let remaining = count; + while (remaining > 0) { + const head = this.#head; + invariant(head !== undefined, 'byte-queue underflow during discard'); + const take = Math.min(head.bytes.length - head.start, remaining); + head.start += take; + remaining -= take; + if (head.start === head.bytes.length) this.#dropHead(); + } + this.#size -= count; + } + + /** + * Mark this queue closed (IO-41 — idempotent, the underlying resource released at most once). + * + * Deliberately leaves the read/write surface usable: IO-42 exempts a purely in-memory buffer so that + * snapshot-after-close body logging still works. A queue owns no external resource, so there is nothing + * else to release here. Invalidating derived views is `RetentionWindow`'s job, not this class's — views + * are cursors over a window, never over a bare queue. + */ + close(): void { + this.#closed = true; + } + + /** Caller owns the source-side size accounting; `#dropHead` deliberately does not touch `#size`. */ + #moveTo(dest: ByteQueue, count: number): void { + if (count > 0) this.#generation += 1; + let remaining = count; + while (remaining > 0) { + const head = this.#head; + invariant(head !== undefined, 'byte-queue underflow during move'); + const take = Math.min(head.bytes.length - head.start, remaining); + dest.#append(head.bytes.subarray(head.start, head.start + take)); + head.start += take; + remaining -= take; + if (head.start === head.bytes.length) this.#dropHead(); + } + this.#size -= count; + } + + #append(bytes: Uint8Array): void { + const chunk: Chunk = {bytes, start: 0, next: undefined}; + if (this.#tail === undefined) this.#head = chunk; + else this.#tail.next = chunk; + this.#tail = chunk; + this.#size += bytes.length; + } + + #dropHead(): void { + const head = this.#head; + invariant(head !== undefined, 'byte-queue drop with no head'); + this.#head = head.next; + if (this.#head === undefined) this.#tail = undefined; + } +} + +function assertCount(count: number): void { + invariant( + Number.isInteger(count) && count >= 0, + `count must be a non-negative integer, got ${String(count)}`, + ); +} + +/** + * A genuinely independent copy of `bytes`. + * + * `bytes.slice()` is NOT sufficient: a Node `Buffer` is a `Uint8Array` subclass whose own + * `slice` is an alias for `subarray`, so it returns an aliasing view over the caller's memory. Since a + * `Buffer` — very often a pooled one handed over by a socket read — is the single most likely input type + * in a Node SDK, that would silently break IO-30's independence guarantee. Allocating + * a fresh array and `set` copies into it unconditionally, whatever the argument's subclass does. + * + * @internal + */ +export function copyBytes(bytes: Uint8Array): Uint8Array { + const out = new Uint8Array(bytes.length); + out.set(bytes); + return out; +} + +/** + * IO-9's backstop. The eager `MAX_BYTE_ARRAY_LENGTH` check is deliberately conservative, so a host whose + * real ceiling is lower would otherwise surface a raw `RangeError` — exactly the "low-level allocation + * crash" IO-9 exists to prevent. + */ +function allocate(count: number): Uint8Array { + try { + return new Uint8Array(count); + } catch (e: unknown) { + if (e instanceof RangeError) { + throw new AllocationLimitError(count, MAX_BYTE_ARRAY_LENGTH, {cause: e}); + } + throw e; + } +} diff --git a/packages/core/src/io/errors.test.ts b/packages/core/src/io/errors.test.ts new file mode 100644 index 0000000..4038132 --- /dev/null +++ b/packages/core/src/io/errors.test.ts @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/io/errors.test.ts +// Exercises: IO-4/IO-11/IO-12/IO-15 (EndOfStreamError), IO-17 (SourceContractViolationError), +// IO-24/IO-42 (ClosedResourceError), IO-9 (AllocationLimitError) +import {describe, expect, test} from 'bun:test'; +import {DexpaceError} from '../http/errors.js'; +import { + AllocationLimitError, + ClosedResourceError, + EndOfStreamError, + IoError, + SourceContractViolationError, +} from './errors.js'; + +describe('IoError tree', () => { + test('IoError descends from DexpaceError', () => { + expect(new IoError('boom')).toBeInstanceOf(DexpaceError); + }); + + test('every leaf descends from IoError', () => { + expect(new EndOfStreamError(3, 8)).toBeInstanceOf(IoError); + expect(new SourceContractViolationError('zero read')).toBeInstanceOf( + IoError, + ); + expect(new ClosedResourceError('BufferedSource')).toBeInstanceOf(IoError); + expect(new AllocationLimitError(9, 8)).toBeInstanceOf(IoError); + }); + + test('each error sets name from its own constructor', () => { + expect(new EndOfStreamError(3, 8).name).toBe('EndOfStreamError'); + expect(new ClosedResourceError('ByteQueue').name).toBe( + 'ClosedResourceError', + ); + }); + + test('EndOfStreamError names delivered-of-requested as typed fields and in the message', () => { + const error = new EndOfStreamError(3, 8); + expect(error.delivered).toBe(3); + expect(error.requested).toBe(8); + expect(error.message).toBe('end of stream: delivered 3 of 8 bytes'); + }); + + test('ClosedResourceError names the resource and is distinct from end-of-stream', () => { + const error = new ClosedResourceError('BufferedSource'); + expect(error.message).toBe('BufferedSource is closed'); + expect(error).not.toBeInstanceOf(EndOfStreamError); + }); + + test('AllocationLimitError points at streaming alternatives', () => { + const error = new AllocationLimitError(5_000, 4_000); + expect(error.requested).toBe(5_000); + expect(error.limit).toBe(4_000); + expect(error.message).toBe( + 'cannot materialize 5000 bytes as one array (limit 4000); stream the body instead', + ); + }); + + test('cause chains through', () => { + const cause = new RangeError('array too large'); + expect(new AllocationLimitError(5, 4, {cause}).cause).toBe(cause); + }); + + test('EndOfStreamError chains a cause', () => { + const cause = new Error('underlying read failure'); + expect(new EndOfStreamError(1, 2, {cause}).cause).toBe(cause); + }); + + test('ClosedResourceError chains a cause', () => { + const cause = new Error('already closed'); + expect(new ClosedResourceError('ByteQueue', {cause}).cause).toBe(cause); + }); + + test('SourceContractViolationError carries its message and descends from IoError', () => { + const error = new SourceContractViolationError('returned zero bytes'); + expect(error.message).toBe('returned zero bytes'); + expect(error.name).toBe('SourceContractViolationError'); + }); +}); diff --git a/packages/core/src/io/errors.ts b/packages/core/src/io/errors.ts new file mode 100644 index 0000000..bc53c0c --- /dev/null +++ b/packages/core/src/io/errors.ts @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/io/errors.ts +import {DexpaceError} from '../http/errors.js'; + +/** + * Root of the I/O error tree (product-spec §5). + * + * Error messages in this tree carry counts and limits, never buffer contents — these buffers hold request + * and response bodies, which routinely contain credentials and PII (styleguide 8.8). + * + * @internal + */ +export class IoError extends DexpaceError { + // bun's coverage tool never marks a bodiless subclass's implicit constructor as covered + // (undercounts function coverage); an explicit forwarding constructor is instrumented + // correctly and keeps the file above the 80% function-coverage floor without changing behavior. + // eslint-disable-next-line @typescript-eslint/no-useless-constructor -- see comment above + constructor(message: string, options?: ErrorOptions) { + super(message, options); + } +} + +/** + * A source ended before delivering the requested number of bytes (IO-11, IO-12, IO-15), or a sink write + * found fewer bytes in its source buffer than requested (IO-4). + * + * @internal + */ +export class EndOfStreamError extends IoError { + readonly delivered: number; + readonly requested: number; + + constructor(delivered: number, requested: number, options?: ErrorOptions) { + super( + `end of stream: delivered ${String(delivered)} of ${String(requested)} bytes`, + options, + ); + this.delivered = delivered; + this.requested = requested; + } +} + +/** + * A foreign source violated the read protocol — most commonly by returning zero bytes for a positive + * requested count, which IO-17 requires be raised rather than tolerated as end-of-stream or spun on. + * + * @internal + */ +export class SourceContractViolationError extends IoError { + // See IoError's constructor above: keeps this bodiless subclass registered for bun's + // function coverage. + // eslint-disable-next-line @typescript-eslint/no-useless-constructor -- see comment above + constructor(message: string, options?: ErrorOptions) { + super(message, options); + } +} + +/** + * A closed source, sink, buffer, or view was used (IO-42), or a view outlived the parent that invalidated + * it (IO-22). Distinct from `EndOfStreamError` by requirement — IO-24 demands a closed view fail loudly + * with a state error rather than looking like a normal exhaustion. + * + * @internal + */ +export class ClosedResourceError extends IoError { + readonly resource: string; + + constructor(resource: string, options?: ErrorOptions) { + super(`${resource} is closed`, options); + this.resource = resource; + } +} + +/** + * A materialization would exceed the maximum single-array allocation (IO-9). The message points at the + * streaming alternative, as IO-9 requires. + * + * @internal + */ +export class AllocationLimitError extends IoError { + readonly requested: number; + readonly limit: number; + + constructor(requested: number, limit: number, options?: ErrorOptions) { + super( + `cannot materialize ${String(requested)} bytes as one array (limit ${String(limit)}); stream the body instead`, + options, + ); + this.requested = requested; + this.limit = limit; + } +} diff --git a/packages/core/src/io/factories.test.ts b/packages/core/src/io/factories.test.ts new file mode 100644 index 0000000..2026a38 --- /dev/null +++ b/packages/core/src/io/factories.test.ts @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/io/factories.test.ts +// Exercises: IO-30 (factory half — fresh, independent, empty buffers; stream, byte-array, and +// foreign-primitive wrapping; the byte-array source is an independent copy), IO-17 (a primitive +// source returning 0 for a positive request fails loudly) +import {describe, expect, test} from 'bun:test'; +import {SourceContractViolationError} from './errors.js'; +import { + bufferedSinkOverPrimitive, + bufferedSinkOverStream, + bufferedSourceOverBytes, + bufferedSourceOverPrimitive, + bufferedSourceOverStream, + newByteQueue, +} from './factories.js'; +import {END_OF_STREAM} from './limits.js'; +import { + collectingWritableStream, + fakeReadableStream, +} from './test-support/fake-stream.js'; +import {rejection} from './test-support/rejection.js'; + +describe('IO-30 factories', () => { + test('two buffers are distinct and both empty', () => { + const first = newByteQueue(); + const second = newByteQueue(); + expect(first).not.toBe(second); + expect(first.size).toBe(0); + expect(second.size).toBe(0); + }); + + test('buffers are independent — writing to one does not affect the other', () => { + const first = newByteQueue(); + const second = newByteQueue(); + first.writeBytes(Uint8Array.from([1, 2])); + expect(second.size).toBe(0); + }); + + test('wrapping a byte array then mutating the input leaves the source unchanged', async () => { + const input = Uint8Array.from([1, 2, 3]); + const source = bufferedSourceOverBytes(input); + input[0] = 99; + expect([...(await source.readBytes())]).toEqual([1, 2, 3]); + }); + + test('wrapping a caller stream produces a readable source', async () => { + const source = bufferedSourceOverStream( + fakeReadableStream([Uint8Array.from([7, 8])]), + ); + expect([...(await source.readBytes())]).toEqual([7, 8]); + }); + + test('wrapping a caller stream produces a writable sink', async () => { + const {stream, written} = collectingWritableStream(); + const sink = bufferedSinkOverStream(stream); + await sink.writeUtf8('hi'); + await sink.close(); + expect(new TextDecoder().decode(written())).toBe('hi'); + }); + + test('wrapping a foreign primitive source supplies the typed reads', async () => { + const backing = newByteQueue(); + backing.writeBytes(Uint8Array.from([1, 2, 3])); + const source = bufferedSourceOverPrimitive({ + read: (dest, count) => backing.read(dest, count), + }); + expect([...(await source.readBytes())]).toEqual([1, 2, 3]); + }); + + test('IO-17: a primitive source returning 0 for a positive request fails loudly', async () => { + const source = bufferedSourceOverPrimitive({read: () => 0}); + expect(await rejection(source.readBytes())).toBeInstanceOf( + SourceContractViolationError, + ); + }); + + test('wrapping a foreign primitive sink supplies the typed writes', async () => { + const collected = newByteQueue(); + const sink = bufferedSinkOverPrimitive({ + write: (src, count) => { + collected.write(src, count); + }, + }); + await sink.writeUtf8('hi'); + await sink.close(); + expect(new TextDecoder().decode(collected.snapshot())).toBe('hi'); + }); +}); + +describe('bufferedSourceOverPrimitive residue handling (IO-1, IO-17)', () => { + test('a primitive that appends more than it reports fails loudly instead of losing bytes', async () => { + // A staging queue hoisted into the closure and drained by `read` rather than by its size leaves the + // excess at the head, where it is both dropped from its own pull and re-emitted out of order on the + // next one — 8 bytes in, 4 out, no error at all. + let call = 0; + const source = bufferedSourceOverPrimitive({ + read(dest): number { + call += 1; + if (call > 2) return END_OF_STREAM; + const base = call === 1 ? 10 : 20; + dest.writeBytes(Uint8Array.from([base, base + 1, base + 2, base + 3])); + return 2; + }, + }); + expect(await rejection(source.readBytes())).toBeInstanceOf( + SourceContractViolationError, + ); + }); + + test('a primitive that appends bytes at end of stream fails loudly', async () => { + const source = bufferedSourceOverPrimitive({ + read(dest): number { + dest.writeBytes(Uint8Array.from([1, 2])); + return END_OF_STREAM; + }, + }); + expect(await rejection(source.readBytes())).toBeInstanceOf( + SourceContractViolationError, + ); + }); + + test('each pull gets a fresh staging queue, so nothing carries between them', async () => { + let call = 0; + const source = bufferedSourceOverPrimitive({ + read(dest): number { + call += 1; + if (call > 2) return END_OF_STREAM; + const base = call === 1 ? 10 : 20; + dest.writeBytes(Uint8Array.from([base, base + 1])); + return 2; + }, + }); + expect([...(await source.readBytes())]).toEqual([10, 11, 20, 21]); + }); +}); diff --git a/packages/core/src/io/factories.ts b/packages/core/src/io/factories.ts new file mode 100644 index 0000000..6bf5bb8 --- /dev/null +++ b/packages/core/src/io/factories.ts @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/io/factories.ts +import {BufferedSink} from './buffered-sink.js'; +import {BufferedSource} from './buffered-source.js'; +import {ByteQueue} from './byte-queue.js'; +import {SourceContractViolationError} from './errors.js'; +import {END_OF_STREAM} from './limits.js'; + +/** + * IO-30's factory half. Named free functions rather than a namespace object, so the module stays + * tree-shakeable (styleguide 10.1, 15.9). + * + * IO-30's provider-*resolution* half — install precedence, idempotent install, caching, warning, + * de-duplication, and the IO-31–IO-36 rules it defers to — is deliberately not built. There is one + * implementation, always present, requiring no installation call; `sdk-design/03` §3.1 derives this in + * full, and it is the same permanent simplification as SEAM-5–SEAM-10. + * + * @internal + */ + +/** A fresh, independent, empty buffer (IO-30). */ +export function newByteQueue(): ByteQueue { + return new ByteQueue(); +} + +/** Wrap a caller stream as a buffered source (IO-30). */ +export function bufferedSourceOverStream( + stream: ReadableStream, +): BufferedSource { + return BufferedSource.overStream(stream); +} + +/** Wrap a byte array as a buffered source over an independent copy (IO-30). */ +export function bufferedSourceOverBytes(bytes: Uint8Array): BufferedSource { + return BufferedSource.overBytes(bytes); +} + +/** Wrap a caller stream as a buffered sink (IO-30). */ +export function bufferedSinkOverStream( + stream: WritableStream, +): BufferedSink { + return BufferedSink.overStream(stream); +} + +/** + * The raw read protocol of IO-1 — append up to `count` bytes to `dest`'s tail, return the number + * transferred or `END_OF_STREAM` — with none of the typed reads, views, or line semantics. What a + * "foreign primitive" source implements. + */ +export interface PrimitiveSource { + read(dest: ByteQueue, count: number): Promise | number; +} + +/** The raw write protocol of IO-4 — remove exactly `count` bytes from `src`'s head, push downstream. */ +export interface PrimitiveSink { + write(src: ByteQueue, count: number): Promise | void; +} + +/** How much the primitive-source adapter asks for per pull. */ +const PRIMITIVE_CHUNK = 16 * 1024; + +/** Wrap a foreign primitive source with the typed buffered surface (IO-30). */ +export function bufferedSourceOverPrimitive( + source: PrimitiveSource, +): BufferedSource { + return BufferedSource.overStream( + new ReadableStream({ + async pull(controller): Promise { + // A FRESH queue per pull, matching `bufferedSinkOverPrimitive`. Hoisting one into the closure + // and draining only `read` of it leaves any excess the primitive appended sitting at the head, + // where it is both lost from its own pull and re-emitted out of order on the next one. + const staging = new ByteQueue(); + const read = await source.read(staging, PRIMITIVE_CHUNK); + if (read === END_OF_STREAM) { + assertDrained( + staging, + 'foreign source appended bytes at end of stream', + ); + controller.close(); + return; + } + if (read === 0) { + // IO-17: a zero-byte read for a positive request is a source-contract violation — never + // tolerated as end-of-stream, never spun on. + throw new SourceContractViolationError( + 'foreign source returned 0 bytes for a positive request', + ); + } + const chunk = staging.takeBytes(read); + // IO-17: appending more than it reported is a contract violation too. Silently dropping the + // excess is how bytes go missing with no error at all. + assertDrained( + staging, + `foreign source reported ${String(read)} bytes but appended more`, + ); + controller.enqueue(chunk); + }, + }), + ); +} + +function assertDrained(staging: ByteQueue, message: string): void { + if (staging.size > 0) throw new SourceContractViolationError(message); +} + +/** Wrap a foreign primitive sink with the typed buffered surface (IO-30). */ +export function bufferedSinkOverPrimitive(sink: PrimitiveSink): BufferedSink { + return BufferedSink.overStream( + new WritableStream({ + async write(chunk): Promise { + const staging = new ByteQueue(); + staging.writeBytes(chunk); + await sink.write(staging, staging.size); + }, + }), + ); +} diff --git a/packages/core/src/io/index.ts b/packages/core/src/io/index.ts new file mode 100644 index 0000000..8fbdb99 --- /dev/null +++ b/packages/core/src/io/index.ts @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/io/index.ts +// Internal barrel for product-spec §5 (IO-1–IO-42). +// +// NOTHING here is re-exported from packages/core/src/index.ts. Every symbol is @internal, kept out of +// the api-extractor surface so Phase 3b can promote deliberately (styleguide 10.3) — or not at all, if +// it shapes BODY-1's write-to-sink around the platform's WritableStream instead of BufferedSink. +export {BufferedSink} from './buffered-sink.js'; +export {BufferedSource} from './buffered-source.js'; +export {ByteQueue, copyBytes} from './byte-queue.js'; +export { + AllocationLimitError, + ClosedResourceError, + EndOfStreamError, + IoError, + SourceContractViolationError, +} from './errors.js'; +export { + bufferedSinkOverPrimitive, + bufferedSinkOverStream, + bufferedSourceOverBytes, + bufferedSourceOverPrimitive, + bufferedSourceOverStream, + newByteQueue, + type PrimitiveSink, + type PrimitiveSource, +} from './factories.js'; +export { + assertAllocatable, + END_OF_STREAM, + MAX_BYTE_ARRAY_LENGTH, +} from './limits.js'; +export {writeAll} from './pump.js'; +export {RetentionWindow, type Cursor} from './retention-window.js'; +export type {Sink} from './sink.js'; +export {TeeSink} from './tee-sink.js'; +export {decodeText, encodeText} from './text-codec.js'; diff --git a/packages/core/src/io/limits.test.ts b/packages/core/src/io/limits.test.ts new file mode 100644 index 0000000..47c6ccb --- /dev/null +++ b/packages/core/src/io/limits.test.ts @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/io/limits.test.ts +// Exercises: IO-1 (the end-of-stream sentinel), IO-9 (maximum single-array allocation) +import {describe, expect, test} from 'bun:test'; +import {AllocationLimitError} from './errors.js'; +import { + assertAllocatable, + END_OF_STREAM, + MAX_BYTE_ARRAY_LENGTH, +} from './limits.js'; + +describe('limits', () => { + test('END_OF_STREAM is the -1 sentinel IO-1 specifies', () => { + expect(END_OF_STREAM).toBe(-1); + }); + + test('MAX_BYTE_ARRAY_LENGTH is a positive safe integer', () => { + expect(Number.isSafeInteger(MAX_BYTE_ARRAY_LENGTH)).toBe(true); + expect(MAX_BYTE_ARRAY_LENGTH).toBeGreaterThan(0); + }); + + // There is deliberately NO test that a Uint8Array of MAX_BYTE_ARRAY_LENGTH actually allocates. + // Honest verification means allocating 2 GiB, which is far too heavy for the default suite, and the + // cheap stand-in — comparing the constant against another compile-time constant — cannot fail for any + // value the constant could plausibly hold, so it reads as coverage while asserting nothing. The + // guarantee is carried instead by the RangeError backstop in `ByteQueue.allocate`, which converts a + // host whose real ceiling is lower into an AllocationLimitError (see byte-queue.test.ts). + test('MAX_BYTE_ARRAY_LENGTH stays under the 2 GiB the docs promise', () => { + expect(MAX_BYTE_ARRAY_LENGTH).toBe(2 ** 31 - 1); + }); + + test('IO-9: assertAllocatable refuses over the ceiling and permits everything at or under it', () => { + expect(() => { + assertAllocatable(MAX_BYTE_ARRAY_LENGTH + 1); + }).toThrow(AllocationLimitError); + expect(() => { + assertAllocatable(MAX_BYTE_ARRAY_LENGTH); + }).not.toThrow(); + expect(() => { + assertAllocatable(0); + }).not.toThrow(); + }); + + test('IO-9: the refusal names the limit and points at streaming alternatives', () => { + // This is the guard the count-less read path applies incrementally, so it stands in for the + // multi-gigabyte case the suite cannot afford to allocate. + const error = new AllocationLimitError( + MAX_BYTE_ARRAY_LENGTH + 1, + MAX_BYTE_ARRAY_LENGTH, + ); + expect(error.message).toContain(String(MAX_BYTE_ARRAY_LENGTH)); + }); +}); diff --git a/packages/core/src/io/limits.ts b/packages/core/src/io/limits.ts new file mode 100644 index 0000000..b7283c6 --- /dev/null +++ b/packages/core/src/io/limits.ts @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/io/limits.ts +import {AllocationLimitError} from './errors.js'; + +/** + * End-of-stream sentinel returned by every read (IO-1). + * + * The numeric protocol is kept spec-literal rather than modelled as `number | undefined`, because IO-2 + * (a zero-count read returns 0 and must NOT report end-of-stream) and, later, BODY-25 ("EOF is signaled + * only by the explicit sentinel") both reason over it. + * + * @internal + */ +export const END_OF_STREAM = -1; + +/** + * Largest byte count this package will attempt to materialize as one contiguous `Uint8Array` (IO-9). + * + * Deliberately conservative. Core is runtime-agnostic, so `node:buffer`'s constant is unavailable; V8 and + * JavaScriptCore disagree on the real ceiling and both have moved it, and rule 12.6 forbids probing at + * import time. 2 GiB − 1 is at or below every supported host's limit. Callers that exceed it get an + * actionable `AllocationLimitError` rather than a low-level allocation crash; a `RangeError` backstop at + * the allocation site covers any host whose real ceiling is lower still. + * + * @internal + */ +export const MAX_BYTE_ARRAY_LENGTH = 2 ** 31 - 1; + +/** + * IO-9's eager guard: refuse a materialization that would exceed the ceiling, with an actionable error + * that points at streaming alternatives, BEFORE any allocation is attempted. + * + * A named function rather than an inlined `if` at each site because the count-less read path has to + * apply it incrementally — it cannot know the total up front — and a rule applied in two shapes is a + * rule that drifts. + */ +export function assertAllocatable(count: number): void { + if (count > MAX_BYTE_ARRAY_LENGTH) { + throw new AllocationLimitError(count, MAX_BYTE_ARRAY_LENGTH); + } +} diff --git a/packages/core/src/io/pump.test.ts b/packages/core/src/io/pump.test.ts new file mode 100644 index 0000000..80edb15 --- /dev/null +++ b/packages/core/src/io/pump.test.ts @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/io/pump.test.ts +// Exercises: IO-17 (pump to exhaustion, terminate only on the EOF sentinel, raise a zero-read for a +// positive request as a source-contract violation) +import {describe, expect, test} from 'bun:test'; +import {BufferedSink} from './buffered-sink.js'; +import {BufferedSource} from './buffered-source.js'; +import {SourceContractViolationError} from './errors.js'; +import {writeAll} from './pump.js'; +import { + collectingWritableStream, + fakeReadableStream, + protocolViolatingStream, +} from './test-support/fake-stream.js'; +import {rejection} from './test-support/rejection.js'; + +describe('writeAll (IO-17)', () => { + test('pumps the source to exhaustion and returns the total transferred', async () => { + const source = BufferedSource.overStream( + fakeReadableStream([Uint8Array.from([1, 2]), Uint8Array.from([3, 4, 5])]), + ); + const {stream, written} = collectingWritableStream(); + const sink = BufferedSink.overStream(stream); + + expect(await writeAll(source, sink)).toBe(5); + await sink.close(); + expect([...written()]).toEqual([1, 2, 3, 4, 5]); + }); + + test('an already-exhausted source transfers zero and does not hang', async () => { + const source = BufferedSource.overStream(fakeReadableStream([])); + const {stream} = collectingWritableStream(); + expect(await writeAll(source, BufferedSink.overStream(stream))).toBe(0); + }); + + test('a source returning zero bytes for a positive request is a contract violation', async () => { + // Never tolerated as end-of-stream, and never spun on forever — a misbehaving foreign source must + // fail loudly rather than hang or truncate a body. + const source = BufferedSource.overStream(protocolViolatingStream()); + const {stream} = collectingWritableStream(); + expect( + await rejection(writeAll(source, BufferedSink.overStream(stream))), + ).toBeInstanceOf(SourceContractViolationError); + }); +}); diff --git a/packages/core/src/io/pump.ts b/packages/core/src/io/pump.ts new file mode 100644 index 0000000..50db09c --- /dev/null +++ b/packages/core/src/io/pump.ts @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/io/pump.ts +import type {BufferedSource} from './buffered-source.js'; +import {ByteQueue} from './byte-queue.js'; +import type {Sink} from './sink.js'; +import {END_OF_STREAM} from './limits.js'; + +/** How much the pump asks for per iteration. */ +const PUMP_CHUNK = 16 * 1024; + +/** + * Pump `source` to exhaustion into `sink` and return the total bytes transferred (IO-17). + * + * Terminates only on the end-of-stream sentinel. A zero-byte read for a non-zero requested count is a + * source-contract violation raised by the source itself — never tolerated here as end-of-stream, and + * never spun on. + * + * @internal + */ +export async function writeAll( + source: BufferedSource, + sink: Sink, +): Promise { + const staging = new ByteQueue(); + let total = 0; + for (;;) { + const read = await source.read(staging, PUMP_CHUNK); + if (read === END_OF_STREAM) return total; + await sink.write(staging, staging.size); + total += read; + } +} diff --git a/packages/core/src/io/retention-window.test.ts b/packages/core/src/io/retention-window.test.ts new file mode 100644 index 0000000..dfa8e77 --- /dev/null +++ b/packages/core/src/io/retention-window.test.ts @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/io/retention-window.test.ts +// Exercises: IO-19/IO-20 (non-consuming views), IO-22 (parent close invalidates views), +// IO-23 (mutually independent cursors), IO-24 (closed view fails loudly, distinct from EOF), +// IO-17 (a chunk that is not a Uint8Array is a source-contract violation), +// IO-41 (teardown is awaited, releases the reader lock, and surfaces its failure) +import {describe, expect, test} from 'bun:test'; +import {ByteQueue} from './byte-queue.js'; +import {ClosedResourceError, SourceContractViolationError} from './errors.js'; +import {RetentionWindow} from './retention-window.js'; +import {fakeReadableStream} from './test-support/fake-stream.js'; +import {rejection} from './test-support/rejection.js'; + +const bytes = (...values: number[]): Uint8Array => Uint8Array.from(values); + +const windowOver = (...chunks: Uint8Array[]): RetentionWindow => + new RetentionWindow(fakeReadableStream(chunks).getReader()); + +describe('RetentionWindow', () => { + test('pullThrough pulls until the requested logical offset is available', async () => { + const window = windowOver(bytes(1, 2), bytes(3, 4)); + expect(await window.pullThrough(3)).toBe(true); + expect(window.pulledThrough).toBeGreaterThanOrEqual(3); + }); + + test('pullThrough returns false once the stream is exhausted', async () => { + const window = windowOver(bytes(1, 2)); + expect(await window.pullThrough(5)).toBe(false); + expect(window.pulledThrough).toBe(2); + }); + + test('readInto advances only the cursor it is given', async () => { + const window = windowOver(bytes(1, 2, 3, 4)); + const first = window.register(0); + const second = window.register(0); + await window.pullThrough(4); + + const dest = new ByteQueue(); + expect(window.readInto(first, dest, 2)).toBe(2); + expect(first.at).toBe(2); + expect(second.at).toBe(0); + }); + + test('IO-23: two cursors read the same bytes independently', async () => { + const window = windowOver(bytes(1, 2, 3)); + const first = window.register(0); + const second = window.register(0); + await window.pullThrough(3); + + const a = new ByteQueue(); + const b = new ByteQueue(); + window.readInto(first, a, 3); + window.readInto(second, b, 3); + expect([...a.snapshot()]).toEqual([1, 2, 3]); + expect([...b.snapshot()]).toEqual([1, 2, 3]); + }); + + test('bytes behind the slowest cursor are trimmed, bytes at or ahead of it are retained', async () => { + const window = windowOver(bytes(1, 2, 3, 4)); + const fast = window.register(0); + const slow = window.register(0); + await window.pullThrough(4); + + window.readInto(fast, new ByteQueue(), 4); + expect(window.retainedBytes).toBe(4); // slow still needs all four + + window.readInto(slow, new ByteQueue(), 4); + expect(window.retainedBytes).toBe(0); // nobody needs them now + }); +}); + +describe('RetentionWindow trim, peek, and close', () => { + test('releasing a cursor lets the head trim forward', async () => { + const window = windowOver(bytes(1, 2, 3, 4)); + const fast = window.register(0); + const slow = window.register(0); + await window.pullThrough(4); + window.readInto(fast, new ByteQueue(), 4); + + window.release(slow); + expect(window.retainedBytes).toBe(0); + }); + + test('peekBytes materializes without advancing the cursor', async () => { + const window = windowOver(bytes(1, 2, 3)); + const cursor = window.register(0); + await window.pullThrough(3); + expect([...window.peekBytes(cursor, 0, 2)]).toEqual([1, 2]); + expect(cursor.at).toBe(0); + }); + + test('peekBytes reads a window starting `offset` ahead of the cursor', async () => { + const window = windowOver(bytes(1, 2, 3, 4, 5)); + const cursor = window.register(0); + await window.pullThrough(5); + expect([...window.peekBytes(cursor, 2, 2)]).toEqual([3, 4]); + // Clamped to what has been pulled, never over-reading past the end. + expect([...window.peekBytes(cursor, 3, 99)]).toEqual([4, 5]); + expect([...window.peekBytes(cursor, 5, 1)]).toEqual([]); + expect(cursor.at).toBe(0); + }); + + test('IO-22/IO-24: after close, any cursor use throws ClosedResourceError, not an EOF', async () => { + const window = windowOver(bytes(1, 2, 3)); + const cursor = window.register(0); + await window.pullThrough(3); + await window.close(); + + expect(() => { + window.assertUsable(); + }).toThrow(ClosedResourceError); + expect(() => window.readInto(cursor, new ByteQueue(), 1)).toThrow( + ClosedResourceError, + ); + }); + + test('IO-41: close is idempotent', async () => { + const window = windowOver(bytes(1)); + await window.close(); + await window.close(); + expect(window.closed).toBe(true); + }); +}); + +describe('RetentionWindow source-contract guards (IO-17)', () => { + /** + * A stream that yields a chunk the TYPE system says cannot occur. The cast is the point: these values + * arrive from a caller-supplied stream, so the compile-time narrowing guarantees nothing at runtime. + */ + const windowOverRaw = (chunk: unknown): RetentionWindow => + new RetentionWindow( + new ReadableStream({ + start(controller): void { + controller.enqueue(chunk as Uint8Array); + controller.close(); + }, + }).getReader(), + ); + + test('an undefined chunk is an IoError, not a raw TypeError', async () => { + // The `done: false` narrowing is a COMPILE-time guarantee about a value that arrives from a + // caller-supplied stream, so without a runtime check this escapes the IoError tree entirely. + const window = windowOverRaw(undefined); + expect(await rejection(window.pullThrough(1))).toBeInstanceOf( + SourceContractViolationError, + ); + }); + + test('a string chunk is rejected at the boundary, not left to corrupt the queue', async () => { + // What `Readable.toWeb()` yields when the Node stream has an encoding set. `'abc'.length` is 3, so + // a length-only check waves it through and it detonates much later, far from its cause. + const window = windowOverRaw('abc'); + expect(await rejection(window.pullThrough(1))).toBeInstanceOf( + SourceContractViolationError, + ); + }); +}); + +describe('RetentionWindow teardown (IO-41)', () => { + test('close resolves only after the underlying cancel has finished', async () => { + // Detaching the cancel lets `close()` resolve ahead of the real release, racing anything a caller + // sequences on it — connection reuse, shutdown. + const order: string[] = []; + const stream = new ReadableStream({ + start(controller): void { + controller.enqueue(bytes(1)); + }, + async cancel(): Promise { + await Bun.sleep(10); + order.push('underlying-cancel-finished'); + }, + }); + const window = new RetentionWindow(stream.getReader()); + await window.close(); + order.push('close-returned'); + expect(order).toEqual(['underlying-cancel-finished', 'close-returned']); + }); + + test('a cancel failure propagates instead of being swallowed', async () => { + const stream = new ReadableStream({ + start(controller): void { + controller.enqueue(bytes(1)); + }, + cancel(): never { + throw new Error('socket teardown failed'); + }, + }); + const window = new RetentionWindow(stream.getReader()); + expect((await rejection(window.close())).message).toContain( + 'socket teardown failed', + ); + }); + + test('close releases the reader lock, which cancel alone never does', async () => { + const stream = new ReadableStream({ + start(controller): void { + controller.enqueue(bytes(1)); + }, + }); + const window = new RetentionWindow(stream.getReader()); + expect(stream.locked).toBe(true); + await window.close(); + expect(stream.locked).toBe(false); + }); + + test('overlapping closes share one teardown and one outcome', async () => { + let cancels = 0; + const stream = new ReadableStream({ + start(controller): void { + controller.enqueue(bytes(1)); + }, + cancel(): void { + cancels += 1; + }, + }); + const window = new RetentionWindow(stream.getReader()); + await Promise.all([window.close(), window.close()]); + await window.close(); + expect(cancels).toBe(1); + }); +}); diff --git a/packages/core/src/io/retention-window.ts b/packages/core/src/io/retention-window.ts new file mode 100644 index 0000000..c62cb98 --- /dev/null +++ b/packages/core/src/io/retention-window.ts @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/io/retention-window.ts +import {invariant} from '../invariant.js'; +import {ByteQueue} from './byte-queue.js'; +import {ClosedResourceError, SourceContractViolationError} from './errors.js'; + +/** + * A reader's position, as a logical offset into the whole stream. Two cursors over one window are + * mutually independent (IO-23): advancing one never moves another. + * + * @internal + */ +export interface Cursor { + at: number; +} + +/** + * The shared buffer behind a `BufferedSource` and all of its peek/slice views. + * + * Bytes are retained from `min(all live cursors)` forward and trimmed as the slowest cursor advances, so + * with no views outstanding retention collapses to the read size. There is deliberately **no cap** here: + * §5 bounds nothing, and every cap the product spec mandates (BODY-19, BODY-30/HTTP-52, BODY-34) sits in + * §6 and belongs to Phase 3b. A cap at this layer would bound the spread between the fastest and slowest + * cursor, which in the divergent case stops a view reaching the end and partially fails IO-19's MUST. + * + * Owns the stream reader, so a view — which owns no reader — can still pull through its parent's source. + * + * @internal + */ +export class RetentionWindow { + readonly #queue = new ByteQueue(); + readonly #cursors = new Set(); + readonly #reader: ReadableStreamDefaultReader | undefined; + #retainedFrom = 0; + #pulledThrough = 0; + #exhausted = false; + #closed = false; + #closing: Promise | undefined; + + constructor(reader: ReadableStreamDefaultReader | undefined) { + this.#reader = reader; + this.#exhausted = reader === undefined; + } + + /** Logical offset one past the last byte pulled from the stream. */ + get pulledThrough(): number { + return this.#pulledThrough; + } + + /** Bytes currently held because some cursor may still need them. */ + get retainedBytes(): number { + return this.#queue.size; + } + + get closed(): boolean { + return this.#closed; + } + + /** Register a new cursor at a logical offset (IO-23 — its own cursor, independent of every other). */ + register(at: number): Cursor { + this.assertUsable(); + const cursor: Cursor = {at}; + this.#cursors.add(cursor); + return cursor; + } + + /** + * Drop a cursor and let the retained head trim forward (IO-22 — releasing a view neither closes the + * parent nor moves the parent's cursor). + */ + release(cursor: Cursor): void { + this.#cursors.delete(cursor); + if (!this.#closed) this.#trim(); + } + + /** + * Pull from the stream until `offset` bytes are available, or the stream ends. Returns false at end. + */ + async pullThrough(offset: number): Promise { + this.assertUsable(); + while (this.#pulledThrough < offset && !this.#exhausted) { + await this.#pullOnce(); + this.assertUsable(); + } + return this.#pulledThrough >= offset; + } + + /** Move up to `count` already-pulled bytes onto `dest`, advancing only `cursor`. */ + readInto(cursor: Cursor, dest: ByteQueue, count: number): number { + this.assertUsable(); + const take = Math.min(count, this.#pulledThrough - cursor.at); + if (take <= 0) return 0; + this.#queue.copyTo(dest, cursor.at - this.#retainedFrom, take); + cursor.at += take; + this.#trim(); + return take; + } + + /** + * Materialize up to `count` already-pulled bytes starting `offset` ahead of `cursor`, without + * advancing it (IO-19, IO-20). + * + * The offset exists so an incremental scanner can re-peek only the tail it has not seen. Without it + * every caller re-materializes the whole scanned prefix on each pull, which is quadratic. + */ + peekBytes(cursor: Cursor, offset: number, count: number): Uint8Array { + this.assertUsable(); + const from = cursor.at + offset; + const take = Math.min(count, this.#pulledThrough - from); + if (take <= 0) return new Uint8Array(0); + return this.#queue.copyOut(from - this.#retainedFrom, take); + } + + /** How many pulled bytes sit at or ahead of `cursor`. Does not pull and does not advance. */ + availableFrom(cursor: Cursor): number { + this.assertUsable(); + return Math.max(0, this.#pulledThrough - cursor.at); + } + + /** IO-24: a closed window fails loudly with a state error, never as a normal EOF. */ + assertUsable(): void { + if (this.#closed) throw new ClosedResourceError('BufferedSource'); + } + + /** + * IO-41: idempotent. IO-22: invalidates every outstanding view, so a later read from one fails loudly + * rather than returning stale bytes. + * + * The returned promise settles only once the underlying reader has actually been cancelled and its + * lock released, and it REJECTS when that teardown fails. Detaching the cancel (`void reader.cancel()`) + * would let `await source.close()` resolve ahead of the real release — racing anything a caller + * sequences on it, such as connection reuse — and would swallow a teardown failure entirely. + * + * Memoized rather than early-returned on a flag: two overlapping `close()` calls must await the SAME + * teardown and observe the SAME outcome, instead of the second resolving while the first is still in + * flight or has already failed. + */ + close(): Promise { + this.#closing ??= this.#teardown(); + return this.#closing; + } + + async #teardown(): Promise { + this.#closed = true; + this.#cursors.clear(); + this.#queue.clear(); + this.#queue.close(); + const reader = this.#reader; + if (reader === undefined) return; + try { + await reader.cancel(); + } finally { + // `cancel()` cancels the STREAM; it never releases the reader's lock — only `releaseLock()` does, + // and without it the caller's ReadableStream stays locked forever. Runs even when the cancel + // rejects, because a stream that failed to cancel is exactly the one whose lock must not leak. + reader.releaseLock(); + } + } + + async #pullOnce(): Promise { + invariant(this.#reader !== undefined, 'pull on a window with no reader'); + const {done, value} = await this.#reader.read(); + if (done) { + this.#exhausted = true; + return; + } + // IO-17: the `done: false` narrowing is a COMPILE-time guarantee about a value that arrives from a + // caller-supplied stream, so it guarantees nothing at runtime. Without this check `undefined` + // escapes as a raw `TypeError` outside the IoError tree, and a string chunk — what + // `Readable.toWeb()` yields when the Node stream has an encoding set — passes the length test and + // corrupts the queue, detonating much later and far from its cause. + if (!(value instanceof Uint8Array)) { + throw new SourceContractViolationError( + `source delivered a non-Uint8Array chunk (${typeof value})`, + ); + } + if (value.length === 0) { + // IO-17: a zero-length delivery for an outstanding read is a source-contract violation, never + // end-of-stream and never something to spin on. + throw new SourceContractViolationError( + 'source delivered 0 bytes without signalling end of stream', + ); + } + this.#queue.writeBytes(value); + this.#pulledThrough += value.length; + } + + /** Drop everything no live cursor can still reach. */ + #trim(): void { + const low = this.#lowestCursor(); + const drop = low - this.#retainedFrom; + if (drop <= 0) return; + this.#queue.skip(drop); + this.#retainedFrom = low; + } + + #lowestCursor(): number { + let low = this.#pulledThrough; + for (const cursor of this.#cursors) low = Math.min(low, cursor.at); + return low; + } +} diff --git a/packages/core/src/io/sink.ts b/packages/core/src/io/sink.ts new file mode 100644 index 0000000..e69b5fa --- /dev/null +++ b/packages/core/src/io/sink.ts @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/io/sink.ts +import type {ByteQueue} from './byte-queue.js'; + +/** + * The write surface of §5 — what `BufferedSink` and `TeeSink` both are. + * + * This interface exists because `TeeSink` is a `BufferedSink` DECORATOR, and TypeScript has no structural + * escape hatch for that: `BufferedSink` carries `#private` fields, which make it a nominal type, so a + * decorator can never be assignable to it no matter how faithfully it mirrors the API. Without a shared + * interface `writeAll` — the only pump in the package — cannot accept a tee, and tees cannot nest, which + * defeats the body-capture use case IO-25 exists for and silently invites callers to bypass the tap by + * reaching for the primary's bridge instead. + * + * `flush`/`emit` return `Promise` rather than `this` because IO-18 only asks that they be chainable; + * each implementation narrows the return to its own type. + * + * @internal + */ +export interface Sink { + /** Whether the sink has been closed or aborted (IO-42). */ + readonly closed: boolean; + + /** Remove exactly `count` bytes from `src`'s head and push them downstream (IO-4). */ + write(src: ByteQueue, count: number): Promise; + + /** Encode and write UTF-8 text (IO-13). */ + writeUtf8(text: string): Promise; + + /** Encode and write text with an explicit charset (IO-13). */ + writeString(text: string, charset: string): Promise; + + /** IO-18: force buffered bytes all the way out toward the destination. */ + flush(): Promise; + + /** IO-18: a cheap one-level handoff. */ + emit(): Promise; + + /** IO-5, IO-41: closeable and idempotent. */ + close(): Promise; + + /** Discard the destination with a reason rather than committing what was written (IO-42). */ + abort(reason?: unknown): Promise; + + /** A writable host-native byte-stream bridge (IO-16). */ + toWritableStream(): WritableStream; +} diff --git a/packages/core/src/io/tee-sink.test.ts b/packages/core/src/io/tee-sink.test.ts new file mode 100644 index 0000000..9f080e5 --- /dev/null +++ b/packages/core/src/io/tee-sink.test.ts @@ -0,0 +1,277 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/io/tee-sink.test.ts +// Exercises: IO-25 (mirror into a tap AND forward the full untruncated payload), +// IO-26 (tap capacity limit; unbounded default; a limit of 0 mirrors nothing), +// IO-27 (mirror BEFORE forwarding; staging cleared even on a failed write), +// IO-28 (no direct backing-buffer handle), IO-29 (flush/close/emit forward to the primary only), +// IO-42 (write after close rejects with the source intact), +// IO-13 (the tap mirrors the primary's exact encoded bytes, and refuses a label identically), +// IO-16 (the tee's own writable bridge still feeds the tap) +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import {BufferedSink} from './buffered-sink.js'; +import {BufferedSource} from './buffered-source.js'; +import {ByteQueue} from './byte-queue.js'; +import {ClosedResourceError} from './errors.js'; +import {TeeSink} from './tee-sink.js'; +import {writeAll} from './pump.js'; +import { + collectingWritableStream, + failingWritableStream, +} from './test-support/fake-stream.js'; +import {rejection} from './test-support/rejection.js'; + +const queueOf = (bytes: Uint8Array): ByteQueue => { + const queue = new ByteQueue(); + queue.writeBytes(bytes); + return queue; +}; + +describe('TeeSink', () => { + test('IO-25: the primary receives the full payload and the tap mirrors it', async () => { + const {stream, written} = collectingWritableStream(); + const tee = new TeeSink(BufferedSink.overStream(stream)); + await tee.write(queueOf(Uint8Array.from([1, 2, 3])), 3); + await tee.close(); + expect([...written()]).toEqual([1, 2, 3]); + expect([...tee.snapshot()]).toEqual([1, 2, 3]); + }); + + test('IO-26: past the tap limit the tap stops copying but the primary still gets everything', async () => { + const {stream, written} = collectingWritableStream(); + const tee = new TeeSink(BufferedSink.overStream(stream), 2); + await tee.write(queueOf(Uint8Array.from([1, 2, 3, 4, 5])), 5); + await tee.close(); + expect([...written()]).toEqual([1, 2, 3, 4, 5]); + expect([...tee.snapshot()]).toEqual([1, 2]); + }); + + test('IO-26: a limit of 0 mirrors nothing and forwards everything', async () => { + const {stream, written} = collectingWritableStream(); + const tee = new TeeSink(BufferedSink.overStream(stream), 0); + await tee.write(queueOf(Uint8Array.from([1, 2, 3])), 3); + await tee.close(); + expect([...written()]).toEqual([1, 2, 3]); + expect([...tee.snapshot()]).toEqual([]); + }); + + test('IO-26: the default limit mirrors everything', async () => { + const {stream} = collectingWritableStream(); + const tee = new TeeSink(BufferedSink.overStream(stream)); + await tee.write(queueOf(new Uint8Array(10_000).fill(7)), 10_000); + await tee.close(); + expect(tee.snapshot().length).toBe(10_000); + }); +}); + +describe('TeeSink mirror-before-forward and lifecycle (IO-27, IO-28, IO-29, IO-42)', () => { + test('IO-27: a failed primary write still captures the attempted bytes in the tap', async () => { + const tee = new TeeSink( + BufferedSink.overStream(failingWritableStream('primary down')), + ); + expect( + (await rejection(tee.write(queueOf(Uint8Array.from([1, 2, 3])), 3))) + .message, + ).toContain('primary down'); + await Promise.resolve(); + expect([...tee.snapshot()]).toEqual([1, 2, 3]); + }); + + test("IO-27: a write following a FAILED write does not prepend the failed write's bytes", async () => { + // The staging buffer is per-call, so this holds structurally — but the assertion has to actually + // drive the failure path to prove it, which is why the first sink is the failing one. + const failing = new TeeSink( + BufferedSink.overStream(failingWritableStream('primary down')), + ); + expect( + (await rejection(failing.write(queueOf(Uint8Array.from([1, 2])), 2))) + .message, + ).toContain('primary down'); + + const {stream, written} = collectingWritableStream(); + const good = new TeeSink(BufferedSink.overStream(stream)); + await good.write(queueOf(Uint8Array.from([3])), 1); + await good.close(); + expect([...written()]).toEqual([3]); + }); + + test('IO-27: consecutive successful writes concatenate without duplication or reordering', async () => { + const {stream, written} = collectingWritableStream(); + const tee = new TeeSink(BufferedSink.overStream(stream)); + await tee.write(queueOf(Uint8Array.from([1, 2])), 2); + await tee.write(queueOf(Uint8Array.from([3])), 1); + await tee.close(); + expect([...written()]).toEqual([1, 2, 3]); + }); +}); + +describe('TeeSink no-raw-buffer and close (IO-28, IO-29, IO-42)', () => { + test('IO-28: there is no direct backing-buffer handle', () => { + const {stream} = collectingWritableStream(); + const tee = new TeeSink(BufferedSink.overStream(stream)); + expect(() => tee.buffer).toThrow( + 'TeeSink exposes no backing buffer; use the typed write methods', + ); + }); + + test('IO-29: close forwards to the primary and leaves the tap intact for later snapshotting', async () => { + const {stream, written} = collectingWritableStream(); + const tee = new TeeSink(BufferedSink.overStream(stream)); + await tee.write(queueOf(Uint8Array.from([1, 2])), 2); + await tee.close(); + expect([...written()]).toEqual([1, 2]); + expect([...tee.snapshot()]).toEqual([1, 2]); + }); + + test('IO-29: flush and emit return the tee and leave the tap intact', async () => { + const {stream, written} = collectingWritableStream(); + const tee = new TeeSink(BufferedSink.overStream(stream)); + await tee.write(queueOf(Uint8Array.from([1, 2])), 2); + expect(await tee.flush()).toBe(tee); + expect(await tee.emit()).toBe(tee); + expect([...tee.snapshot()]).toEqual([1, 2]); + await tee.close(); + expect([...written()]).toEqual([1, 2]); + }); + + test('IO-29: flush and emit really reach the primary — a closed primary makes both reject', async () => { + // The observable proof that neither is swallowed by the decorator: BufferedSink rejects a flush + // or emit after close (IO-42), so the rejection can only have come from the primary. + const {stream} = collectingWritableStream(); + const tee = new TeeSink(BufferedSink.overStream(stream)); + await tee.close(); + expect(await rejection(tee.flush())).toBeInstanceOf(ClosedResourceError); + expect(await rejection(tee.emit())).toBeInstanceOf(ClosedResourceError); + }); +}); + +describe('TeeSink text writes (IO-13, IO-25)', () => { + test('IO-25: writeUtf8 forwards the encoded bytes and mirrors exactly those bytes', async () => { + const {stream, written} = collectingWritableStream(); + const tee = new TeeSink(BufferedSink.overStream(stream)); + await tee.writeUtf8('héllo ☃'); + await tee.close(); + expect([...tee.snapshot()]).toEqual([...written()]); + expect(new TextDecoder('utf-8').decode(written())).toBe('héllo ☃'); + }); + + test('IO-13: writeString mirrors the charset-encoded bytes, not a UTF-8 re-encoding', async () => { + // 'é' is one byte in ISO-8859-1 and two in UTF-8, so a tap that re-encoded would differ from the + // wire body — the exact divergence the single shared `encodeText` exists to prevent. + const {stream, written} = collectingWritableStream(); + const tee = new TeeSink(BufferedSink.overStream(stream)); + await tee.writeString('hé', 'iso-8859-1'); + await tee.close(); + expect([...written()]).toEqual([0x68, 0xe9]); + expect([...tee.snapshot()]).toEqual([0x68, 0xe9]); + }); + + test('IO-13: an unsupported charset is refused before anything is mirrored or forwarded', async () => { + const {stream, written} = collectingWritableStream(); + const tee = new TeeSink(BufferedSink.overStream(stream)); + expect( + (await rejection(tee.writeString('x', 'shift_jis'))).message, + ).toContain( + 'unsupported write charset: shift_jis (only utf-8 and iso-8859-1 can be encoded)', + ); + await tee.close(); + expect([...tee.snapshot()]).toEqual([]); + expect([...written()]).toEqual([]); + }); + + test('IO-42: write after close rejects and leaves the source intact', async () => { + const {stream} = collectingWritableStream(); + const tee = new TeeSink(BufferedSink.overStream(stream)); + await tee.close(); + const source = queueOf(Uint8Array.from([1, 2])); + expect(await rejection(tee.write(source, 2))).toBeInstanceOf( + ClosedResourceError, + ); + expect(source.size).toBe(2); + expect([...tee.snapshot()]).toEqual([]); + }); + + test('IO-25 property: the primary always receives the exact concatenation of every written byte', async () => { + // The single most important property in §5: logging never reduces the wire body, whatever the cap. + await fc.assert( + fc.asyncProperty( + fc.array(fc.uint8Array({maxLength: 32}), {maxLength: 8}), + fc.integer({min: 0, max: 64}), + async (writes, tapLimit) => { + const {stream, written} = collectingWritableStream(); + const tee = new TeeSink(BufferedSink.overStream(stream), tapLimit); + for (const chunk of writes) + await tee.write(queueOf(chunk), chunk.length); + await tee.close(); + + const expected = writes.flatMap(chunk => [...chunk]); + expect([...written()]).toEqual(expected); + expect(tee.snapshot().length).toBe( + Math.min(tapLimit, expected.length), + ); + }, + ), + ); + }); +}); + +describe('TeeSink as a first-class sink (IO-16, IO-25)', () => { + test('a tee is accepted anywhere a sink is, including by the pump and by another tee', async () => { + // `BufferedSink`'s #private fields make it a NOMINAL type, so a decorator can never be assignable + // to it. Without a shared interface the only pump in the package cannot take a tee and tees cannot + // nest — which defeats the body capture IO-25 exists for. + const {stream, written} = collectingWritableStream(); + const inner = new TeeSink(BufferedSink.overStream(stream)); + const outer = new TeeSink(inner); + const total = await writeAll( + BufferedSource.overBytes(Uint8Array.from([1, 2, 3])), + outer, + ); + expect(total).toBe(3); + expect([...written()]).toEqual([1, 2, 3]); + expect([...outer.snapshot()]).toEqual([1, 2, 3]); + expect([...inner.snapshot()]).toEqual([1, 2, 3]); + }); + + test('IO-16: the tee exposes its own bridge, so bridged bytes still reach the tap', async () => { + // Handing callers the primary's bridge instead would route every byte written through it past the + // tap, silently producing an empty capture. + const {stream, written} = collectingWritableStream(); + const tee = new TeeSink(BufferedSink.overStream(stream)); + const writer = tee.toWritableStream().getWriter(); + await writer.write(Uint8Array.from([7, 8])); + await writer.close(); + expect([...written()]).toEqual([7, 8]); + expect([...tee.snapshot()]).toEqual([7, 8]); + }); + + test('IO-16: aborting the tee bridge aborts the primary with the reason', async () => { + const {stream, wasAborted, abortReason} = collectingWritableStream(); + const tee = new TeeSink(BufferedSink.overStream(stream)); + const reason = new Error('cancelled'); + await tee.toWritableStream().abort(reason); + expect(wasAborted()).toBe(true); + expect(abortReason()).toBe(reason); + }); + + test('a failed primary write leaves the caller its bytes, and the tap records the attempt', async () => { + // IO-27 requires the tap capture the ATTEMPTED bytes, so it keeps them either way; what must not + // happen is `src` being drained by a write that never reached the wire. + const tee = new TeeSink( + BufferedSink.overStream(failingWritableStream('boom')), + ); + const source = queueOf(Uint8Array.from([1, 2, 3])); + expect((await rejection(tee.write(source, 3))).message).toContain('boom'); + expect(source.size).toBe(3); + expect([...source.snapshot()]).toEqual([1, 2, 3]); + expect([...tee.snapshot()]).toEqual([1, 2, 3]); + }); + + test('an empty payload produces the same chunk sequence as the sink and the bridge', async () => { + const {stream, chunkSizes} = collectingWritableStream(); + const tee = new TeeSink(BufferedSink.overStream(stream)); + await tee.writeUtf8(''); + expect(chunkSizes()).toEqual([]); + expect(tee.snapshot().length).toBe(0); + }); +}); diff --git a/packages/core/src/io/tee-sink.ts b/packages/core/src/io/tee-sink.ts new file mode 100644 index 0000000..322045b --- /dev/null +++ b/packages/core/src/io/tee-sink.ts @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/io/tee-sink.ts +import {invariant} from '../invariant.js'; +import {ByteQueue} from './byte-queue.js'; +import {ClosedResourceError, EndOfStreamError, IoError} from './errors.js'; +import type {Sink} from './sink.js'; +import {encodeText} from './text-codec.js'; + +/** + * A sink that mirrors written bytes into a bounded in-memory tap while forwarding the full, untruncated + * payload to its primary (IO-25–IO-29). + * + * Built as a plain `Sink` decorator rather than on `TransformStream`, which `sdk-design/03` §3.1 + * sketches: a `TransformStream`'s own queueing and backpressure semantics muddy IO-27's + * mirror-before-forward ordering, the clause most easily gotten wrong. §3.1's substantive point — that + * the platform's `ReadableStream.tee()` solves a different problem (duplicating a *readable* for two + * consumers, not mirroring a *sink's* writes) — is why no platform primitive is used at all. + * + * Decorates the `Sink` INTERFACE, not `BufferedSink` itself, so a tee is usable everywhere a sink is — + * `writeAll`, another tee — and so it can offer its own bridge instead of forcing callers to reach for + * the primary's, which would route every byte past the tap. + * + * The tap has no cap by default. §5 bounds nothing; BODY-19 and BODY-34 set the real cap in Phase 3b. + * + * @internal + */ +export class TeeSink implements Sink { + readonly #primary: Sink; + readonly #tap = new ByteQueue(); + readonly #tapLimit: number; + + constructor(primary: Sink, tapLimit: number = Number.POSITIVE_INFINITY) { + invariant( + tapLimit >= 0, + `tapLimit must be non-negative, got ${String(tapLimit)}`, + ); + this.#primary = primary; + this.#tapLimit = tapLimit; + } + + /** Tracks the primary, which owns the real destination. */ + get closed(): boolean { + return this.#primary.closed; + } + + /** + * IO-28: a raw buffer write would reach only the tap or only the primary and silently corrupt the wire + * body, so no such handle exists. + */ + get buffer(): never { + throw new IoError( + 'TeeSink exposes no backing buffer; use the typed write methods', + ); + } + + /** + * Mirror into the tap, then forward the full payload to the primary (IO-25, IO-27). + * + * `src` is drained only after the primary write resolves, so a caller that catches a failed write + * still holds its bytes. The tap deliberately keeps the attempted bytes either way — IO-27 requires + * exactly that, so the tap records what was ATTEMPTED, not what reached the wire. + */ + async write(src: ByteQueue, count: number): Promise { + // IO-42: reject before consuming from `src` or touching the tap. + if (this.#primary.closed) throw new ClosedResourceError('TeeSink'); + if (src.size < count) throw new EndOfStreamError(src.size, count); + if (count === 0) return; + const staging = new ByteQueue(); + src.copyTo(staging, 0, count); + // IO-27: mirror BEFORE forwarding, so a failed primary write still captures the attempted bytes. + this.#mirror(staging); + try { + // IO-27: the staging buffer is cleared even on a failed primary write, so a later write cannot + // prepend stale bytes. + await this.#primary.write(staging, count); + } finally { + staging.clear(); + } + src.skip(count); + } + + /** Mirror and forward UTF-8 text (IO-25). */ + async writeUtf8(text: string): Promise { + return this.writeString(text, 'utf-8'); + } + + /** + * Mirror and forward text with an explicit charset (IO-25). + * + * Encodes once, through the shared `encodeText`, then routes the bytes down the normal `write` path. + * That guarantees the tap mirrors exactly the bytes the primary emits — not a UTF-8 re-encoding of + * them — and that an unsupported charset is refused identically on both sides. + */ + async writeString(text: string, charset: string): Promise { + const encoded = new ByteQueue(); + encoded.writeBytes(encodeText(text, charset)); + return this.write(encoded, encoded.size); + } + + /** A non-consuming copy of the tap's contents. */ + snapshot(): Uint8Array { + return this.#tap.snapshot(); + } + + /** IO-29: forwards to the PRIMARY only, leaving the tap intact. */ + async flush(): Promise { + await this.#primary.flush(); + return this; + } + + /** IO-29: forwards to the PRIMARY only, leaving the tap intact. */ + async emit(): Promise { + await this.#primary.emit(); + return this; + } + + /** IO-29: forwards to the PRIMARY only; the tap survives for later snapshotting. */ + async close(): Promise { + await this.#primary.close(); + } + + /** IO-29: forwards to the PRIMARY only; the tap survives, recording what was attempted. */ + async abort(reason?: unknown): Promise { + await this.#primary.abort(reason); + } + + /** + * A writable host-native byte-stream bridge (IO-16) that still feeds the tap. + * + * Routed through this tee's own `write`, not the primary's bridge — handing callers the primary's + * would mean every byte written through it bypasses the tap, silently producing an empty capture. + */ + toWritableStream(): WritableStream { + return new WritableStream({ + write: async (chunk): Promise => { + const staging = new ByteQueue(); + staging.writeBytes(chunk); + await this.write(staging, staging.size); + }, + close: async (): Promise => { + await this.close(); + }, + abort: async (reason: unknown): Promise => { + await this.abort(reason); + }, + }); + } + + /** IO-26: copy until the cap is reached, then stop copying while the payload still forwards. */ + #mirror(staging: ByteQueue): void { + const room = this.#tapLimit - this.#tap.size; + if (room <= 0) return; + staging.copyTo(this.#tap, 0, Math.min(room, staging.size)); + } +} diff --git a/packages/core/src/io/test-support/fake-stream.ts b/packages/core/src/io/test-support/fake-stream.ts new file mode 100644 index 0000000..3d208b6 --- /dev/null +++ b/packages/core/src/io/test-support/fake-stream.ts @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/io/test-support/fake-stream.ts +// Test-only. Excluded from the build (tsconfig.build.json) and never exported from any barrel. +// Styleguide 11.3: fake your own interfaces rather than reaching for mock.module. + +/** A readable stream that yields exactly the chunks given, at exactly those boundaries. */ +export function fakeReadableStream( + chunks: readonly Uint8Array[], + onCancel?: () => void, +): ReadableStream { + let index = 0; + return new ReadableStream({ + cancel(): void { + onCancel?.(); + }, + pull(controller): void { + if (index >= chunks.length) { + controller.close(); + return; + } + const chunk = chunks[index]; + index += 1; + if (chunk !== undefined) controller.enqueue(chunk); + }, + }); +} + +/** A readable stream that violates the read protocol by yielding an empty chunk (drives IO-17). */ +export function protocolViolatingStream(): ReadableStream { + return fakeReadableStream([new Uint8Array(0)]); +} + +/** A writable stream that accumulates everything written, for asserting the wire payload. */ +export function collectingWritableStream(): { + stream: WritableStream; + written: () => Uint8Array; + chunkSizes: () => number[]; + isClosed: () => boolean; + abortReason: () => unknown; + wasAborted: () => boolean; +} { + const parts: Uint8Array[] = []; + let closed = false; + let aborted = false; + let abortReason: unknown = undefined; + const stream = new WritableStream({ + write(chunk): void { + parts.push(chunk.slice()); + }, + close(): void { + closed = true; + }, + abort(reason: unknown): void { + aborted = true; + abortReason = reason; + }, + }); + const written = (): Uint8Array => { + const total = parts.reduce((sum, part) => sum + part.length, 0); + const out = new Uint8Array(total); + let at = 0; + for (const part of parts) { + out.set(part, at); + at += part.length; + } + return out; + }; + return { + stream, + written, + chunkSizes: () => parts.map(part => part.length), + isClosed: () => closed, + abortReason: () => abortReason, + wasAborted: () => aborted, + }; +} + +/** A writable stream whose writes stay pending until released, for observing emit/flush ordering. */ +export function gatedWritableStream(): { + stream: WritableStream; + delivered: () => number; + release: () => void; +} { + let delivered = 0; + let open: (() => void) | undefined; + const gate = new Promise(resolve => { + open = resolve; + }); + const stream = new WritableStream({ + async write(chunk): Promise { + await gate; + delivered += chunk.length; + }, + }); + return { + stream, + delivered: () => delivered, + release: () => open?.(), + }; +} + +/** A writable stream whose `close` rejects, for asserting teardown-failure behavior. */ +export function failingCloseWritableStream( + message: string, +): WritableStream { + return new WritableStream({ + close(): never { + throw new Error(message); + }, + }); +} + +/** A writable stream whose first write rejects, for asserting failure-path behavior. */ +export function failingWritableStream( + message: string, +): WritableStream { + return new WritableStream({ + write(): never { + throw new Error(message); + }, + }); +} + +/** + * Read a stream to completion and return everything it yielded. + * + * Hand-rolled rather than `new Response(stream).arrayBuffer()`: `Response` is a restricted global here, + * since in this package the name belongs to the SDK's own HTTP model. + */ +export async function drainStream( + stream: ReadableStream, +): Promise { + const reader = stream.getReader(); + const parts: Uint8Array[] = []; + let total = 0; + for (;;) { + const {done, value} = await reader.read(); + if (done) break; + parts.push(value); + total += value.length; + } + const out = new Uint8Array(total); + let at = 0; + for (const part of parts) { + out.set(part, at); + at += part.length; + } + return out; +} diff --git a/packages/core/src/io/test-support/rejection.test.ts b/packages/core/src/io/test-support/rejection.test.ts new file mode 100644 index 0000000..5c83f30 --- /dev/null +++ b/packages/core/src/io/test-support/rejection.test.ts @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/io/test-support/rejection.test.ts +// Exercises the `rejection()` test helper's own failure paths, not covered by its many callers +// (which all reject with a real Error). +import {describe, expect, test} from 'bun:test'; +import {rejection} from './rejection.js'; + +describe('rejection', () => { + test('returns the rejection reason when the promise rejects with an Error', async () => { + const error = new Error('boom'); + expect(await rejection(Promise.reject(error))).toBe(error); + }); + + test('throws when the promise rejects with a non-Error value', async () => { + let caught: unknown; + try { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercising rejection()'s non-Error branch + await rejection(Promise.reject('not an error')); + } catch (e: unknown) { + caught = e; + } + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toBe( + 'expected an Error rejection, got string', + ); + }); + + test('throws when the promise resolves instead of rejecting', async () => { + let caught: unknown; + try { + await rejection(Promise.resolve('fine')); + } catch (e: unknown) { + caught = e; + } + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toBe( + 'expected the promise to reject, but it resolved', + ); + }); +}); diff --git a/packages/core/src/io/test-support/rejection.ts b/packages/core/src/io/test-support/rejection.ts new file mode 100644 index 0000000..94dfb38 --- /dev/null +++ b/packages/core/src/io/test-support/rejection.ts @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/io/test-support/rejection.ts +// Test-only. Excluded from the build (tsconfig.build.json) and never exported from any barrel. + +/** + * Await a promise that must reject, and return the rejection reason. + * + * Why this exists rather than `expect(promise).rejects.toThrow(...)`: bun types `rejects` as + * `Matchers`, whose `toThrow()` returns `void` even though at run time it returns a promise. + * So `await`ing it fails `@typescript-eslint/await-thenable`, and omitting the `await` leaves the + * assertion racing test teardown — bun still fails the run, but the failure can attribute to a later + * test. Capturing the rejection keeps every failure awaited and attributable, with no lint suppression. + */ +export async function rejection(promise: Promise): Promise { + try { + await promise; + } catch (e: unknown) { + if (e instanceof Error) return e; + throw new Error(`expected an Error rejection, got ${typeof e}`); + } + throw new Error('expected the promise to reject, but it resolved'); +} diff --git a/packages/core/src/io/text-codec.ts b/packages/core/src/io/text-codec.ts new file mode 100644 index 0000000..6c1e33e --- /dev/null +++ b/packages/core/src/io/text-codec.ts @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/io/text-codec.ts +import {IoError} from './errors.js'; + +/** + * The single source of truth for both directions of IO-13's text encoding. + * + * Lives in its own module because encode and decode must agree byte for byte: IO-13 requires the two to + * round-trip, and the only way to guarantee that is to derive them from one table. `BufferedSink`, + * `TeeSink` and `BufferedSource` all route through here rather than each reaching for the platform. + * + * @internal + */ + +/** Charset labels this package encodes and decodes itself rather than delegating to the platform. */ +const LATIN1_LABELS = new Set([ + 'iso-8859-1', + 'latin1', + 'iso8859-1', + 'iso_8859-1', +]); +const UTF8_LABELS = new Set(['utf-8', 'utf8', 'unicode-1-1-utf-8']); + +/** + * Encode `text` for the wire (IO-13). + * + * ISO-8859-1 is a direct code-point-to-byte map for 0–255; anything above is not representable. + * `TextEncoder` is UTF-8-only — there is no `TextEncoder('iso-8859-1')` — and SEAM-1 forbids an encoding + * dependency, so any other label throws rather than silently re-encoding as UTF-8, which would corrupt + * the bytes on the wire. + */ +export function encodeText(text: string, charset: string): Uint8Array { + const normalized = charset.toLowerCase(); + if (UTF8_LABELS.has(normalized)) return new TextEncoder().encode(text); + if (!LATIN1_LABELS.has(normalized)) { + throw new IoError( + `unsupported write charset: ${charset} (only utf-8 and iso-8859-1 can be encoded)`, + ); + } + const out = new Uint8Array(text.length); + for (let i = 0; i < text.length; i += 1) { + const code = text.charCodeAt(i); + if (code > 0xff) { + throw new IoError( + `code point ${String(code)} is not representable in ${charset}`, + ); + } + out[i] = code; + } + return out; +} + +/** + * Decode `bytes` that arrived from the wire (IO-13). + * + * ISO-8859-1 is decoded HERE rather than through `TextDecoder`, deliberately. The WHATWG Encoding + * Standard maps the labels `iso-8859-1` and `latin1` onto windows-1252, so + * `new TextDecoder('iso-8859-1').encoding === 'windows-1252'` — which reinterprets 0x80–0x9F as typographic + * characters (0x80 becomes U+20AC EUR). That breaks IO-13's mandated symmetry in both directions: bytes + * written by `encodeText` do not come back, and text decoded that way cannot be re-encoded at all, + * because the substituted code points are above 0xFF. The direct byte-to-code-point map is the actual + * ISO-8859-1 the write side implements. + * + * Every other label goes to `TextDecoder`, which is correct for them. + * + * `ignoreBOM: true` is REQUIRED, not incidental. The decoder is applied per fragment — per line, per + * counted read — so the default (strip a leading U+FEFF) deletes a BOM anywhere a fragment happens to + * begin, not just at the start of a stream. That silently drops the first three bytes of a body, breaking + * content hashing and signature verification, and it makes SSE-12 ("any BOM later in the stream MUST be + * preserved as ordinary data") unimplementable in Phase 6b, because the byte is gone before the SSE + * parser ever sees the line. Consuming a single start-of-stream BOM belongs to whoever knows where the + * stream starts; it is not this function's business. Do not turn this flag off. + */ +export function decodeText(bytes: Uint8Array, charset: string): string { + const normalized = charset.toLowerCase(); + if (LATIN1_LABELS.has(normalized)) { + // Chunked because `String.fromCharCode(...bytes)` overflows the call stack on a large body. + let out = ''; + for (let at = 0; at < bytes.length; at += LATIN1_CHUNK) { + out += String.fromCharCode(...bytes.subarray(at, at + LATIN1_CHUNK)); + } + return out; + } + return decoderFor(charset).decode(bytes); +} + +const LATIN1_CHUNK = 8192; + +function decoderFor(charset: string): TextDecoder { + try { + return new TextDecoder(charset, {ignoreBOM: true}); + } catch (e: unknown) { + // A charset label reaching this layer is internal, so this is an argument error, not boundary + // data. Phase 3b's HTTP-42 owns the "unknown declared charset falls back to UTF-8" rule. + throw new IoError(`unsupported charset: ${charset}`, {cause: e}); + } +} + +/** Whether `charset` can be decoded at all — used to reject a bad label before any bytes are consumed. */ +export function assertDecodable(charset: string): void { + if (LATIN1_LABELS.has(charset.toLowerCase())) return; + decoderFor(charset); +} diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json index d39dc7e..a8c27f3 100644 --- a/packages/core/tsconfig.build.json +++ b/packages/core/tsconfig.build.json @@ -6,6 +6,8 @@ "sourceMap": true }, "exclude": [ - "src/**/*.test.ts" + "src/**/*.test.ts", + "src/**/*.bench.ts", + "src/io/test-support/**" ] } diff --git a/tsconfig.base.json b/tsconfig.base.json index 0c1d0df..7c876c0 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -6,7 +6,8 @@ "moduleResolution": "nodenext", "lib": [ "ES2022", - "DOM" + "DOM", + "DOM.AsyncIterable" ], "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, From 133879a8f1f763e202d54cda6dfc4438c76f0ac5 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh <78609166+Wahbeh-Mohammad@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:06:19 +0300 Subject: [PATCH 2/8] feat(core): request/response body lifecycle. (#32) * feat(core): add request/response body lifecycle: Body model, materialize(), TypedResponse, HttpStatusError, logging tees (BODY-1..37, HTTP-36..52). * fix(core): resolve body-lifecycle review findings (BODY-3..37, HTTP-26/39/42/43/44/51). * test(core): cover withBodyWriter teardown paths (RECOV-12, RETRY-2). --- .changeset/body-lifecycle-review-fixes.md | 24 ++ .changeset/body-lifecycle.md | 7 + ...026-07-25-phase3b-body-lifecycle-design.md | 3 +- packages/core/etc/core.api.md | 223 +++++++++++++- packages/core/src/body/body.ts | 24 ++ packages/core/src/body/errors.test.ts | 31 ++ packages/core/src/body/errors.ts | 79 +++++ .../core/src/body/http-status-error.test.ts | 148 ++++++++++ packages/core/src/body/http-status-error.ts | 118 ++++++++ packages/core/src/body/index.ts | 37 +++ packages/core/src/body/materialize.test.ts | 78 +++++ packages/core/src/body/materialize.ts | 42 +++ packages/core/src/body/media-type-safety.ts | 38 +++ packages/core/src/body/multipart-body.test.ts | 271 ++++++++++++++++++ packages/core/src/body/multipart-body.ts | 205 +++++++++++++ .../src/body/request-body-logging.test.ts | 192 +++++++++++++ .../core/src/body/request-body-logging.ts | 81 ++++++ .../src/body/response-body-logging.test.ts | 270 +++++++++++++++++ .../core/src/body/response-body-logging.ts | 219 ++++++++++++++ packages/core/src/body/simple-bodies.test.ts | 188 ++++++++++++ packages/core/src/body/simple-bodies.ts | 202 +++++++++++++ packages/core/src/body/stream-body.test.ts | 196 +++++++++++++ packages/core/src/body/stream-body.ts | 99 +++++++ packages/core/src/body/typed-response.test.ts | 111 +++++++ packages/core/src/body/typed-response.ts | 63 ++++ packages/core/src/body/write-body.test.ts | 104 +++++++ packages/core/src/body/write-body.ts | 31 ++ packages/core/src/http/charset.ts | 28 ++ packages/core/src/http/request.test.ts | 22 +- packages/core/src/http/request.ts | 30 +- packages/core/src/http/response.test.ts | 171 +++++++++-- packages/core/src/http/response.ts | 178 ++++++------ packages/core/src/index.ts | 35 +++ packages/core/src/io/errors.test.ts | 36 ++- packages/core/src/io/errors.ts | 33 ++- packages/core/src/io/index.ts | 1 + packages/core/src/seams/operation.test.ts | 7 +- packages/core/src/seams/operation.ts | 3 +- 38 files changed, 3438 insertions(+), 190 deletions(-) create mode 100644 .changeset/body-lifecycle-review-fixes.md create mode 100644 .changeset/body-lifecycle.md create mode 100644 packages/core/src/body/body.ts create mode 100644 packages/core/src/body/errors.test.ts create mode 100644 packages/core/src/body/errors.ts create mode 100644 packages/core/src/body/http-status-error.test.ts create mode 100644 packages/core/src/body/http-status-error.ts create mode 100644 packages/core/src/body/index.ts create mode 100644 packages/core/src/body/materialize.test.ts create mode 100644 packages/core/src/body/materialize.ts create mode 100644 packages/core/src/body/media-type-safety.ts create mode 100644 packages/core/src/body/multipart-body.test.ts create mode 100644 packages/core/src/body/multipart-body.ts create mode 100644 packages/core/src/body/request-body-logging.test.ts create mode 100644 packages/core/src/body/request-body-logging.ts create mode 100644 packages/core/src/body/response-body-logging.test.ts create mode 100644 packages/core/src/body/response-body-logging.ts create mode 100644 packages/core/src/body/simple-bodies.test.ts create mode 100644 packages/core/src/body/simple-bodies.ts create mode 100644 packages/core/src/body/stream-body.test.ts create mode 100644 packages/core/src/body/stream-body.ts create mode 100644 packages/core/src/body/typed-response.test.ts create mode 100644 packages/core/src/body/typed-response.ts create mode 100644 packages/core/src/body/write-body.test.ts create mode 100644 packages/core/src/body/write-body.ts create mode 100644 packages/core/src/http/charset.ts diff --git a/.changeset/body-lifecycle-review-fixes.md b/.changeset/body-lifecycle-review-fixes.md new file mode 100644 index 0000000..3d3b291 --- /dev/null +++ b/.changeset/body-lifecycle-review-fixes.md @@ -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). diff --git a/.changeset/body-lifecycle.md b/.changeset/body-lifecycle.md new file mode 100644 index 0000000..6c296c6 --- /dev/null +++ b/.changeset/body-lifecycle.md @@ -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 | 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. diff --git a/docs/superpowers/specs/2026-07-25-phase3b-body-lifecycle-design.md b/docs/superpowers/specs/2026-07-25-phase3b-body-lifecycle-design.md index 333f673..50715fc 100644 --- a/docs/superpowers/specs/2026-07-25-phase3b-body-lifecycle-design.md +++ b/docs/superpowers/specs/2026-07-25-phase3b-body-lifecycle-design.md @@ -52,7 +52,7 @@ Everything else in `§6` ships in this phase. | BODY-17, BODY-18, BODY-19, BODY-21, BODY-37 | MUST | `withRequestLogging` tee decorator over `Body` — a self-contained tee reusing only `ByteQueue`, **not** Phase 3a's `TeeSink` class (whose `ByteQueue`-and-count signature does not compose with `writeTo`'s chunk-shaped sink; see the section below) | | BODY-20 | SHOULD | Partial-failure snapshot returns bytes mirrored up to the failure | | BODY-22, BODY-23, BODY-24, BODY-27, BODY-28 | MUST | Response-body logging wrapper, two regimes (fits-cap capture vs. exceeds-cap prefix+tail), shared close-once guard | -| BODY-25 | MUST | **Structurally inapplicable on Node** — the wrapper reads through a `ReadableStreamDefaultReader`, which has no requested-count parameter, so "returns zero for a positive requested count" has no analog; a zero-length chunk is not an EOS signal and is captured as-is, with EOS signalled only by `{done: true}`. Ledgered | +| BODY-25 | MUST | Implemented: a zero-length delegate chunk raises `SourceContractViolationError`, matching `RetentionWindow` under the identically-worded `IO-17`. `ReadableStreamDefaultReader.read()` carries no requested count, so the clause has no *literal* analog — but a response body reaches both this tee and `BufferedSource`, and the tolerant reading made the same upstream succeed or fail depending only on which wrapper it passed through. Ledger entry withdrawn (review finding, 2026-08-24) | | BODY-26 | MUST | `LoggedResponseBody.error(): Error \| null` — the drain failure is cached in the wrapper's closure; `read()` re-throws it on every call, `snapshot()` returns the partial bytes without throwing, and `error()` surfaces it **without triggering a drain** | | BODY-29 | SHOULD | `LoggedResponseBody.contentLength` — the captured size in the fits-cap regime, the delegate's declared length otherwise (the capture is only a bounded prefix) | | HTTP-52 / BODY-30, BODY-31 | MUST | `toHttpError(response)` — 1 MiB fixed cap, 4xx/5xx only, buffering inside the response's own close-guaranteeing scope | @@ -398,7 +398,6 @@ Phase 1's `unknown` placeholder; `Response` gains `text()`/`bytes()`/`close()`), | Both logging tees are new, self-contained implementations, not built on Phase 3a's `TeeSink`/`BufferedSource` | none — forced by the `writeTo` decision above | `TeeSink`/`BufferedSource`/`BufferedSink` are reader/writer-bound with `ByteQueue`-and-count-shaped signatures; `Body.writeTo`'s chunk-shaped `WritableStream` doesn't compose with them without rewriting Phase 3a's frozen surface. Only `ByteQueue` (pure in-memory, unbound to a stream shape) is reused | | Phase 3a's `IoError` tier flattened in this phase, not in 3a itself | phase-boundary discipline (each phase's own frozen surface) | The checkpoint's `§5.2` fix for `DomainModelError` missed the identically-shaped `IoError` tier; carrying the inconsistency forward into a fourth phase was judged worse than a scoped retrofit here | | Logging tees and `toHttpError`'s preview machinery shipped `@internal`, unwired to any `Logger` | none — matches Phase 2's `Serde` precedent | No `Logger`/config surface exists until Phase 7 | -| `BODY-25`'s zero-byte-read-for-a-positive-count clause not implemented | `BODY-25` (MUST) | Structurally inapplicable: `ReadableStreamDefaultReader.read()` takes no requested count, so the failure mode has no analog. EOF is signalled only by `{done: true}`, which is what the drain loop keys on, so the silent truncation `BODY-25` guards against cannot arise | | `BODY-34`'s shared preview cap covers the two logging tees only, not `toHttpError` | `BODY-34` (MUST), read literally as "all three" | `HTTP-52` *fixes* the error-body cap at 1 MiB, so it cannot also be the configurable shared value. The two capture sites `BODY-34` actually names — request-side tee and response-side drain — do share one cap | | Concrete `Body` classes exported from the public barrel as types only, never as values | none — required by `HTTP-2` | Exporting the class as a value publishes a field-wise constructor, which `HTTP-2` forbids; the factory functions are the sanctioned construction path and the classes remain usable as type annotations | diff --git a/packages/core/etc/core.api.md b/packages/core/etc/core.api.md index 330de32..8614a45 100644 --- a/packages/core/etc/core.api.md +++ b/packages/core/etc/core.api.md @@ -4,6 +4,20 @@ ```ts +// @public +interface Body_2 { + // (undocumented) + readonly contentLength: number; + // (undocumented) + readonly kind: 'byte-array' | 'string' | 'stream' | 'form-urlencoded' | 'multipart'; + // (undocumented) + readonly mediaType: string | undefined; + // (undocumented) + readonly replayable: boolean; + writeTo(sink: WritableStream): Promise; +} +export { Body_2 as Body } + // @public export interface Builder { build(): T; @@ -12,6 +26,24 @@ export interface Builder { // @public export function buildRequest(baseUrl: string | URL, operation: OperationDescriptor): Request_2; +// @public +export class ByteArrayBody implements Body_2 { + constructor(bytes: Uint8Array, mediaType?: string); + // (undocumented) + readonly contentLength: number; + // (undocumented) + readonly kind: "byte-array"; + // (undocumented) + readonly mediaType: string | undefined; + // (undocumented) + readonly replayable = true; + // (undocumented) + writeTo(sink: WritableStream): Promise; +} + +// @public +export function byteArrayBody(bytes: Uint8Array, mediaType?: string): ByteArrayBody; + // @public export class CancellationError extends DexpaceError { constructor(message: string, options?: ErrorOptions); @@ -20,6 +52,13 @@ export class CancellationError extends DexpaceError { // @public export function composeSignal(userSignal?: AbortSignal, timeoutMs?: number): AbortSignal | undefined; +// @public +export class ConsumedBodyError extends DexpaceError { + constructor(bodyKind: string, options?: ErrorOptions); + // (undocumented) + readonly bodyKind: string; +} + // @public export class DexpaceError extends Error { constructor(message: string, options?: ErrorOptions); @@ -43,6 +82,39 @@ export class ETag { export class EtagParseError extends DomainModelError { } +// @public +export class FormBodyValidationError extends DexpaceError { + constructor(field: string, value: unknown, options?: ErrorOptions); + // (undocumented) + readonly field: string; +} + +// @public +export class FormUrlEncodedBody implements Body_2 { + constructor(input: FormUrlEncodedInput); + // (undocumented) + readonly contentLength: number; + // (undocumented) + readonly kind: "form-urlencoded"; + // (undocumented) + readonly mediaType = "application/x-www-form-urlencoded"; + // (undocumented) + readonly params: QueryParams; + // (undocumented) + readonly replayable = true; + // (undocumented) + writeTo(sink: WritableStream): Promise; +} + +// @public +export function formUrlEncodedBody(input: FormUrlEncodedInput): FormUrlEncodedBody; + +// @public +export type FormUrlEncodedInput = QueryParams | ReadonlyMap | Record | readonly (readonly [string, FormUrlEncodedValue])[]; + +// @public +export type FormUrlEncodedValue = string | number | boolean | bigint | null; + // @public export class HeaderName { equals(other: HeaderName): boolean; @@ -97,9 +169,24 @@ export class HttpRange { export class HttpRangeValidationError extends DomainModelError { } +// @public +export class HttpStatusError extends DexpaceError { + constructor(status: number, bodyBytes: Uint8Array | undefined, mediaType: string | undefined, options?: ErrorOptions); + body(): Body_2 | undefined; + preview(charset?: string): string | null; + // (undocumented) + readonly status: number; +} + +// @public +export function isBodyError(error: unknown): error is ConsumedBodyError | MultipartBoundaryError | FormBodyValidationError; + // @public export function isTimeoutSignal(signal: AbortSignal): boolean; +// @public +export function materialize(body: Body_2): Promise; + // @public export class MediaType { get charset(): string | undefined; @@ -120,6 +207,56 @@ export class MediaTypeParseError extends DomainModelError { // @public export type Method = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'CONNECT' | 'OPTIONS' | 'TRACE' | 'PATCH'; +// @public +export class MultipartBody implements Body_2 { + constructor(parts: readonly MultipartPart[], boundary?: string); + // (undocumented) + readonly contentLength: number; + // (undocumented) + readonly kind: "multipart"; + // (undocumented) + readonly mediaType: string; + // (undocumented) + static newBuilder(): MultipartBodyBuilder; + newBuilder(): MultipartBodyBuilder; + // (undocumented) + readonly replayable: boolean; + // (undocumented) + writeTo(sink: WritableStream): Promise; +} + +// @public +export function multipartBody(parts: readonly MultipartPart[], boundary?: string): MultipartBody; + +// @public +export class MultipartBodyBuilder implements Builder { + // (undocumented) + addPart(part: MultipartPart): this; + // (undocumented) + boundary(boundary: string | undefined): this; + // (undocumented) + build(): MultipartBody; + // (undocumented) + parts(parts: readonly MultipartPart[]): this; +} + +// @public +export class MultipartBoundaryError extends DexpaceError { + constructor(boundary: string, options?: ErrorOptions); + // (undocumented) + readonly boundary: string; +} + +// @public +export interface MultipartPart { + // (undocumented) + readonly body: Body_2; + // (undocumented) + readonly filename?: string | undefined; + // (undocumented) + readonly name: string; +} + // @public export class OperationAssemblyError extends DexpaceError { constructor(message: string, parameterName: string); @@ -128,7 +265,7 @@ export class OperationAssemblyError extends DexpaceError { // @public export interface OperationDescriptor { - readonly body?: unknown; + readonly body?: Body_2 | undefined; readonly headers?: Headers_2 | undefined; readonly method: Method; readonly pathParams?: Readonly> | undefined; @@ -172,7 +309,7 @@ export type RangeKind = 'bounded' | 'suffix' | 'open'; // @public class Request_2 { - get body(): unknown; + get body(): Body_2 | undefined; equals(other: Request_2): boolean; get headers(): Headers_2; get method(): Method; @@ -189,7 +326,7 @@ export class RequestBodyNotAllowedError extends DomainModelError { // @public export class RequestBuilder implements Builder { - body(body: unknown): this; + body(body: Body_2 | undefined): this; build(): Request_2; headers(headers: Headers_2): this; method(method: Method): this; @@ -246,25 +383,45 @@ export class RequiredFieldError extends DomainModelError { // @public class Response_2 { - get body(): unknown; + // (undocumented) + [Symbol.asyncDispose](): Promise; + constructor(request: Request_2, protocol: Protocol, status: Status, reasonPhrase: string | undefined, headers: Headers_2, body: ReadableStream | null); + get body(): ReadableStream | null; + bytes(): Promise; + close(): Promise; + // (undocumented) get headers(): Headers_2; + // (undocumented) static newBuilder(): ResponseBuilder; + // (undocumented) newBuilder(): ResponseBuilder; + // (undocumented) get protocol(): Protocol; + // (undocumented) get reasonPhrase(): string | undefined; + // (undocumented) get request(): Request_2; + // (undocumented) get status(): Status; + text(): Promise; } export { Response_2 as Response } // @public export class ResponseBuilder implements Builder { - body(body: unknown): this; + // (undocumented) + body(body: ReadableStream | null): this; + // (undocumented) build(): Response_2; + // (undocumented) headers(headers: Headers_2): this; + // (undocumented) protocol(protocol: Protocol): this; + // (undocumented) reasonPhrase(reasonPhrase: string | undefined): this; + // (undocumented) request(request: Request_2): this; + // (undocumented) status(status: Status): this; } @@ -284,12 +441,68 @@ export class Status { static recognized(code: number): Status | undefined; } +// @public +export class StreamBody implements Body_2 { + constructor(stream: ReadableStream, mediaType?: string, contentLength?: number); + // (undocumented) + readonly contentLength: number; + // (undocumented) + readonly kind: "stream"; + // (undocumented) + readonly mediaType: string | undefined; + // (undocumented) + readonly replayable = false; + // (undocumented) + writeTo(sink: WritableStream): Promise; +} + +// @public +export function streamBody(stream: ReadableStream, mediaType?: string, contentLength?: number): StreamBody; + +// @public +export class StringBody implements Body_2 { + constructor(text: string, mediaType?: string); + // (undocumented) + readonly contentLength: number; + // (undocumented) + readonly kind: "string"; + // (undocumented) + readonly mediaType: string; + // (undocumented) + readonly replayable = true; + // (undocumented) + readonly text: string; + // (undocumented) + writeTo(sink: WritableStream): Promise; +} + +// @public +export function stringBody(text: string, mediaType?: string): StringBody; + +// @public +export function toHttpError(response: Response_2): Promise; + // @public export interface Transport { close(): Promise; send(request: Request_2, options?: RequestOptions, signal?: AbortSignal): Promise; } +// @public +export class TypedResponse { + constructor(response: Response_2, parse: (response: Response_2) => Promise); + // (undocumented) + get headers(): Response_2['headers']; + // (undocumented) + get protocol(): string; + // (undocumented) + get reason(): string | undefined; + get request(): Request_2; + // (undocumented) + get status(): Response_2['status']; + value(): Promise; +} + // @public export class UrlConstructionError extends DomainModelError { } diff --git a/packages/core/src/body/body.ts b/packages/core/src/body/body.ts new file mode 100644 index 0000000..9b6d5ec --- /dev/null +++ b/packages/core/src/body/body.ts @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/body/body.ts + +/** + * The core domain interface for HTTP message bodies. + * + * @public + */ +export interface Body { + readonly kind: + 'byte-array' | 'string' | 'stream' | 'form-urlencoded' | 'multipart'; + readonly mediaType: string | undefined; + readonly contentLength: number; + readonly replayable: boolean; + /** + * Writes the body once into `sink`, closing it on success and aborting it on failure so a partially + * written body is never signalled to the transport as a complete one. + * + * @throws ConsumedBodyError when a single-use body is written a second time (BODY-3). + * @throws EndOfStreamError when a stream body's byte count disagrees with its declared + * `contentLength` (HTTP-39/BODY-10). + */ + writeTo(sink: WritableStream): Promise; +} diff --git a/packages/core/src/body/errors.test.ts b/packages/core/src/body/errors.test.ts new file mode 100644 index 0000000..d55a2b4 --- /dev/null +++ b/packages/core/src/body/errors.test.ts @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/body/errors.test.ts +// Exercises: BODY-3 (ConsumedBodyError), HTTP-51 (MultipartBoundaryError) +import {describe, expect, test} from 'bun:test'; +import {DexpaceError} from '../http/errors.js'; +import { + ConsumedBodyError, + isBodyError, + MultipartBoundaryError, +} from './errors.js'; + +describe('body errors', () => { + test('ConsumedBodyError descends from DexpaceError and names the body kind', () => { + const error = new ConsumedBodyError('stream'); + expect(error).toBeInstanceOf(DexpaceError); + expect(error.bodyKind).toBe('stream'); + expect(error.message).toContain('stream'); + }); + + test('MultipartBoundaryError descends from DexpaceError and names the offending boundary', () => { + const error = new MultipartBoundaryError('bad boundary'); + expect(error).toBeInstanceOf(DexpaceError); + expect(error.boundary).toBe('bad boundary'); + }); + + test('isBodyError groups both leaves without a class tier', () => { + expect(isBodyError(new ConsumedBodyError('stream'))).toBe(true); + expect(isBodyError(new MultipartBoundaryError('x'))).toBe(true); + expect(isBodyError(new DexpaceError('other'))).toBe(false); + }); +}); diff --git a/packages/core/src/body/errors.ts b/packages/core/src/body/errors.ts new file mode 100644 index 0000000..95e05bf --- /dev/null +++ b/packages/core/src/body/errors.ts @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/body/errors.ts +import {DexpaceError} from '../http/errors.js'; + +/** + * A single-use body's second write (BODY-3). `bodyKind` names which Body variant refused the write. + * + * @example + * ```ts + * try { + * await body.writeTo(sink); + * } catch (error) { + * if (error instanceof ConsumedBodyError) { + * // materialize() first if you need to send this body more than once + * } + * } + * ``` + * @public + */ +export class ConsumedBodyError extends DexpaceError { + readonly bodyKind: string; + + constructor(bodyKind: string, options?: ErrorOptions) { + super( + `${bodyKind} body already consumed -- single-use bodies cannot be written twice`, + options, + ); + this.bodyKind = bodyKind; + } +} + +/** + * A caller-supplied multipart boundary violates RFC 2046's grammar (HTTP-51). + * + * @public + */ +export class MultipartBoundaryError extends DexpaceError { + readonly boundary: string; + + constructor(boundary: string, options?: ErrorOptions) { + super(`invalid multipart boundary: ${JSON.stringify(boundary)}`, options); + this.boundary = boundary; + } +} + +/** + * A form field that cannot be rendered into an `x-www-form-urlencoded` body (HTTP-38/BODY-35) -- a + * non-string field name, or a value that is neither a primitive nor `null`. Raised rather than dropping + * the field, which would put a silently incomplete body on the wire. + * + * @public + */ +export class FormBodyValidationError extends DexpaceError { + readonly field: string; + + constructor(field: string, value: unknown, options?: ErrorOptions) { + super( + `form field ${JSON.stringify(field)} has an unsupported value of type ${typeof value} -- use a string, number, boolean, bigint, or null`, + options, + ); + this.field = field; + } +} + +/** + * Type guard for body errors. + * + * @public + */ +export function isBodyError( + error: unknown, +): error is + ConsumedBodyError | MultipartBoundaryError | FormBodyValidationError { + return ( + error instanceof ConsumedBodyError || + error instanceof MultipartBoundaryError || + error instanceof FormBodyValidationError + ); +} diff --git a/packages/core/src/body/http-status-error.test.ts b/packages/core/src/body/http-status-error.test.ts new file mode 100644 index 0000000..cd85c01 --- /dev/null +++ b/packages/core/src/body/http-status-error.test.ts @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/body/http-status-error.test.ts +// Exercises: HTTP-52/BODY-30 (1 MiB cap, replayable re-serve, buffered inside close-guaranteeing scope), +// BODY-31 (4xx/5xx only, no-body response returned unchanged), BODY-33 (non-consuming preview), +// HTTP-42 (preview decodes with the media type's charset, falling back to UTF-8, never throwing) +import {describe, expect, test} from 'bun:test'; +import {Headers} from '../http/headers.js'; +import {Protocol} from '../http/protocol.js'; +import {Request} from '../http/request.js'; +import {Response} from '../http/response.js'; +import {Status} from '../http/status.js'; +import {toHttpError} from './http-status-error.js'; + +function readableOf(bytes: Uint8Array): ReadableStream { + return new ReadableStream({ + start: c => { + c.enqueue(bytes); + c.close(); + }, + }); +} + +function responseWith( + status: number, + body: ReadableStream | null, + headers: Headers = Headers.newBuilder().build(), +): Response { + return Response.newBuilder() + .request(Request.newBuilder().url('https://example.com').build()) + .protocol(Protocol.HTTP_1_1) + .status(Status.of(status)) + .headers(headers) + .body(body) + .build(); +} + +describe('toHttpError (BODY-31)', () => { + test('returns null for a non-error response', async () => { + expect(await toHttpError(responseWith(200, null))).toBeNull(); + expect(await toHttpError(responseWith(304, null))).toBeNull(); + }); + + test('returns an HttpStatusError for 4xx and 5xx', async () => { + expect(await toHttpError(responseWith(404, null))).not.toBeNull(); + expect(await toHttpError(responseWith(500, null))).not.toBeNull(); + }); +}); + +describe('HttpStatusError (HTTP-52/BODY-30)', () => { + test('carries the status', async () => { + expect((await toHttpError(responseWith(404, null)))?.status).toBe(404); + }); + + test('buffers the body and re-serves it as a replayable, independently readable Body', async () => { + const bytes = new TextEncoder().encode('not found'); + const error = await toHttpError(responseWith(404, readableOf(bytes))); + const body = error?.body(); + expect(body?.replayable).toBe(true); + + const chunks: Uint8Array[] = []; + await body?.writeTo(new WritableStream({write: c => void chunks.push(c)})); + expect(new TextDecoder().decode(chunks[0])).toBe('not found'); + + const chunksAgain: Uint8Array[] = []; + await error + ?.body() + ?.writeTo(new WritableStream({write: c => void chunksAgain.push(c)})); + expect(new TextDecoder().decode(chunksAgain[0])).toBe('not found'); + }); + + test('drops bytes beyond the 1 MiB cap but still drains and closes the connection', async () => { + const big = new Uint8Array(2 * 1024 * 1024).fill(65); + const error = await toHttpError(responseWith(500, readableOf(big))); + expect(error?.body()?.contentLength).toBe(1024 * 1024); + }); + + test('when the response has no body, the error carries an undefined body and null preview (BODY-31)', async () => { + const error = await toHttpError(responseWith(500, null)); + expect(error?.body()).toBeUndefined(); + expect(error?.preview()).toBeNull(); + }); + + test('preview is non-consuming and repeatable (BODY-33)', async () => { + const error = await toHttpError( + responseWith(500, readableOf(new TextEncoder().encode('boom'))), + ); + expect(error?.preview()).toBe('boom'); + expect(error?.preview()).toBe('boom'); + }); +}); + +function contentType(value: string): Headers { + return Headers.newBuilder().add('content-type', value).build(); +} + +describe('preview charset resolution (HTTP-42, BODY-33)', () => { + const cafeLatin1 = Uint8Array.from([0x63, 0x61, 0x66, 0xe9]); // "café" in ISO-8859-1 + + test('decodes with the charset declared by the response media type', async () => { + const error = await toHttpError( + responseWith( + 500, + readableOf(cafeLatin1), + contentType('text/plain; charset=iso-8859-1'), + ), + ); + expect(error?.preview()).toBe('café'); + }); + + test('an explicit charset argument still wins', async () => { + const error = await toHttpError( + responseWith(500, readableOf(cafeLatin1), contentType('text/plain')), + ); + expect(error?.preview('iso-8859-1')).toBe('café'); + }); + + test('an unknown charset falls back to UTF-8 instead of raising a RangeError', async () => { + const error = await toHttpError( + responseWith( + 500, + readableOf(new TextEncoder().encode('ok')), + contentType('text/plain; charset=bogus-charset'), + ), + ); + expect(error?.preview()).toBe('ok'); + expect(error?.preview('also-bogus')).toBe('ok'); + }); + + test('defaults to UTF-8 when no media type was sent', async () => { + const error = await toHttpError( + responseWith(500, readableOf(new TextEncoder().encode('héllo'))), + ); + expect(error?.preview()).toBe('héllo'); + }); + + test('body() drops an inbound media type that is not outbound-safe (HTTP-18/HTTP-19)', async () => { + // HTTP-19 admits obs-text (>= 0x80) inbound; HTTP-18 forbids it outbound. Re-serving a received + // content-type on an outbound Body must drop it, never raise from an accessor on an error object. + const headers = Headers.newBuilder() + .addInbound('content-type', 'text/plain; note="\u00e9"') + .build(); + const error = await toHttpError( + responseWith(500, readableOf(Uint8Array.from([1])), headers), + ); + expect(error?.body()?.mediaType).toBeUndefined(); + expect(error?.preview()).toBe('\u0001'); // still previews, charset resolution falls back + }); +}); diff --git a/packages/core/src/body/http-status-error.ts b/packages/core/src/body/http-status-error.ts new file mode 100644 index 0000000..a877552 --- /dev/null +++ b/packages/core/src/body/http-status-error.ts @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/body/http-status-error.ts +import {decodeText, resolveCharset} from '../http/charset.js'; +import {DexpaceError} from '../http/errors.js'; +import type {Response} from '../http/response.js'; +import {invariant} from '../invariant.js'; +import type {Body} from './body.js'; +import {headerSafeMediaType} from './media-type-safety.js'; +import {byteArrayBody} from './simple-bodies.js'; + +// Fixed by HTTP-52. Deliberately NOT BODY-34's shared preview cap, which is configurable and covers the +// two logging tees only -- a spec-fixed value cannot be the configurable one. +const ERROR_BODY_CAP_BYTES = 1024 * 1024; // 1 MiB, HTTP-52/BODY-30 + +/** + * A 4xx/5xx response turned into an exception (HTTP-52/BODY-30, BODY-31). + * + * @public + */ +export class HttpStatusError extends DexpaceError { + readonly status: number; + readonly #bodyBytes: Uint8Array | undefined; + readonly #mediaType: string | undefined; + + // eslint-disable-next-line max-params -- constructor parameters fixed by error model + constructor( + status: number, + bodyBytes: Uint8Array | undefined, + mediaType: string | undefined, + options?: ErrorOptions, + ) { + super(`HTTP ${String(status)}`, options); + this.status = status; + this.#bodyBytes = bodyBytes; + this.#mediaType = mediaType; + } + + /** + * The buffered error body, re-served as a replayable Body -- readable independently and repeatably + * after the transport connection was released (BODY-30). Undefined when there was no body. + */ + body(): Body | undefined { + return this.#bodyBytes === undefined + ? undefined + : // Dropped rather than raised when the received content-type is not outbound-safe: an inbound + // value may legally carry obs-text (HTTP-19) that an outbound body may not (HTTP-18). + byteArrayBody(this.#bodyBytes, headerSafeMediaType(this.#mediaType)); + } + + /** + * Non-consuming preview from the buffered copy (BODY-33). Null for no body. + * + * Decodes with `charset` when given, otherwise with the charset declared by the response's media type, + * falling back to UTF-8 when that is absent or unknown -- the same resolution `Response.text()` uses + * (HTTP-42). Never throws: an unrecognized label falls back rather than raising a RangeError out of a + * method on an error object, where a caller is least able to handle another exception. + */ + preview(charset?: string): string | null { + if (this.#bodyBytes === undefined) return null; + return decodeText( + this.#bodyBytes, + charset ?? resolveCharset(this.#mediaType), + ); + } +} + +/** + * Turns a 4xx/5xx response into an HttpStatusError, buffering at most 1 MiB of the body inside the + * response's own close-guaranteeing scope (HTTP-52/BODY-30). Returns null for a non-error response + * (BODY-31) -- the caller keeps the response, body intact. + * + * @throws Whatever reading the response body raises; the response is closed either way (BODY-16). + * @public + */ +export async function toHttpError( + response: Response, +): Promise { + // BODY-31: error statuses only, i.e. HTTP-11's 400-599 band. A bare `code < 400` would sweep a + // non-standard 6xx -- which HTTP-10 requires Status.of to accept and return -- into the error path + // and consume a body BODY-31 says must be handed back intact. + if (!response.status.isError) return null; + const mediaType = response.headers.get('content-type'); + if (response.body === null) { + await response.close(); + return new HttpStatusError(response.status.code, undefined, mediaType); + } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + // Serial by necessity: each read depends on the previous one advancing the cursor. + const {done, value} = await reader.read(); + if (done) break; + if (total >= ERROR_BODY_CAP_BYTES) continue; // keep draining to release the connection; drop the bytes + const room = ERROR_BODY_CAP_BYTES - total; + const piece = value.length > room ? value.subarray(0, room) : value; + chunks.push(piece); + total += piece.length; + } + } finally { + // Release before close(): cancel() rejects with TypeError on a locked stream (see Response.bytes). + reader.releaseLock(); + await response.close(); + } + invariant( + total <= ERROR_BODY_CAP_BYTES, + `buffered ${String(total)} bytes past the ${String(ERROR_BODY_CAP_BYTES)} cap`, + ); + + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + return new HttpStatusError(response.status.code, bytes, mediaType); +} diff --git a/packages/core/src/body/index.ts b/packages/core/src/body/index.ts new file mode 100644 index 0000000..902ce74 --- /dev/null +++ b/packages/core/src/body/index.ts @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/body/index.ts +// Internal-facing barrel for product-spec §6. Everything except the two logging tees is also promoted to +// packages/core/src/index.ts (Step 2) -- this file is the superset a future in-tree consumer (e.g. Phase +// 7's pipeline) imports from directly. +export type {Body} from './body.js'; +export { + ConsumedBodyError, + FormBodyValidationError, + isBodyError, + MultipartBoundaryError, +} from './errors.js'; +export {HttpStatusError, toHttpError} from './http-status-error.js'; +export {materialize} from './materialize.js'; +export { + multipartBody, + MultipartBody, + MultipartBodyBuilder, + type MultipartPart, +} from './multipart-body.js'; +export {withRequestLogging, type LoggedBody} from './request-body-logging.js'; +export { + withResponseLogging, + type LoggedResponseBody, +} from './response-body-logging.js'; +export { + byteArrayBody, + ByteArrayBody, + formUrlEncodedBody, + FormUrlEncodedBody, + type FormUrlEncodedInput, + type FormUrlEncodedValue, + stringBody, + StringBody, +} from './simple-bodies.js'; +export {streamBody, StreamBody} from './stream-body.js'; +export {TypedResponse} from './typed-response.js'; diff --git a/packages/core/src/body/materialize.test.ts b/packages/core/src/body/materialize.test.ts new file mode 100644 index 0000000..2582d8c --- /dev/null +++ b/packages/core/src/body/materialize.test.ts @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/body/materialize.test.ts +// Exercises: BODY-3/HTTP-37 (materialize-once) +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import {ConsumedBodyError} from './errors.js'; +import {byteArrayBody} from './simple-bodies.js'; +import {materialize} from './materialize.js'; +import {streamBody} from './stream-body.js'; + +function readableOf(...bytes: number[]): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.from(bytes)); + controller.close(); + }, + }); +} + +async function drainBody(body: { + writeTo: (sink: WritableStream) => Promise; +}): Promise { + const chunks: Uint8Array[] = []; + await body.writeTo(new WritableStream({write: c => void chunks.push(c)})); + const total = chunks.reduce((s, c) => s + c.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + out.set(c, offset); + offset += c.length; + } + return out; +} + +describe('materialize', () => { + test('returns an already-replayable body unchanged', async () => { + const body = byteArrayBody(Uint8Array.from([1, 2])); + expect(await materialize(body)).toBe(body); + }); + + test('drains a single-use body into a fresh replayable ByteArrayBody', async () => { + const materialized = await materialize(streamBody(readableOf(1, 2, 3))); + expect(materialized.replayable).toBe(true); + expect(materialized.kind).toBe('byte-array'); + expect([...(await drainBody(materialized))]).toEqual([1, 2, 3]); + }); + + test('the materialized body is writable more than once, byte-for-byte identical', async () => { + const materialized = await materialize(streamBody(readableOf(9, 8))); + expect([...(await drainBody(materialized))]).toEqual([9, 8]); + expect([...(await drainBody(materialized))]).toEqual([9, 8]); + }); + + test('preserves the original mediaType', async () => { + const materialized = await materialize( + streamBody(readableOf(1), 'text/plain'), + ); + expect(materialized.mediaType).toBe('text/plain'); + }); + + test('under N concurrent callers exactly one drains; every other observes ConsumedBodyError (BODY-3)', async () => { + await fc.assert( + fc.asyncProperty(fc.integer({min: 2, max: 8}), async callers => { + const body = streamBody(readableOf(1, 2, 3)); + const results = await Promise.allSettled( + Array.from({length: callers}, () => materialize(body)), + ); + + const fulfilled = results.filter(r => r.status === 'fulfilled'); + expect(fulfilled.length).toBe(1); + for (const result of results.filter(r => r.status === 'rejected')) { + expect(result.reason).toBeInstanceOf(ConsumedBodyError); + } + }), + {seed: 0x3b}, + ); + }); +}); diff --git a/packages/core/src/body/materialize.ts b/packages/core/src/body/materialize.ts new file mode 100644 index 0000000..f001d0c --- /dev/null +++ b/packages/core/src/body/materialize.ts @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/body/materialize.ts +import {invariant} from '../invariant.js'; +import type {Body} from './body.js'; +import {byteArrayBody} from './simple-bodies.js'; + +/** + * Returns `body` unchanged if already replayable; otherwise drains its single write into a fresh + * replayable ByteArrayBody, after which the original is treated as consumed (BODY-3/HTTP-37). + * + * @throws ConsumedBodyError when `body` is single-use and has already been written (BODY-3). + * @throws Whatever the delegate's `writeTo` raises -- an EndOfStreamError from a stream body whose + * byte count disagrees with its declared length, for instance (HTTP-39/BODY-10). + * @public + */ +export async function materialize(body: Body): Promise { + if (body.replayable) return body; + const chunks: Uint8Array[] = []; + let total = 0; + const collector = new WritableStream({ + write: chunk => { + chunks.push(chunk); + total += chunk.length; + }, + }); + await body.writeTo(collector); + + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + invariant( + offset === total, + `materialized ${String(offset)} bytes, expected ${String(total)}`, + ); + + const replayed = byteArrayBody(bytes, body.mediaType); + invariant(replayed.replayable, 'materialize must return a replayable body'); // BODY-3's postcondition + return replayed; +} diff --git a/packages/core/src/body/media-type-safety.ts b/packages/core/src/body/media-type-safety.ts new file mode 100644 index 0000000..ba0f383 --- /dev/null +++ b/packages/core/src/body/media-type-safety.ts @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/body/media-type-safety.ts +import {hasForbiddenOutboundByte} from '../http/ascii-validation.js'; +import {MediaTypeParseError} from '../http/errors.js'; + +/** + * Rejects a media type that is not header-safe, using the same predicate as outbound header-value + * validation (HTTP-26). + * + * `Body.mediaType` is interpolated into a multipart part header verbatim (HTTP-51), so a CR/LF inside it + * is a header-injection primitive: it can append arbitrary headers, close the header block outright, and + * forge a closing boundary, all while the shared framing routine keeps the declared content length + * consistent with the corrupted bytes. Validating at construction closes it at the source -- a media type + * containing a control character is never legitimate. + */ +export function assertHeaderSafeMediaType(mediaType: string | undefined): void { + if (mediaType === undefined) return; + if (hasForbiddenOutboundByte(mediaType)) { + throw new MediaTypeParseError( + `media type must not contain a control character or non-ASCII byte: ${JSON.stringify(mediaType)}`, + ); + } +} + +/** + * Returns `mediaType` when it is header-safe, otherwise undefined. + * + * For media types that arrive from the wire rather than from a caller. HTTP-19 deliberately lets an + * inbound header value carry obs-text (>= 0x80) that HTTP-18 forbids outbound, so re-serving a received + * `content-type` on an outbound body can legitimately fail {@link assertHeaderSafeMediaType}. Dropping + * the media type is the right trade there -- raising from an accessor on an error object is not. + */ +export function headerSafeMediaType( + mediaType: string | undefined, +): string | undefined { + if (mediaType === undefined) return undefined; + return hasForbiddenOutboundByte(mediaType) ? undefined : mediaType; +} diff --git a/packages/core/src/body/multipart-body.test.ts b/packages/core/src/body/multipart-body.test.ts new file mode 100644 index 0000000..a3ced6f --- /dev/null +++ b/packages/core/src/body/multipart-body.test.ts @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/body/multipart-body.test.ts +// Exercises: BODY-2 (composite replayability, unknown-length collapse), HTTP-51 (shared framing routine, +// boundary generation/validation, header quoting, and a part media type that cannot break the framing), +// HTTP-26 (a media type is header-safe), RECOV-12 (a close failure never masks the primary failure) +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import {MediaTypeParseError} from '../http/errors.js'; +import type {Body} from './body.js'; +import {MultipartBoundaryError} from './errors.js'; +import { + MultipartBody, + MultipartBodyBuilder, + multipartBody, +} from './multipart-body.js'; +import {byteArrayBody, stringBody} from './simple-bodies.js'; +import {streamBody} from './stream-body.js'; + +function emptyStream(): ReadableStream { + return new ReadableStream({ + start: c => { + c.close(); + }, + }); +} + +function oneByteStream(): ReadableStream { + return new ReadableStream({ + start(c) { + c.enqueue(Uint8Array.from([1])); + c.close(); + }, + }); +} + +async function drain(body: { + writeTo: (sink: WritableStream) => Promise; +}): Promise { + const chunks: Uint8Array[] = []; + await body.writeTo(new WritableStream({write: c => void chunks.push(c)})); + const total = chunks.reduce((s, c) => s + c.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + out.set(c, offset); + offset += c.length; + } + return new TextDecoder().decode(out); +} + +describe('MultipartBody replayability and length (BODY-2)', () => { + test('replayable when every part is replayable', () => { + expect(multipartBody([{name: 'a', body: stringBody('x')}]).replayable).toBe( + true, + ); + }); + + test('not replayable when any part is not', () => { + const body = multipartBody([ + {name: 'a', body: stringBody('x')}, + {name: 'b', body: streamBody(oneByteStream())}, + ]); + expect(body.replayable).toBe(false); + }); + + test('declared length collapses to -1 if any part length is unknown (BODY-2)', () => { + expect( + multipartBody([{name: 'a', body: streamBody(emptyStream())}]) + .contentLength, + ).toBe(-1); + }); + + test('declared length equals the bytes actually written when every part length is known', async () => { + const body = multipartBody( + [{name: 'a', body: stringBody('hello')}], + 'FIXEDBOUNDARY', + ); + const rendered = await drain(body); + expect(new TextEncoder().encode(rendered).length).toBe(body.contentLength); + }); +}); + +describe('MultipartBody framing and headers (HTTP-51)', () => { + test('frames one part with boundary, headers, body, and a CRLF-terminated trailer', async () => { + const rendered = await drain( + multipartBody( + [ + { + name: 'field', + body: byteArrayBody(new TextEncoder().encode('value')), + }, + ], + 'B', + ), + ); + expect(rendered).toBe( + '--B\r\nContent-Disposition: form-data; name="field"\r\n\r\nvalue\r\n--B--\r\n', + ); + }); + + test('includes filename and Content-Type when the part has them', async () => { + const rendered = await drain( + multipartBody( + [ + { + name: 'file', + filename: 'a.txt', + body: byteArrayBody(Uint8Array.from([1]), 'text/plain'), + }, + ], + 'B', + ), + ); + expect(rendered).toContain('filename="a.txt"'); + expect(rendered).toContain('Content-Type: text/plain\r\n'); + }); + + test('quotes/escapes a quote or backslash in a part name, and strips embedded CR/LF (HTTP-51)', async () => { + const rendered = await drain( + multipartBody([{name: 'a"b\\c\r\nd', body: stringBody('x')}], 'B'), + ); + expect(rendered).toContain('name="a\\"b\\\\cd"'); + }); +}); + +describe('MultipartBody boundary generation and validation (HTTP-51)', () => { + test('a valid caller-supplied boundary is accepted', () => { + expect(() => + multipartBody([{name: 'a', body: stringBody('x')}], 'valid-boundary_1'), + ).not.toThrow(); + }); + + test('an invalid caller-supplied boundary throws MultipartBoundaryError', () => { + expect(() => + multipartBody([{name: 'a', body: stringBody('x')}], 'trailing space '), + ).toThrow(MultipartBoundaryError); + expect(() => + multipartBody([{name: 'a', body: stringBody('x')}], ''), + ).toThrow(MultipartBoundaryError); + }); + + test('an unsupplied boundary is generated and spec-valid', () => { + const body = multipartBody([{name: 'a', body: stringBody('x')}]); + expect(body.mediaType).toMatch( + /^multipart\/form-data; boundary=dexpace-[A-Za-z0-9]{32}$/, + ); + }); + + test('two generated boundaries differ', () => { + const a = multipartBody([{name: 'a', body: stringBody('x')}]); + const b = multipartBody([{name: 'a', body: stringBody('x')}]); + expect(a.mediaType).not.toBe(b.mediaType); + }); +}); + +describe('MultipartBodyBuilder (HTTP-2, HTTP-3)', () => { + test('static newBuilder and instance newBuilder pre-populates parts and boundary', async () => { + const original = MultipartBody.newBuilder() + .addPart({name: 'p1', body: stringBody('v1')}) + .boundary('CUSTOMB') + .build(); + + expect(original.contentLength).toBeGreaterThan(0); + + const derived = original + .newBuilder() + .addPart({name: 'p2', body: stringBody('v2')}) + .build(); + expect(derived.mediaType).toBe('multipart/form-data; boundary=CUSTOMB'); + const rendered = await drain(derived); + expect(rendered).toContain('name="p1"'); + expect(rendered).toContain('name="p2"'); + }); + + test('MultipartBodyBuilder.parts sets the parts list', async () => { + const builder = new MultipartBodyBuilder(); + builder.parts([{name: 'a', body: stringBody('1')}]); + const body = builder.build(); + expect(await drain(body)).toContain('name="a"'); + }); +}); + +describe('MultipartBody property tests (HTTP-51)', () => { + test('declared length always equals the bytes written, for any part set (HTTP-51)', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(fc.record({name: fc.string(), content: fc.string()}), { + minLength: 1, + maxLength: 8, + }), + async specs => { + const body = multipartBody( + specs.map(s => ({name: s.name, body: stringBody(s.content)})), + ); + const written = new TextEncoder().encode(await drain(body)).length; + expect(written).toBe(body.contentLength); + }, + ), + {seed: 0x3b}, + ); + }); + + test('a part name containing CR/LF or a quote never breaks the framing (HTTP-51)', async () => { + await fc.assert( + fc.asyncProperty(fc.string(), async name => { + const rendered = await drain( + multipartBody( + [{name, body: byteArrayBody(new TextEncoder().encode('x'))}], + 'B', + ), + ); + const headerBlock = rendered.slice(0, rendered.indexOf('\r\n\r\n')); + // exactly two CRLFs of framing (boundary line, disposition line) -- no injected extras + expect(headerBlock.split('\r\n').length).toBe(2); + }), + {seed: 0x3b}, + ); + }); +}); + +// A hand-rolled Body bypassing the bundled factories' construction-time validation -- MultipartPart +// accepts any Body, so the framing routine cannot assume the media type was already checked. +function forgedBody(mediaType: string): Body { + return { + kind: 'byte-array', + mediaType, + contentLength: 1, + replayable: true, + writeTo: async sink => { + const writer = sink.getWriter(); + await writer.write(Uint8Array.from([120])); + await writer.close(); + }, + }; +} + +describe('a part media type cannot break the framing (HTTP-51)', () => { + test('a media type carrying CR/LF is refused, not interpolated', () => { + const part = { + name: 'f', + body: forgedBody('text/plain\r\nX-Injected: pwned'), + }; + expect(() => multipartBody([part], 'BOUNDARY')).toThrow( + MediaTypeParseError, + ); + }); + + test('a media type that would forge a closing boundary is refused', () => { + const part = { + name: 'f', + body: forgedBody('text/plain\r\n\r\nSMUGGLED\r\n--BOUNDARY--'), + }; + // Without this the declared contentLength still matches the written bytes -- the shared framing + // routine counts the forged bytes too, so the wire is consistently, silently wrong. + expect(() => multipartBody([part], 'BOUNDARY')).toThrow( + MediaTypeParseError, + ); + }); +}); + +describe('MultipartBody failure propagation (RECOV-12)', () => { + test('surfaces the sink failure, not a close TypeError', () => { + const body = multipartBody([{name: 'a', body: stringBody('x')}], 'B'); + const sink = new WritableStream({ + write: () => { + throw new Error('SOCKET GONE'); + }, + }); + expect(body.writeTo(sink)).rejects.toThrow('SOCKET GONE'); + }); +}); diff --git a/packages/core/src/body/multipart-body.ts b/packages/core/src/body/multipart-body.ts new file mode 100644 index 0000000..801b3f4 --- /dev/null +++ b/packages/core/src/body/multipart-body.ts @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/body/multipart-body.ts +import type {Builder} from '../http/builder.js'; +import {invariant} from '../invariant.js'; +import type {Body} from './body.js'; +import {MultipartBoundaryError} from './errors.js'; +import {assertHeaderSafeMediaType} from './media-type-safety.js'; +import {withBodyWriter} from './write-body.js'; + +/** + * A part inside a {@link MultipartBody}. + * + * @public + */ +export interface MultipartPart { + readonly name: string; + readonly filename?: string | undefined; + readonly body: Body; +} + +const BOUNDARY_CHARS = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; +// RFC 2046 bchars grammar: 1-70 chars, last char not a space. +const BOUNDARY_PATTERN = + /^[A-Za-z0-9'()+_,\-./:=? ]{1,69}[A-Za-z0-9'()+_,\-./:=?]$/; +const SINGLE_CHAR_BOUNDARY_PATTERN = /^[A-Za-z0-9'()+_,\-./:=?]$/; +const CRLF = new TextEncoder().encode('\r\n'); + +function generateBoundary(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + let boundary = 'dexpace-'; + for (const byte of bytes) { + const char = BOUNDARY_CHARS[byte % BOUNDARY_CHARS.length]; + invariant(char !== undefined, 'boundary character must be defined'); + boundary += char; + } + return boundary; +} + +function validateBoundary(boundary: string): void { + const valid = + boundary.length === 1 + ? SINGLE_CHAR_BOUNDARY_PATTERN.test(boundary) + : BOUNDARY_PATTERN.test(boundary); + if (!valid) throw new MultipartBoundaryError(boundary); +} + +// Escapes a quote/backslash so it cannot break the quoted-string grammar, and strips CR/LF outright so +// they can never break the header framing (HTTP-51). +function quoteParam(value: string): string { + return value.replace(/[\\"]/g, ch => `\\${ch}`).replace(/[\r\n]/g, ''); +} + +// The shared framing routine HTTP-51 requires: both computeContentLength and writeTo call this for every +// part, so the declared length and the written bytes cannot drift. +function renderPartHeader(part: MultipartPart, boundary: string): Uint8Array { + let header = `--${boundary}\r\n`; + header += `Content-Disposition: form-data; name="${quoteParam(part.name)}"`; + if (part.filename !== undefined) + header += `; filename="${quoteParam(part.filename)}"`; + header += '\r\n'; + if (part.body.mediaType !== undefined) { + // Defence in depth: the bundled Body implementations validate at construction, but `MultipartPart` + // accepts any `Body`, and this value is interpolated raw. A CR/LF here would append arbitrary + // headers, close the header block, or forge a closing boundary -- and because this routine is shared + // with computeContentLength, the declared length would agree with the corrupted bytes (HTTP-51). + assertHeaderSafeMediaType(part.body.mediaType); + header += `Content-Type: ${part.body.mediaType}\r\n`; + } + header += '\r\n'; + return new TextEncoder().encode(header); +} + +function trailerBytes(boundary: string): Uint8Array { + return new TextEncoder().encode(`--${boundary}--\r\n`); +} + +function computeContentLength( + parts: readonly MultipartPart[], + boundary: string, +): number { + let total = 0; + for (const part of parts) { + if (part.body.contentLength === -1) return -1; // BODY-2: any unknown part collapses the whole + total += + renderPartHeader(part, boundary).length + + part.body.contentLength + + CRLF.length; + } + return total + trailerBytes(boundary).length; +} + +// Wraps a locked writer as a WritableStream whose close() does not close the real sink -- multiple parts +// share one underlying writer, and only the outer writeTo's own finally block closes it. +function nonClosingSink( + writer: WritableStreamDefaultWriter, +): WritableStream { + return new WritableStream({ + write: async chunk => { + await writer.write(chunk); + }, + }); +} + +/** + * A composite body (BODY-2, HTTP-51). Replayable iff every part is; declared length collapses to unknown + * if any part's length is unknown. + * + * @public + */ +export class MultipartBody implements Body { + readonly kind = 'multipart' as const; + readonly mediaType: string; + readonly contentLength: number; + readonly replayable: boolean; + readonly #parts: readonly MultipartPart[]; + readonly #boundary: string; + + constructor(parts: readonly MultipartPart[], boundary?: string) { + if (boundary !== undefined) validateBoundary(boundary); + this.#boundary = boundary ?? generateBoundary(); + this.#parts = [...parts]; + this.mediaType = `multipart/form-data; boundary=${this.#boundary}`; + this.replayable = this.#parts.every(part => part.body.replayable); + this.contentLength = computeContentLength(this.#parts, this.#boundary); + invariant( + this.contentLength === -1 || + this.contentLength >= trailerBytes(this.#boundary).length, + `framing computed an impossible length ${String(this.contentLength)}`, + ); + } + + static newBuilder(): MultipartBodyBuilder { + return new MultipartBodyBuilder(); + } + + /** HTTP-3: pre-populated with this instance's parts and boundary, aliasing neither. */ + newBuilder(): MultipartBodyBuilder { + return new MultipartBodyBuilder() + .parts(this.#parts) + .boundary(this.#boundary); + } + + async writeTo(sink: WritableStream): Promise { + await withBodyWriter(sink, async writer => { + for (const part of this.#parts) { + await writer.write(renderPartHeader(part, this.#boundary)); + await part.body.writeTo(nonClosingSink(writer)); + await writer.write(CRLF); + } + await writer.write(trailerBytes(this.#boundary)); + }); + } +} + +/** + * Creates a MultipartBody (BODY-2, HTTP-51). + * + * @throws MultipartBoundaryError when `boundary` violates RFC 2046's bchars grammar (HTTP-51). + * @throws MediaTypeParseError when a part's media type contains a control character or non-ASCII byte, + * which would let it break out of the part header it is rendered into (HTTP-26/HTTP-51). + * @public + */ +export function multipartBody( + parts: readonly MultipartPart[], + boundary?: string, +): MultipartBody { + return new MultipartBody(parts, boundary); +} + +/** + * Builder for {@link MultipartBody}. + * + * @public + */ +export class MultipartBodyBuilder implements Builder { + #parts: MultipartPart[] = []; + #boundary: string | undefined; + + parts(parts: readonly MultipartPart[]): this { + this.#parts = [...parts]; + return this; + } + + addPart(part: MultipartPart): this { + this.#parts.push(part); + return this; + } + + boundary(boundary: string | undefined): this { + this.#boundary = boundary; + return this; + } + + /** + * @throws MultipartBoundaryError when the configured boundary violates RFC 2046's bchars grammar + * (HTTP-51). + * @throws MediaTypeParseError when a part's media type contains a control character or non-ASCII byte + * (HTTP-26/HTTP-51). + */ + build(): MultipartBody { + return new MultipartBody(this.#parts, this.#boundary); + } +} diff --git a/packages/core/src/body/request-body-logging.test.ts b/packages/core/src/body/request-body-logging.test.ts new file mode 100644 index 0000000..c167f0d --- /dev/null +++ b/packages/core/src/body/request-body-logging.test.ts @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/body/request-body-logging.test.ts +// Exercises: BODY-17 (mirror + forward the full untruncated payload), BODY-18 (tap clears at the start +// of every write), BODY-19 (tap cap, full payload unaffected), BODY-20 (partial-failure snapshot), BODY-21 +// (replayable/materialize pass through, preserving the tap CAP without sharing its buffer), BODY-37 (no +// backing-buffer escape hatch) +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import {InvariantViolation} from '../invariant.js'; +import {withRequestLogging} from './request-body-logging.js'; +import {byteArrayBody} from './simple-bodies.js'; +import {streamBody} from './stream-body.js'; + +function collectingSink(): { + sink: WritableStream; + written: () => Uint8Array; +} { + const chunks: Uint8Array[] = []; + const sink = new WritableStream({ + write: c => void chunks.push(c), + }); + return { + sink, + written: () => { + const total = chunks.reduce((s, c) => s + c.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + out.set(c, offset); + offset += c.length; + } + return out; + }, + }; +} + +describe('withRequestLogging mirroring and caps (BODY-17..20)', () => { + test('forwards the full payload untruncated regardless of the tap cap (BODY-17, BODY-19)', async () => { + const logged = withRequestLogging( + byteArrayBody(Uint8Array.from([1, 2, 3, 4, 5])), + 2, + ); + const {sink, written} = collectingSink(); + await logged.writeTo(sink); + expect([...written()]).toEqual([1, 2, 3, 4, 5]); + expect([...logged.snapshot()]).toEqual([1, 2]); + }); + + test('the tap clears at the start of every write (BODY-18)', async () => { + const logged = withRequestLogging( + byteArrayBody(Uint8Array.from([9, 9])), + 10, + ); + await logged.writeTo(collectingSink().sink); + await logged.writeTo(collectingSink().sink); + expect([...logged.snapshot()]).toEqual([9, 9]); // not [9, 9, 9, 9] + }); + + test('a tap cap of 0 mirrors nothing while still forwarding everything', async () => { + const logged = withRequestLogging( + byteArrayBody(Uint8Array.from([1, 2])), + 0, + ); + const {sink, written} = collectingSink(); + await logged.writeTo(sink); + expect([...written()]).toEqual([1, 2]); + expect(logged.snapshot().length).toBe(0); + }); + + test('a partial write failure still leaves the bytes mirrored up to that point (BODY-20)', () => { + const failing = new WritableStream({ + write: (_chunk, controller) => { + controller.error(new Error('boom')); + }, + }); + const logged = withRequestLogging( + byteArrayBody(Uint8Array.from([1, 2, 3])), + 10, + ); + expect(logged.writeTo(failing)).rejects.toThrow(); + expect(logged.snapshot().length).toBeGreaterThan(0); + }); +}); + +describe('withRequestLogging replayability, materialize, and protection (BODY-21, 32, 37)', () => { + test('replayable passes through the delegate verbatim (BODY-21)', () => { + expect( + withRequestLogging(byteArrayBody(Uint8Array.from([1])), 10).replayable, + ).toBe(true); + const singleUse = withRequestLogging( + streamBody( + new ReadableStream({ + start: c => { + c.close(); + }, + }), + ), + 10, + ); + expect(singleUse.replayable).toBe(false); + }); + + test('materialize() returns a still-logged, now-replayable wrapper preserving the tap (BODY-21)', async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.from([7, 7])); + controller.close(); + }, + }); + const logged = withRequestLogging(streamBody(stream), 10); + expect(logged.replayable).toBe(false); + + const materialized = await logged.materialize(); + expect(materialized.replayable).toBe(true); + expect(typeof materialized.snapshot).toBe('function'); + + const {sink, written} = collectingSink(); + await materialized.writeTo(sink); + expect([...written()]).toEqual([7, 7]); + expect([...materialized.snapshot()]).toEqual([7, 7]); + }); + + test('exposes no direct handle onto the tap buffer -- snapshot is the only read path (BODY-37)', () => { + const logged = withRequestLogging(byteArrayBody(Uint8Array.from([1])), 10); + expect(Object.keys(logged)).not.toContain('tap'); + expect(Object.keys(logged)).not.toContain('buffer'); + }); + + test('the primary always receives the exact payload, independent of the tap cap (BODY-17)', async () => { + await fc.assert( + fc.asyncProperty( + fc.uint8Array({minLength: 0, maxLength: 512}), + fc.integer({min: 0, max: 600}), + async (payload, tapCap) => { + const logged = withRequestLogging(byteArrayBody(payload), tapCap); + const {sink, written} = collectingSink(); + await logged.writeTo(sink); + + expect([...written()]).toEqual([...payload]); // wire body never reduced or altered + expect(logged.snapshot().length).toBe( + Math.min(payload.length, tapCap), + ); // tap bounded + }, + ), + {seed: 0x3b}, + ); + }); + + test('a negative tap cap is rejected at construction (BODY-32)', () => { + expect(() => + withRequestLogging(byteArrayBody(Uint8Array.from([1])), -1), + ).toThrow(InvariantViolation); + }); +}); + +function bytesStream(...values: number[]): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.from(values)); + controller.close(); + }, + }); +} + +describe('materialize does not alias the tap (BODY-21)', () => { + test('each wrapper keeps its own buffer, so one write cannot rewrite the other preview', async () => { + const logged = withRequestLogging( + streamBody(bytesStream(1, 2, 3), undefined, 3), + 100, + ); + const materialized = await logged.materialize(); + + const {sink} = collectingSink(); + await materialized.writeTo(sink); + + expect([...materialized.snapshot()]).toEqual([1, 2, 3]); + // BODY-18 clears the tap at the start of every write. With one shared ByteQueue, a Phase 7 retry + // loop's second attempt silently rewrites the preview the first-attempt wrapper is still holding. + expect([...logged.snapshot()]).toEqual([]); + }); + + test('the materialized wrapper still honours the configured cap', async () => { + const logged = withRequestLogging( + streamBody(bytesStream(1, 2, 3), undefined, 3), + 2, + ); + const materialized = await logged.materialize(); + const {sink} = collectingSink(); + await materialized.writeTo(sink); + expect([...materialized.snapshot()]).toEqual([1, 2]); + }); +}); diff --git a/packages/core/src/body/request-body-logging.ts b/packages/core/src/body/request-body-logging.ts new file mode 100644 index 0000000..41666ca --- /dev/null +++ b/packages/core/src/body/request-body-logging.ts @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/body/request-body-logging.ts +import {invariant} from '../invariant.js'; +import {ByteQueue} from '../io/byte-queue.js'; +import {MAX_BYTE_ARRAY_LENGTH} from '../io/limits.js'; +import type {Body} from './body.js'; +import {materialize} from './materialize.js'; + +export interface LoggedBody extends Body { + /** A copy of the tap's current contents -- at most tapCapBytes of the most recent write (BODY-19). */ + snapshot(): Uint8Array; + /** Materializes the delegate while preserving the logging wrapper and the tap (BODY-21). */ + materialize(): Promise; +} + +/** + * Mirrors up to tapCapBytes of each writeTo call into an internal tap while forwarding the full, + * untruncated payload to the primary sink (BODY-17). The tap clears at the start of every write so a + * retry against a replayable delegate does not accumulate stale bytes (BODY-18). No handle onto the tap's + * backing buffer is exposed -- snapshot() is the only way to read it (BODY-37). `@internal` -- unwired + * until Phase 7 supplies a Logger to drive it. + */ +export function withRequestLogging( + delegate: Body, + tapCapBytes: number, +): LoggedBody { + // BODY-32: reject a negative cap, clamp to the platform's max single-array size. Without the guard a + // negative cap makes `tap.size < cap` permanently false and the tee silently mirrors nothing. + invariant( + tapCapBytes >= 0, + `tapCapBytes must be non-negative, got ${String(tapCapBytes)}`, + ); + const cap = Math.min(tapCapBytes, MAX_BYTE_ARRAY_LENGTH); + + function wrap(inner: Body): LoggedBody { + // Per-wrapper, never hoisted to the factory scope. BODY-21 asks materialize() to preserve the tap + // *cap*, not to share the buffer: two live wrappers over one ByteQueue means BODY-18's clear-on-write + // in the materialized wrapper silently rewrites the preview the pre-materialization wrapper is still + // holding -- which is precisely what a Phase 7 retry loop does between attempts. + const tap = new ByteQueue(); + return { + kind: inner.kind, + mediaType: inner.mediaType, + contentLength: inner.contentLength, + get replayable() { + return inner.replayable; + }, + async writeTo(sink: WritableStream): Promise { + tap.clear(); // BODY-18 + const writer = sink.getWriter(); + const tapped = new WritableStream({ + write: async chunk => { + if (tap.size < cap) { + const room = cap - tap.size; + // BODY-20/IO-27: mirror BEFORE forwarding, so a failing primary write still captures + // the chunk that failed. + tap.writeBytes( + room >= chunk.length ? chunk : chunk.subarray(0, room), + ); + } + await writer.write(chunk); // BODY-19: the full payload always reaches the primary + invariant( + tap.size <= cap, + `tap grew past its ${String(cap)}-byte cap`, + ); + }, + close: async () => { + await writer.close(); + }, + }); + await inner.writeTo(tapped); + }, + snapshot(): Uint8Array { + return tap.snapshot(); + }, + materialize: async () => wrap(await materialize(inner)), + }; + } + + return wrap(delegate); +} diff --git a/packages/core/src/body/response-body-logging.test.ts b/packages/core/src/body/response-body-logging.test.ts new file mode 100644 index 0000000..6108c07 --- /dev/null +++ b/packages/core/src/body/response-body-logging.test.ts @@ -0,0 +1,270 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/body/response-body-logging.test.ts +// Exercises: BODY-22 (lazy, drain-once), BODY-23 (fits-cap: full capture, repeatable non-consuming +// reads), BODY-24 (exceeds-cap: prefix+tail once, second read fails), BODY-26 (drain failure cached, +// partial bytes retained, error() does not drain), BODY-27 (close-once shared guard), BODY-28 (captured +// buffer survives close), BODY-29 (reported length), BODY-32 (negative cap rejected), BODY-25 (a +// zero-length delegate chunk is a stream-contract violation, never end-of-stream) +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import {InvariantViolation} from '../invariant.js'; +import {SourceContractViolationError} from '../io/errors.js'; +import {withResponseLogging} from './response-body-logging.js'; + +function readableOf(...chunks: number[][]): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(Uint8Array.from(chunk)); + controller.close(); + }, + }); +} + +async function readAll( + stream: ReadableStream, +): Promise { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const {done, value} = await reader.read(); + if (done) break; + chunks.push(value); + total += value.length; + } + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +} + +describe('withResponseLogging regimes (BODY-22..24)', () => { + test('nothing is captured until read() is called (BODY-22 laziness)', () => { + expect( + withResponseLogging(readableOf([1, 2, 3]), 100).snapshot().length, + ).toBe(0); + }); + + test('fits-cap: fully captures, and every later read() is a fresh non-consuming view (BODY-23)', async () => { + const logged = withResponseLogging(readableOf([1, 2, 3]), 100); + expect([...(await readAll(await logged.read()))]).toEqual([1, 2, 3]); + expect([...(await readAll(await logged.read()))]).toEqual([1, 2, 3]); + expect([...logged.snapshot()]).toEqual([1, 2, 3]); + }); + + test('exceeds-cap: replays the prefix then the live tail, consumer receives the complete body (BODY-24)', async () => { + const logged = withResponseLogging(readableOf([1, 2], [3, 4, 5]), 3); + expect([...(await readAll(await logged.read()))]).toEqual([1, 2, 3, 4, 5]); + expect([...logged.snapshot()]).toEqual([1, 2, 3]); // only the prefix up to the cap is retained + }); + + test('exceeds-cap: a second read() throws (BODY-24)', async () => { + const logged = withResponseLogging(readableOf([1, 2, 3, 4]), 1); + await logged.read(); + expect(logged.read()).rejects.toThrow(); + }); +}); + +describe('withResponseLogging lifecycle (BODY-27, 28)', () => { + test('the delegate is cancelled at most once however often close is called (BODY-27)', async () => { + // The exceeds-cap regime deliberately: on the fits path the delegate is already closed by the time + // the guard runs, so cancel() is a spec no-op and a counter there proves nothing -- and BODY-27 + // exists for the transports that are less forgiving than a spec-compliant ReadableStream. + const {stream, cancels} = countingStream([1, 2], [3, 4]); + const logged = withResponseLogging(stream, 1); + await logged.read(); + await logged.close(); + await logged.close(); + await logged.close(); + expect(cancels()).toBe(1); + }); + + test('the wrapper close and the tail stream share one guard (BODY-27)', async () => { + const {stream, cancels} = countingStream([1, 2], [3, 4]); + const logged = withResponseLogging(stream, 1); + const tail = await logged.read(); + await tail.cancel(); // tail path + await logged.close(); // wrapper path + expect(cancels()).toBe(1); + }); + + test('the captured buffer survives close -- snapshot still works after (BODY-28)', async () => { + const logged = withResponseLogging(readableOf([1, 2]), 100); + await readAll(await logged.read()); + await logged.close(); + expect([...logged.snapshot()]).toEqual([1, 2]); + }); + + test('[Symbol.asyncDispose] delegates to close()', async () => { + await withResponseLogging(readableOf([1]), 100)[Symbol.asyncDispose](); + }); +}); + +describe('withResponseLogging error caching (BODY-26)', () => { + test('a drain failure is cached: read() re-throws it, snapshot keeps the partial bytes (BODY-26)', () => { + const boom = new Error('upstream reset'); + const failing = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.from([1, 2])); + }, + pull(controller) { + controller.error(boom); + }, + }); + const logged = withResponseLogging(failing, 100); + + expect(logged.read()).rejects.toBe(boom); + expect(logged.read()).rejects.toBe(boom); // same cached error, upstream never re-read + expect([...logged.snapshot()]).toEqual([1, 2]); // partial capture retained, snapshot does not throw + expect(logged.error()).toBe(boom); + }); + + test('error() reports null without triggering a drain (BODY-26)', () => { + const logged = withResponseLogging(readableOf([1, 2, 3]), 100); + expect(logged.error()).toBeNull(); + expect(logged.snapshot().length).toBe(0); // still undrained -- error() did not read anything + }); +}); + +describe('withResponseLogging properties and lengths (BODY-29..34)', () => { + test('contentLength is the captured size when it fits, the declared length when it does not (BODY-29)', async () => { + const fits = withResponseLogging(readableOf([1, 2, 3]), 100, 3); + await fits.read(); + expect(fits.contentLength).toBe(3); + + const exceeds = withResponseLogging(readableOf([1, 2, 3, 4]), 2, 4); + await exceeds.read(); + expect(exceeds.contentLength).toBe(4); // the delegate's true length, not the 2-byte prefix + }); + + test('a negative cap is rejected at construction (BODY-32)', () => { + expect(() => withResponseLogging(readableOf([1]), -1)).toThrow( + InvariantViolation, + ); + }); + + test('for any (cap, body) pair the consumer receives every byte and the tap stays bounded', async () => { + await fc.assert( + fc.asyncProperty( + fc.uint8Array({minLength: 0, maxLength: 512}), + fc.integer({min: 0, max: 600}), + async (payload, cap) => { + const source = new ReadableStream({ + start(controller) { + if (payload.length > 0) controller.enqueue(payload); + controller.close(); + }, + }); + const logged = withResponseLogging(source, cap); + + // BODY-34: the consumer gets the complete body whichever regime triggered. + expect([...(await readAll(await logged.read()))]).toEqual([ + ...payload, + ]); + // BODY-23/BODY-24: the capture is bounded by the cap either way. + expect(logged.snapshot().length).toBe(Math.min(payload.length, cap)); + }, + ), + {seed: 0x3b}, + ); + }); +}); + +/** + * A delegate that counts calls to `cancel()` and throws on the second, standing in for the transports + * BODY-27 names -- the ones that do not tolerate a double close. Counting the underlying source's + * `cancel` callback instead would prove nothing: the Streams spec makes a second `cancel()` on an + * already-cancelled stream a resolved no-op that never reaches the source. + */ +function countingStream(...chunks: number[][]): { + stream: ReadableStream; + cancels: () => number; +} { + let cancels = 0; + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(Uint8Array.from(chunk)); + controller.close(); + }, + }); + const delegate = stream.cancel.bind(stream); + stream.cancel = async (reason?: unknown): Promise => { + cancels += 1; + if (cancels > 1) + throw new Error('transport does not tolerate a double close'); + return delegate(reason); + }; + return {stream, cancels: () => cancels}; +} + +describe('close failures (BODY-28)', () => { + test('a non-TypeError from cancel() propagates rather than being swallowed', async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.from([1, 2])); + }, + }); + stream.cancel = (): Promise => + Promise.reject(new Error('CONNECTION STUCK')); + const logged = withResponseLogging(stream, 1); + await logged.read(); // exceeds-cap regime leaves the delegate live, so close() really cancels + expect(logged.close()).rejects.toThrow('CONNECTION STUCK'); + }); +}); + +describe('delegate stream contract (BODY-25)', () => { + function emptyThenData(): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(0)); + controller.enqueue(Uint8Array.from([7])); + controller.close(); + }, + }); + } + + test('a zero-length chunk is raised, never tolerated as a no-op', () => { + // Matches RetentionWindow under IO-17's identical rule: a response body reaches both this tee and + // BufferedSource, so the two layers must not disagree about the same upstream. + const logged = withResponseLogging(emptyThenData(), 100); + expect(logged.read()).rejects.toThrow(SourceContractViolationError); + }); + + test('the violation is cached like any other drain failure (BODY-26)', () => { + const logged = withResponseLogging(emptyThenData(), 100); + expect(logged.read()).rejects.toThrow(SourceContractViolationError); + expect(logged.error()).toBeInstanceOf(SourceContractViolationError); + expect(logged.read()).rejects.toThrow(SourceContractViolationError); + }); +}); + +describe('snapshot is a drain trigger (BODY-22)', () => { + test('calling snapshot starts the drain, without a read()', async () => { + const logged = withResponseLogging(readableOf([1, 2, 3]), 100); + expect([...logged.snapshot()]).toEqual([]); // synchronous: the drain has only just been started + await new Promise(resolve => setTimeout(resolve, 0)); + expect([...logged.snapshot()]).toEqual([1, 2, 3]); + }); + + test('the drain still happens exactly once (BODY-22)', async () => { + const logged = withResponseLogging(readableOf([1, 2, 3]), 100); + logged.snapshot(); + logged.snapshot(); + expect([...(await readAll(await logged.read()))]).toEqual([1, 2, 3]); + }); + + test('a snapshot-triggered drain failure still reaches read(), and does not go unhandled', async () => { + const stream = new ReadableStream({ + start(controller) { + controller.error(new Error('UPSTREAM GONE')); + }, + }); + const logged = withResponseLogging(stream, 100); + expect([...logged.snapshot()]).toEqual([]); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(logged.read()).rejects.toThrow('UPSTREAM GONE'); + }); +}); diff --git a/packages/core/src/body/response-body-logging.ts b/packages/core/src/body/response-body-logging.ts new file mode 100644 index 0000000..7652a0c --- /dev/null +++ b/packages/core/src/body/response-body-logging.ts @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/body/response-body-logging.ts +import {invariant} from '../invariant.js'; +import {ByteQueue} from '../io/byte-queue.js'; +import {SourceContractViolationError} from '../io/errors.js'; +import {MAX_BYTE_ARRAY_LENGTH} from '../io/limits.js'; +import {ConsumedBodyError} from './errors.js'; + +export interface LoggedResponseBody extends AsyncDisposable { + /** + * Returns a stream serving the body. Lazy -- nothing is read from the delegate until the first call + * (BODY-22). Fits-cap regime: every call, including calls after the first, returns a fresh + * non-consuming view over the captured bytes (BODY-23). Exceeds-cap regime: exactly one call is + * allowed; a second throws (BODY-24). If the drain failed, every call re-throws the cached error. + */ + read(): Promise>; + /** Non-consuming; reflects whatever has been captured so far, even after a failed drain (BODY-26). */ + snapshot(): Uint8Array; + /** The cached drain failure, or null. MUST NOT trigger a drain (BODY-26). */ + error(): Error | null; + /** Captured size iff fully captured within the cap, else the delegate's declared length (BODY-29). */ + readonly contentLength: number; + close(): Promise; +} + +/** + * Mutable state for one wrapper instance. Extracted from the factory closure so the factory stays under + * the 70-line function cap and each step below is independently testable. + */ +interface DrainState { + readonly captured: ByteQueue; + readonly reader: ReadableStreamDefaultReader; + readonly delegate: ReadableStream; + readonly cap: number; + regime: 'undrained' | 'fits' | 'exceeds'; + tailConsumed: boolean; + pendingTailChunk: Uint8Array | undefined; + failure: Error | null; + closed: boolean; + started: Promise | undefined; +} + +/** BODY-27: one close-once guard shared by the wrapper's close and the tail stream's completion. */ +async function closeDelegate(state: DrainState): Promise { + if (state.closed) return; + state.closed = true; + // MUST precede cancel(): cancel() rejects with TypeError on a locked stream, and reading to done does + // not release the lock (see Response.bytes for the same trap). + state.reader.releaseLock(); + // BODY-28: on the fits-cap path the capture already succeeded, so a close failure is best-effort and + // must not surface as a drain error. Narrowed to the one thing cancel() reports here. + await state.delegate.cancel().catch((error: unknown) => { + if (!(error instanceof TypeError)) throw error; + }); +} + +/** + * Reads until EOF (fits regime) or until the cap is reached (exceeds regime, leaving the delegate open + * and the overflow chunk staged). BODY-26: a failure is cached, never allowed to truncate silently. + * + * BODY-25: a delegate chunk of zero bytes is a stream-contract violation, not a no-op and never EOF -- + * EOF is signalled only by `{done: true}`. `ReadableStreamDefaultReader.read()` carries no requested + * count, so the requirement's "for a positive requested count" has no literal analog, but the tolerant + * reading is the wrong one to pick: `RetentionWindow` raises on the same input under IO-17's identical + * rule, and a response body reaches both this tee and `BufferedSource`, so a divergence would make one + * upstream fail or succeed depending only on which wrapper it passed through. + */ +async function drainOnce(state: DrainState): Promise { + try { + for (;;) { + // Serial by necessity: each read depends on the previous one advancing the cursor. + const {done, value} = await state.reader.read(); + if (done) { + state.regime = 'fits'; + await closeDelegate(state); + return; + } + if (value.length === 0) { + throw new SourceContractViolationError( + 'source delivered 0 bytes without signalling end of stream', + ); // BODY-25 + } + if (state.captured.size + value.length <= state.cap) { + state.captured.writeBytes(value); + continue; + } + const room = state.cap - state.captured.size; + if (room > 0) state.captured.writeBytes(value.subarray(0, room)); + state.pendingTailChunk = value.subarray(room); + state.regime = 'exceeds'; + invariant( + state.captured.size <= state.cap, + `captured past the ${String(state.cap)}-byte cap`, + ); + return; + } + } catch (error: unknown) { + // BODY-26: retain what was read and cache the error rather than discarding a partial capture. + state.failure = error instanceof Error ? error : new Error(String(error)); + throw state.failure; + } +} + +/** + * BODY-22's once-only, lazily-started drain. Concurrent first accesses share the one in-flight promise. + * + * The detached `.catch` matters: a snapshot-triggered drain has no awaiter, so without it a drain failure + * becomes an unhandled rejection. Attaching a handler to a *copy* leaves the stored promise rejected, so + * `read()` still re-throws the cached failure on every call (BODY-26). + */ +function startDrain(state: DrainState): Promise { + state.started ??= drainOnce(state); + void state.started.catch(() => undefined); + return state.started; +} + +/** A fresh, non-consuming view over the fully-captured bytes. Repeatable (BODY-23). */ +function capturedStream(state: DrainState): ReadableStream { + const bytes = state.captured.snapshot(); + return new ReadableStream({ + start(controller) { + if (bytes.length > 0) controller.enqueue(bytes); + controller.close(); + }, + }); +} + +/** + * Replays the captured prefix, then continues from the still-live tail (BODY-24). Pull-driven, one + * chunk per pull: looping inside start() would eagerly materialize the whole remaining body in the + * controller's queue -- precisely the oversized payloads the cap exists to keep off the heap. + */ +function tailStream(state: DrainState): ReadableStream { + const prefix = state.captured.snapshot(); + let staged: Uint8Array | undefined = state.pendingTailChunk; + let prefixSent = false; + return new ReadableStream({ + async pull(controller) { + if (!prefixSent) { + prefixSent = true; + if (prefix.length > 0) { + controller.enqueue(prefix); + return; + } + } + if (staged !== undefined) { + const chunk = staged; + staged = undefined; + if (chunk.length > 0) { + controller.enqueue(chunk); + return; + } + } + const {done, value} = await state.reader.read(); + if (done) { + await closeDelegate(state); + controller.close(); + return; + } + controller.enqueue(value); + }, + async cancel() { + await closeDelegate(state); + }, + }); +} + +/** + * Wraps a raw response body stream (BODY-22..29). `@internal` -- unwired until Phase 7 supplies a Logger. + */ +export function withResponseLogging( + delegate: ReadableStream, + capBytes: number, + declaredLength = -1, +): LoggedResponseBody { + invariant( + capBytes >= 0, + `capBytes must be non-negative, got ${String(capBytes)}`, + ); // BODY-32 + const state: DrainState = { + captured: new ByteQueue(), + reader: delegate.getReader(), + delegate, + cap: Math.min(capBytes, MAX_BYTE_ARRAY_LENGTH), // BODY-32: clamp, do not attempt an impossible allocation + regime: 'undrained', + tailConsumed: false, + pendingTailChunk: undefined, + failure: null, + closed: false, + started: undefined, + }; + + return { + async read(): Promise> { + await startDrain(state); // a cached failure re-throws here on every call (BODY-26) + if (state.regime === 'fits') return capturedStream(state); + if (state.tailConsumed) { + throw new ConsumedBodyError('logged-response'); + } + state.tailConsumed = true; + return tailStream(state); + }, + snapshot(): Uint8Array { + // BODY-22 lists snapshot in the drain's trigger set alongside read. The accessor is synchronous, + // so it starts the drain and returns what has been captured so far rather than awaiting it; a + // later read() awaits the very same in-flight promise, so the delegate is still read exactly once. + // (BODY-26's "snapshot returns the partial bytes without throwing" is why it cannot await here.) + void startDrain(state); + return state.captured.snapshot(); + }, + error: () => state.failure, // deliberately does not drain (BODY-26) + get contentLength(): number { + // BODY-29: the capture is the true length only when the whole body fit within the cap. + return state.regime === 'fits' ? state.captured.size : declaredLength; + }, + close: () => closeDelegate(state), + [Symbol.asyncDispose]: () => closeDelegate(state), + }; +} diff --git a/packages/core/src/body/simple-bodies.test.ts b/packages/core/src/body/simple-bodies.test.ts new file mode 100644 index 0000000..2965924 --- /dev/null +++ b/packages/core/src/body/simple-bodies.test.ts @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/body/simple-bodies.test.ts +// Exercises: HTTP-36/BODY-1 (mediaType, contentLength, replayable, writeTo), HTTP-38/BODY-35 (replayable +// by source; form-urlencoded uses "+" for space, distinct from RFC 3986 query encoding; a field value +// that cannot be rendered is raised, never dropped), HTTP-26/HTTP-51 (a media type is header-safe), +// RECOV-12 (a close failure never masks the primary write failure) +import {describe, expect, test} from 'bun:test'; +import {MediaTypeParseError} from '../http/errors.js'; +import {FormBodyValidationError} from './errors.js'; +import { + byteArrayBody, + formUrlEncodedBody, + stringBody, +} from './simple-bodies.js'; + +async function drain(body: { + writeTo: (sink: WritableStream) => Promise; +}): Promise { + const chunks: Uint8Array[] = []; + await body.writeTo( + new WritableStream({write: chunk => void chunks.push(chunk)}), + ); + const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const result = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; + } + return result; +} + +describe('ByteArrayBody', () => { + test('reports kind, mediaType, contentLength, and is always replayable', () => { + const body = byteArrayBody( + Uint8Array.from([1, 2, 3]), + 'application/octet-stream', + ); + expect(body.kind).toBe('byte-array'); + expect(body.mediaType).toBe('application/octet-stream'); + expect(body.contentLength).toBe(3); + expect(body.replayable).toBe(true); + }); + + test('defaults mediaType to undefined -- absence is undefined, never null', () => { + expect(byteArrayBody(Uint8Array.from([1])).mediaType).toBeUndefined(); + }); + + test('writeTo emits the exact bytes, twice, byte-for-byte identical (BODY-1)', async () => { + const body = byteArrayBody(Uint8Array.from([9, 8, 7])); + expect([...(await drain(body))]).toEqual([9, 8, 7]); + expect([...(await drain(body))]).toEqual([9, 8, 7]); + }); + + test('holds an independent copy -- mutating the caller array afterwards does not change it', async () => { + const input = Uint8Array.from([1, 2, 3]); + const body = byteArrayBody(input); + input[0] = 99; + expect([...(await drain(body))]).toEqual([1, 2, 3]); + }); +}); + +describe('StringBody', () => { + test('encodes UTF-8 and reports the byte length, not the character length', () => { + const body = stringBody('héllo'); + expect(body.contentLength).toBe(6); // "é" is 2 bytes in UTF-8 + expect(body.replayable).toBe(true); + }); + + test('writeTo emits the UTF-8 bytes', async () => { + expect(new TextDecoder().decode(await drain(stringBody('hi')))).toBe('hi'); + }); +}); + +describe('FormUrlEncodedBody (HTTP-38/BODY-35)', () => { + test('mediaType is fixed and the body is always replayable', () => { + const body = formUrlEncodedBody(new Map([['a', 'b']])); + expect(body.mediaType).toBe('application/x-www-form-urlencoded'); + expect(body.replayable).toBe(true); + }); + + test('encodes space as "+" rather than "%20"', async () => { + const body = formUrlEncodedBody(new Map([['q', 'a b']])); + expect(new TextDecoder().decode(await drain(body))).toBe('q=a+b'); + }); + + test('joins multiple params with "&", preserving insertion order', async () => { + const body = formUrlEncodedBody( + new Map([ + ['a', '1'], + ['b', '2'], + ]), + ); + expect(new TextDecoder().decode(await drain(body))).toBe('a=1&b=2'); + }); + + test('percent-encodes reserved characters in keys and values', async () => { + const body = formUrlEncodedBody(new Map([['a&b', 'c=d']])); + expect(new TextDecoder().decode(await drain(body))).toBe('a%26b=c%3Dd'); + }); +}); + +function failingSink(): WritableStream { + return new WritableStream({ + write: () => { + throw new Error('SOCKET GONE'); + }, + }); +} + +function decode(bytes: Uint8Array): string { + return new TextDecoder().decode(bytes); +} + +describe('media types are header-safe (HTTP-26/HTTP-51)', () => { + test('byteArrayBody rejects a media type carrying CR/LF', () => { + expect(() => + byteArrayBody(Uint8Array.from([1]), 'text/plain\r\nX-Injected: pwned'), + ).toThrow(MediaTypeParseError); + }); + + test('stringBody rejects a media type carrying a control character', () => { + expect(() => stringBody('x', 'text/plain\u0007')).toThrow( + MediaTypeParseError, + ); + }); + + test('a parameterised media type is still accepted', () => { + expect( + byteArrayBody(Uint8Array.from([1]), 'text/plain; charset=utf-8') + .mediaType, + ).toBe('text/plain; charset=utf-8'); + }); +}); + +describe('a write failure is never masked by the close (RECOV-12, RETRY-2)', () => { + test('ByteArrayBody surfaces the sink failure', () => { + expect( + byteArrayBody(Uint8Array.from([1, 2])).writeTo(failingSink()), + ).rejects.toThrow('SOCKET GONE'); + }); + + test('StringBody surfaces the sink failure', () => { + expect(stringBody('hi').writeTo(failingSink())).rejects.toThrow( + 'SOCKET GONE', + ); + }); + + test('FormUrlEncodedBody surfaces the sink failure', () => { + expect(formUrlEncodedBody({a: 'b'}).writeTo(failingSink())).rejects.toThrow( + 'SOCKET GONE', + ); + }); +}); + +describe('form field values (HTTP-38/BODY-35)', () => { + test('primitives are rendered rather than dropped', async () => { + const body = formUrlEncodedBody({count: 5, flag: true, big: 9n}); + expect(decode(await drain(body))).toBe('count=5&flag=true&big=9'); + }); + + test('null renders as a valueless parameter', async () => { + expect(decode(await drain(formUrlEncodedBody({empty: null})))).toBe( + 'empty=', + ); + }); + + test('array values render element-wise', async () => { + expect(decode(await drain(formUrlEncodedBody({tag: ['a', 2]})))).toBe( + 'tag=a&tag=2', + ); + }); + + test('a value that cannot be rendered throws naming the field', () => { + expect(() => formUrlEncodedBody({profile: {a: 1}} as never)).toThrow( + FormBodyValidationError, + ); + expect(() => formUrlEncodedBody({profile: {a: 1}} as never)).toThrow( + /"profile"/, + ); + }); + + test('undefined is rejected too -- an absent field is never guessed at', () => { + expect(() => formUrlEncodedBody({missing: undefined} as never)).toThrow( + FormBodyValidationError, + ); + }); +}); diff --git a/packages/core/src/body/simple-bodies.ts b/packages/core/src/body/simple-bodies.ts new file mode 100644 index 0000000..7a7c593 --- /dev/null +++ b/packages/core/src/body/simple-bodies.ts @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/body/simple-bodies.ts +import {QueryParams, type QueryParamsBuilder} from '../http/query-params.js'; +import {invariant} from '../invariant.js'; +import type {Body} from './body.js'; +import {FormBodyValidationError} from './errors.js'; +import {assertHeaderSafeMediaType} from './media-type-safety.js'; +import {withBodyWriter} from './write-body.js'; + +/** + * A body backed by an in-memory byte array (BODY-1). Always replayable. + * + * @public + */ +export class ByteArrayBody implements Body { + readonly kind = 'byte-array' as const; + readonly mediaType: string | undefined; + readonly contentLength: number; + readonly replayable = true; + readonly #bytes: Uint8Array; + + constructor(bytes: Uint8Array, mediaType?: string) { + assertHeaderSafeMediaType(mediaType); // HTTP-26/HTTP-51 + // Defensive copy: `bytes` caller passed might be mutated later (HTTP-1). Kept `#private` -- + // exposing this publicly would let a caller mutate a "replayable" body's contents after + // construction, silently breaking the byte-for-byte-identical guarantee BODY-1 requires. + this.#bytes = Uint8Array.from(bytes); + this.mediaType = mediaType; + this.contentLength = this.#bytes.length; + } + + async writeTo(sink: WritableStream): Promise { + await withBodyWriter(sink, async writer => { + if (this.#bytes.length > 0) await writer.write(this.#bytes); + }); + } +} + +/** + * Creates a replayable ByteArrayBody (BODY-1). + * + * @throws MediaTypeParseError when `mediaType` contains a control character or non-ASCII byte, which + * would let it break out of the header it is rendered into (HTTP-26/HTTP-51). + * @public + */ +export function byteArrayBody( + bytes: Uint8Array, + mediaType?: string, +): ByteArrayBody { + return new ByteArrayBody(bytes, mediaType); +} + +/** + * A body backed by an in-memory string (BODY-1). Always replayable. + * + * @public + */ +export class StringBody implements Body { + readonly kind = 'string' as const; + readonly mediaType: string; + readonly contentLength: number; + readonly replayable = true; + readonly text: string; + readonly #bytes: Uint8Array; + + constructor(text: string, mediaType = 'text/plain; charset=utf-8') { + assertHeaderSafeMediaType(mediaType); // HTTP-26/HTTP-51 + this.text = text; + this.mediaType = mediaType; + this.#bytes = new TextEncoder().encode(text); + this.contentLength = this.#bytes.length; + } + + async writeTo(sink: WritableStream): Promise { + await withBodyWriter(sink, async writer => { + if (this.#bytes.length > 0) await writer.write(this.#bytes); + }); + } +} + +/** + * Creates a replayable StringBody (BODY-1). + * + * @throws MediaTypeParseError when `mediaType` contains a control character or non-ASCII byte, which + * would let it break out of the header it is rendered into (HTTP-26/HTTP-51). + * @public + */ +export function stringBody( + text: string, + mediaType = 'text/plain; charset=utf-8', +): StringBody { + return new StringBody(text, mediaType); +} + +/** + * A form field value. Primitives are rendered with their standard string form; `null` produces a + * valueless parameter. Anything else is rejected rather than silently dropped. + * + * @public + */ +export type FormUrlEncodedValue = string | number | boolean | bigint | null; + +/** + * Accepted input shapes for {@link formUrlEncodedBody}. + * + * @public + */ +export type FormUrlEncodedInput = + | QueryParams + | ReadonlyMap + | Record + | readonly (readonly [string, FormUrlEncodedValue])[]; + +// BODY-35: a form field that is neither a primitive nor null cannot be rendered, and dropping it would +// put a silently incomplete body on the wire. Fail naming the key instead. +function toFieldValue(key: string, value: unknown): string | null { + if (typeof value === 'string' || value === null) return value; + if ( + typeof value === 'number' || + typeof value === 'boolean' || + typeof value === 'bigint' + ) { + return String(value); + } + throw new FormBodyValidationError(key, value); +} + +function addParamValue( + builder: QueryParamsBuilder, + key: string, + value: unknown, +): void { + if (Array.isArray(value)) { + for (const element of value as readonly unknown[]) { + builder.add(key, toFieldValue(key, element)); + } + return; + } + builder.add(key, toFieldValue(key, value)); +} + +function toQueryParams(input: FormUrlEncodedInput): QueryParams { + if (input instanceof QueryParams) return input; + const builder = QueryParams.newBuilder(); + const entries: readonly (readonly [unknown, unknown])[] = + input instanceof Map + ? [...input.entries()] + : Array.isArray(input) + ? (input as readonly (readonly [unknown, unknown])[]) + : Object.entries(input); + for (const [key, value] of entries) { + if (typeof key !== 'string') + throw new FormBodyValidationError(String(key), key); + addParamValue(builder, key, value); + } + return builder.build(); +} + +/** + * A body backed by URL-encoded form data (BODY-1, HTTP-38/BODY-35). Always replayable. + * + * @public + */ +export class FormUrlEncodedBody implements Body { + readonly kind = 'form-urlencoded' as const; + readonly mediaType = 'application/x-www-form-urlencoded'; + readonly contentLength: number; + readonly replayable = true; + readonly params: QueryParams; + readonly #bytes: Uint8Array; + + constructor(input: FormUrlEncodedInput) { + this.params = toQueryParams(input); + // HTTP-38/BODY-35: x-www-form-urlencoded uses '+' for space, distinct from RFC 3986 query encoding. + const encoded = this.params.encode().replace(/%20/g, '+'); + invariant( + !encoded.includes(' '), + 'form-urlencoded encoding produced illegal space', + ); + this.#bytes = new TextEncoder().encode(encoded); + this.contentLength = this.#bytes.length; + } + + async writeTo(sink: WritableStream): Promise { + await withBodyWriter(sink, async writer => { + if (this.#bytes.length > 0) await writer.write(this.#bytes); + }); + } +} + +/** + * Creates a replayable FormUrlEncodedBody (BODY-1, HTTP-38/BODY-35). + * + * @throws FormBodyValidationError when a field name is not a string, or a field value is neither a + * primitive nor `null` -- such a field cannot be rendered and is never dropped silently. + * @public + */ +export function formUrlEncodedBody( + input: FormUrlEncodedInput, +): FormUrlEncodedBody { + return new FormUrlEncodedBody(input); +} diff --git a/packages/core/src/body/stream-body.test.ts b/packages/core/src/body/stream-body.test.ts new file mode 100644 index 0000000..396b929 --- /dev/null +++ b/packages/core/src/body/stream-body.test.ts @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/body/stream-body.test.ts +// Exercises: BODY-9 (always single-use -- no generic mark/reset on Node's ReadableStream), BODY-3 +// (second write fails loudly and is race-safe), BODY-8 (caller's stream is not force-closed -- read to +// natural exhaustion), HTTP-39/BODY-10 (declared length verified, short stream raises +// delivered-of-declared, and an overrunning stream is stopped BEFORE the extra bytes reach the sink), +// IO-3 (a contentLength below the -1 sentinel is rejected), HTTP-26/HTTP-51 (a media type is +// header-safe), RECOV-12 (a close failure never masks the primary write failure) +import {describe, expect, test} from 'bun:test'; +import {MediaTypeParseError} from '../http/errors.js'; +import {InvariantViolation} from '../invariant.js'; +import {EndOfStreamError} from '../io/errors.js'; +import {ConsumedBodyError} from './errors.js'; +import {streamBody} from './stream-body.js'; + +function readableOf(...chunks: number[][]): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(Uint8Array.from(chunk)); + controller.close(); + }, + }); +} + +function collectingSink(): { + sink: WritableStream; + written: () => Uint8Array; +} { + const chunks: Uint8Array[] = []; + const sink = new WritableStream({ + write: chunk => void chunks.push(chunk), + }); + return { + sink, + written: () => { + const total = chunks.reduce((sum, c) => sum + c.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; + }, + }; +} + +describe('StreamBody properties and writeTo (BODY-1, BODY-9)', () => { + test('is always single-use, regardless of declared length (BODY-9)', () => { + expect(streamBody(readableOf([1, 2]), undefined, 2).replayable).toBe(false); + }); + + test('reports the caller-supplied mediaType and contentLength', () => { + const body = streamBody(readableOf([1]), 'application/octet-stream', 1); + expect(body.mediaType).toBe('application/octet-stream'); + expect(body.contentLength).toBe(1); + }); + + test('defaults contentLength to -1 (unknown)', () => { + expect(streamBody(readableOf([1])).contentLength).toBe(-1); + }); + + test('writeTo forwards the exact bytes', async () => { + const {sink, written} = collectingSink(); + await streamBody(readableOf([1, 2], [3])).writeTo(sink); + expect([...written()]).toEqual([1, 2, 3]); + }); + + test('a second write throws ConsumedBodyError (BODY-3)', async () => { + const body = streamBody(readableOf([1])); + await body.writeTo(collectingSink().sink); + expect(body.writeTo(collectingSink().sink)).rejects.toThrow( + ConsumedBodyError, + ); + }); +}); + +describe('StreamBody declared length verification (HTTP-39, BODY-10, IO-3)', () => { + test('a declared length the stream cannot satisfy raises EndOfStreamError (HTTP-39/BODY-10)', () => { + const body = streamBody(readableOf([1, 2]), undefined, 5); + expect(body.writeTo(collectingSink().sink)).rejects.toThrow( + EndOfStreamError, + ); + }); + + test('a satisfied declared length writes exactly that many bytes (HTTP-39/BODY-10)', async () => { + const {sink, written} = collectingSink(); + await streamBody(readableOf([1, 2], [3]), undefined, 3).writeTo(sink); + expect([...written()]).toEqual([1, 2, 3]); + }); + + test('a declared length of 0 is a legitimate empty write (BODY-10)', () => { + const {sink, written} = collectingSink(); + void streamBody( + new ReadableStream({ + start: c => { + c.close(); + }, + }), + undefined, + 0, + ).writeTo(sink); + expect(written().length).toBe(0); + }); + + test('a contentLength below the -1 sentinel is rejected at construction (IO-3)', () => { + expect(() => streamBody(readableOf([1]), undefined, -2)).toThrow( + InvariantViolation, + ); + }); + + test('concurrent first writes: exactly one proceeds, the other rejects (BODY-3 race-safety)', async () => { + const body = streamBody(readableOf([1, 2, 3])); + const results = await Promise.allSettled([ + body.writeTo(collectingSink().sink), + body.writeTo(collectingSink().sink), + ]); + expect(results.filter(r => r.status === 'fulfilled').length).toBe(1); + expect(results.filter(r => r.status === 'rejected').length).toBe(1); + }); +}); + +interface SinkState { + written: number[]; + closed: boolean; + aborted: boolean; +} + +function probeSink(): {state: SinkState; sink: WritableStream} { + const state: SinkState = {written: [], closed: false, aborted: false}; + const sink = new WritableStream({ + write: chunk => void state.written.push(...chunk), + close: () => void (state.closed = true), + abort: () => void (state.aborted = true), + }); + return {state, sink}; +} + +describe('a mis-framed body never reaches the wire (HTTP-39/BODY-10)', () => { + test('an overrunning chunk is refused before any of it is written', () => { + const {state, sink} = probeSink(); + expect( + streamBody(readableOf([1, 2, 3, 4, 5, 6, 7, 8]), undefined, 3).writeTo( + sink, + ), + ).rejects.toThrow(EndOfStreamError); + // Not [1,2,3,4,5,6,7,8]: once a transport has stamped Content-Length: 3, the surplus sits on the + // socket where the peer reads it as the start of the next message. + expect(state.written).toEqual([]); + expect(state.aborted).toBe(true); + }); + + test('bytes written before the overrun stay written, the straddling chunk does not', () => { + const {state, sink} = probeSink(); + expect( + streamBody(readableOf([1, 2], [3, 4]), undefined, 3).writeTo(sink), + ).rejects.toThrow(EndOfStreamError); + expect(state.written).toEqual([1, 2]); + }); + + test('a short stream aborts the sink rather than closing it cleanly', () => { + const {state, sink} = probeSink(); + expect( + streamBody(readableOf([1]), undefined, 5).writeTo(sink), + ).rejects.toThrow(EndOfStreamError); + expect(state.aborted).toBe(true); + expect(state.closed).toBe(false); // a truncated body is never signalled as complete + }); + + test('an exact-length stream closes the sink cleanly', async () => { + const {state, sink} = probeSink(); + await streamBody(readableOf([1, 2, 3]), undefined, 3).writeTo(sink); + expect(state.written).toEqual([1, 2, 3]); + expect(state.closed).toBe(true); + expect(state.aborted).toBe(false); + }); +}); + +describe('StreamBody media type and failure propagation', () => { + test('rejects a media type carrying CR/LF (HTTP-26/HTTP-51)', () => { + expect(() => + streamBody(readableOf([1]), 'text/plain\r\nX-Injected: pwned'), + ).toThrow(MediaTypeParseError); + }); + + test('surfaces the sink failure, not a close TypeError (RECOV-12)', () => { + const sink = new WritableStream({ + write: () => { + throw new Error('SOCKET GONE'); + }, + }); + expect( + streamBody(readableOf([1, 2]), undefined, 2).writeTo(sink), + ).rejects.toThrow('SOCKET GONE'); + }); +}); diff --git a/packages/core/src/body/stream-body.ts b/packages/core/src/body/stream-body.ts new file mode 100644 index 0000000..c473f1e --- /dev/null +++ b/packages/core/src/body/stream-body.ts @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/body/stream-body.ts +import {EndOfStreamError} from '../io/errors.js'; +import {invariant} from '../invariant.js'; +import type {Body} from './body.js'; +import {ConsumedBodyError} from './errors.js'; +import {assertHeaderSafeMediaType} from './media-type-safety.js'; +import {withBodyWriter} from './write-body.js'; + +/** + * A single-use body backed by a caller-supplied stream. + * + * @public + */ +export class StreamBody implements Body { + readonly kind = 'stream' as const; + readonly mediaType: string | undefined; + readonly contentLength: number; + readonly replayable = false; + readonly #stream: ReadableStream; + #consumed = false; + + constructor( + stream: ReadableStream, + mediaType?: string, + contentLength = -1, + ) { + assertHeaderSafeMediaType(mediaType); // HTTP-26/HTTP-51 + invariant( + contentLength >= -1, + `contentLength must be >= -1 (-1 = unknown), got ${String(contentLength)}`, + ); // IO-3 + this.#stream = stream; + this.mediaType = mediaType; + this.contentLength = contentLength; + } + + async writeTo(sink: WritableStream): Promise { + if (this.#consumed) throw new ConsumedBodyError('stream'); + this.#consumed = true; // set before the first await -- BODY-3's race-safety guard + + if (this.contentLength < 0) { + await this.#stream.pipeTo(sink); + return; + } + await this.#writeExactly(sink, this.contentLength); + } + + /** HTTP-39/BODY-10: writes precisely `declared` bytes or raises naming delivered-of-declared. */ + async #writeExactly( + sink: WritableStream, + declared: number, + ): Promise { + const reader = this.#stream.getReader(); + try { + await withBodyWriter(sink, async writer => { + let delivered = 0; + for (;;) { + // Serial by necessity: each read depends on the previous one advancing the cursor. + const {done, value} = await reader.read(); + if (done) break; + // Checked BEFORE the write, not after the loop: once a transport has stamped the declared + // Content-Length, an overrun byte sits on the socket where the peer reads it as the start of + // the next message, and a thrown error cannot recall bytes already written (HTTP-39/BODY-10). + if (delivered + value.length > declared) { + throw new EndOfStreamError(delivered + value.length, declared); + } + delivered += value.length; + await writer.write(value); + } + // Raised inside the writer scope so withBodyWriter aborts: a truncated body must never be + // signalled to the sink as a clean close. + if (delivered !== declared) { + throw new EndOfStreamError(delivered, declared); + } + }); + } finally { + reader.releaseLock(); // BODY-8: release our handle, never cancel the caller's stream + } + } +} + +/** + * Creates a single-use StreamBody (BODY-9). + * + * @throws MediaTypeParseError when `mediaType` contains a control character or non-ASCII byte, which + * would let it break out of the header it is rendered into (HTTP-26/HTTP-51). + * @throws ConsumedBodyError from `writeTo` when the body has already been written once (BODY-3). + * @throws EndOfStreamError from `writeTo` when the stream yields a byte count other than the declared + * `contentLength` (HTTP-39/BODY-10). + * @public + */ +export function streamBody( + stream: ReadableStream, + mediaType?: string, + contentLength = -1, +): StreamBody { + return new StreamBody(stream, mediaType, contentLength); +} diff --git a/packages/core/src/body/typed-response.test.ts b/packages/core/src/body/typed-response.test.ts new file mode 100644 index 0000000..c794015 --- /dev/null +++ b/packages/core/src/body/typed-response.test.ts @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/body/typed-response.test.ts +// Exercises: HTTP-44 (raw fields without touching the body, parse-once memoized including failure -- +// a synchronous throw included), HTTP-45 (concurrent first callers serialized to one parse run) +import {describe, expect, test} from 'bun:test'; +import {Protocol} from '../http/protocol.js'; +import {Request} from '../http/request.js'; +import {Response} from '../http/response.js'; +import {Status} from '../http/status.js'; +import {TypedResponse} from './typed-response.js'; + +function readableOf(text: string): ReadableStream { + return new ReadableStream({ + start(c) { + c.enqueue(new TextEncoder().encode(text)); + c.close(); + }, + }); +} + +function baseResponse( + body: ReadableStream | null = null, +): Response { + return Response.newBuilder() + .request(Request.newBuilder().url('https://example.com').build()) + .protocol(Protocol.HTTP_1_1) + .status(Status.of(200)) + .reasonPhrase('OK') + .body(body) + .build(); +} + +describe('TypedResponse', () => { + test('exposes raw fields without touching the body (HTTP-44)', () => { + const response = baseResponse(readableOf('untouched')); + const typed = new TypedResponse(response, r => r.text()); + expect(typed.status.code).toBe(200); + expect(typed.headers).toBe(response.headers); + expect(typed.protocol).toBe('http/1.1'); + expect(typed.reason).toBe('OK'); + expect(typed.request).toBe(response.request); + expect(response.body?.locked).toBe(false); + }); + + test('parses on first value() call and memoizes the result', async () => { + let calls = 0; + const typed = new TypedResponse(baseResponse(readableOf('x')), () => { + calls += 1; + return Promise.resolve('parsed'); + }); + expect(await typed.value()).toBe('parsed'); + expect(await typed.value()).toBe('parsed'); + expect(calls).toBe(1); + }); + + test('memoizes a thrown failure -- every later call re-throws the same error, parse never re-runs', () => { + let calls = 0; + const failure = new Error('parse failed'); + const typed = new TypedResponse(baseResponse(readableOf('x')), () => { + calls += 1; + return Promise.reject(failure); + }); + expect(typed.value()).rejects.toBe(failure); + expect(typed.value()).rejects.toBe(failure); + expect(calls).toBe(1); + }); + + test('concurrent first callers share one in-flight parse (HTTP-45)', async () => { + let calls = 0; + const typed = new TypedResponse(baseResponse(readableOf('x')), async () => { + calls += 1; + await Promise.resolve(); + return 'value'; + }); + const [a, b] = await Promise.all([typed.value(), typed.value()]); + expect(a).toBe('value'); + expect(b).toBe('value'); + expect(calls).toBe(1); + }); +}); + +describe('memoization covers a synchronously-throwing parser (HTTP-44)', () => { + test('the handler runs once even when it throws before returning a promise', () => { + let calls = 0; + // Typed `=> Promise` but not `async`: validating an argument before the first await is ordinary, + // and a bare `??=` never completes the assignment when the right-hand side throws. + const typed = new TypedResponse( + baseResponse(readableOf('x')), + () => { + calls += 1; + throw new Error('sync boom'); + }, + ); + for (let attempt = 0; attempt < 3; attempt += 1) { + expect(typed.value()).rejects.toThrow('sync boom'); + } + expect(calls).toBe(1); + }); + + test('the same rejected promise is handed back, never a second body read', () => { + const typed = new TypedResponse( + baseResponse(readableOf('x')), + () => { + throw new Error('sync boom'); + }, + ); + const first = typed.value(); + expect(typed.value()).toBe(first); + expect(first).rejects.toThrow('sync boom'); + }); +}); diff --git a/packages/core/src/body/typed-response.ts b/packages/core/src/body/typed-response.ts new file mode 100644 index 0000000..bbba8fd --- /dev/null +++ b/packages/core/src/body/typed-response.ts @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/body/typed-response.ts +import type {Request} from '../http/request.js'; +import type {Response} from '../http/response.js'; + +/** + * A typed view over an HTTP response (HTTP-44). Wraps an underlying raw Response and a parser function, + * materializing and parsing the response value lazily on the first call to `value()`. + * + * Deliberately does NOT expose the underlying `Response` itself (only its status/headers/protocol/ + * reason/request, per HTTP-44) -- doing so would let a caller read the single-use body directly, + * bypassing `value()`'s memoization and the HTTP-45 in-flight-promise serialization entirely. + * + * @public + */ +export class TypedResponse { + readonly #response: Response; + readonly #parse: (response: Response) => Promise; + #memoized: Promise | undefined; + + constructor(response: Response, parse: (response: Response) => Promise) { + this.#response = response; + this.#parse = parse; + } + + get status(): Response['status'] { + return this.#response.status; + } + + get headers(): Response['headers'] { + return this.#response.headers; + } + + get protocol(): string { + return this.#response.protocol.token; // lower-case token string (Protocol.token) + } + + get reason(): string | undefined { + return this.#response.reasonPhrase; + } + + /** The originating request (HTTP-44). Accessing raw fields never consumes the body. */ + get request(): Request { + return this.#response.request; + } + + /** + * Lazily parses and returns the typed value. Memoized: the parser function runs at most once, and + * subsequent calls return the same parsed value (or re-throw the same error) without re-parsing or + * re-reading the body (HTTP-44). Concurrent first callers share the single in-flight parse (HTTP-45). + * + * @throws Whatever the parser raises -- rethrown identically on every later call, never re-parsed. + */ + value(): Promise { + // The `async` wrapper is load-bearing: a parser is typed `=> Promise` but may still be a plain + // function that throws synchronously (validating an argument before the first await is ordinary). + // A bare `this.#memoized ??= this.#parse(...)` never completes the assignment in that case, so the + // handler re-runs on the next call and re-reads a single-use body whose bytes are already gone -- + // exactly what HTTP-44's "without re-running the handler or re-reading the body" forbids. + this.#memoized ??= (async () => this.#parse(this.#response))(); + return this.#memoized; + } +} diff --git a/packages/core/src/body/write-body.test.ts b/packages/core/src/body/write-body.test.ts new file mode 100644 index 0000000..5cb8d33 --- /dev/null +++ b/packages/core/src/body/write-body.test.ts @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/body/write-body.test.ts +// Exercises: RECOV-12 (a failure inside the writer scope is never masked by the teardown -- the sink is +// aborted, and an abort() that itself rejects does not displace the primary failure), RETRY-2 (the +// primary failure reaches the caller unwrapped so classification can walk its own cause chain) +import {describe, expect, test} from 'bun:test'; +import {rejection} from '../io/test-support/rejection.js'; +import {withBodyWriter} from './write-body.js'; + +interface SinkLog { + readonly chunks: Uint8Array[]; + closed: boolean; + abortReason: unknown; +} + +function recordingSink(overrides: UnderlyingSink = {}): { + stream: WritableStream; + log: SinkLog; +} { + const log: SinkLog = {chunks: [], closed: false, abortReason: undefined}; + const stream = new WritableStream({ + write: chunk => void log.chunks.push(chunk), + close: () => void (log.closed = true), + abort: reason => void (log.abortReason = reason), + ...overrides, + }); + return {stream, log}; +} + +describe('withBodyWriter success path', () => { + test('writes through and closes the sink', async () => { + const {stream, log} = recordingSink(); + + await withBodyWriter(stream, async writer => { + await writer.write(Uint8Array.from([1, 2])); + }); + + expect(log.chunks).toEqual([Uint8Array.from([1, 2])]); + expect(log.closed).toBe(true); + expect(log.abortReason).toBeUndefined(); + }); + + test('a close failure propagates unwrapped (RETRY-2)', async () => { + const {stream} = recordingSink({ + close: () => { + throw new Error('CLOSE FAILED'); + }, + }); + + const error = await rejection( + withBodyWriter(stream, () => Promise.resolve()), + ); + + expect(error.message).toBe('CLOSE FAILED'); + }); +}); + +describe('withBodyWriter failure path (RECOV-12, RETRY-2)', () => { + test('aborts the sink with the primary failure and rethrows it', async () => { + const {stream, log} = recordingSink(); + const primary = new Error('SOCKET GONE'); + + const error = await rejection( + withBodyWriter(stream, () => Promise.reject(primary)), + ); + + expect(error).toBe(primary); + expect(log.abortReason).toBe(primary); + expect(log.closed).toBe(false); + }); + + test('an abort() that itself rejects does not displace the primary failure', async () => { + const {stream} = recordingSink({ + abort: () => { + throw new Error('ABORT FAILED'); + }, + }); + const primary = new Error('SOCKET GONE'); + + const error = await rejection( + withBodyWriter(stream, () => Promise.reject(primary)), + ); + + expect(error).toBe(primary); + }); + + test('aborting an already-errored stream still surfaces the primary failure', async () => { + // The sink's own write() poisons the stream, so abort() runs against a stream that is already + // errored -- the case the naive `finally { close() }` shape turns into a bogus TypeError. + const {stream} = recordingSink({ + write: () => { + throw new Error('SINK EXPLODED'); + }, + }); + + const error = await rejection( + withBodyWriter(stream, async writer => { + await writer.write(Uint8Array.from([1])); + }), + ); + + expect(error.message).toBe('SINK EXPLODED'); + }); +}); diff --git a/packages/core/src/body/write-body.ts b/packages/core/src/body/write-body.ts new file mode 100644 index 0000000..535e320 --- /dev/null +++ b/packages/core/src/body/write-body.ts @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/body/write-body.ts + +/** + * Runs `write` against a fresh writer over `sink`, closing on success and aborting on failure. + * + * The naive shape -- `try { ... } finally { await writer.close(); }` -- is wrong twice over. Closing an + * already-errored writer rejects with a TypeError, and a throwing `finally` *replaces* the in-flight + * exception, so the real "connection died mid-upload" cause is destroyed rather than chained (RECOV-12). + * That is not merely a bad message: RETRY-2 classifies a failure by walking its cause chain, so an I/O + * failure surfacing as a TypeError about closing a stream is silently declassified as non-retryable. + * + * Aborting rather than closing on failure also tells the transport the message is broken; a clean close + * would signal a complete body that was never fully written. + */ +export async function withBodyWriter( + sink: WritableStream, + write: (writer: WritableStreamDefaultWriter) => Promise, +): Promise { + const writer = sink.getWriter(); + try { + await write(writer); + } catch (error: unknown) { + // Best-effort: abort() resolves on an already-errored stream, and a sink whose own abort() throws + // must not displace the primary failure either. + await writer.abort(error).catch(() => undefined); + throw error; + } + // On the success path a close failure IS the primary failure, so it propagates unwrapped. + await writer.close(); +} diff --git a/packages/core/src/http/charset.ts b/packages/core/src/http/charset.ts new file mode 100644 index 0000000..8abe1c1 --- /dev/null +++ b/packages/core/src/http/charset.ts @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/http/charset.ts +import {MediaType} from './media-type.js'; + +/** + * HTTP-42's charset resolution: the media type's declared `charset`, falling back to UTF-8 when the + * media type is absent or unparseable. Never throws. + */ +export function resolveCharset(mediaType: string | undefined): string { + if (mediaType === undefined) return 'utf-8'; + try { + return MediaType.parse(mediaType).charset ?? 'utf-8'; + } catch { + return 'utf-8'; + } +} + +/** + * Decodes with `charset`, falling back to UTF-8 when the label is unknown (HTTP-42). `TextDecoder` + * throws a RangeError on an unrecognized label, which callers on an error path are least able to handle. + */ +export function decodeText(bytes: Uint8Array, charset: string): string { + try { + return new TextDecoder(charset).decode(bytes); + } catch { + return new TextDecoder('utf-8').decode(bytes); + } +} diff --git a/packages/core/src/http/request.test.ts b/packages/core/src/http/request.test.ts index 568ff7c..2506d7e 100644 --- a/packages/core/src/http/request.test.ts +++ b/packages/core/src/http/request.test.ts @@ -5,6 +5,7 @@ // immutability) import {describe, expect, test} from 'bun:test'; import fc from 'fast-check'; +import {stringBody} from '../body/simple-bodies.js'; import {Request} from './request.js'; import {Headers} from './headers.js'; import { @@ -31,7 +32,7 @@ describe('method/body legality (HTTP-7)', () => { Request.newBuilder() .method(method) .url('https://example.com') - .body('x') + .body(stringBody('x')) .build(), ).toThrow(RequestBodyNotAllowedError); } @@ -42,7 +43,7 @@ describe('method/body legality (HTTP-7)', () => { Request.newBuilder() .method('POST') .url('https://example.com') - .body('x') + .body(stringBody('x')) .build(), ).not.toThrow(); }); @@ -51,21 +52,11 @@ describe('method/body legality (HTTP-7)', () => { const request = Request.newBuilder() .method('GET') .url('https://example.com') - .body('x') + .body(stringBody('x')) .body(undefined) .build(); expect(request.body).toBeUndefined(); }); - - test('a null body clears like undefined — HTTP-7 rejects only a non-null body', () => { - const request = Request.newBuilder() - .method('GET') - .url('https://example.com') - .body('x') - .body(null) - .build(); - expect(request.body).toBeUndefined(); - }); }); describe('method defaulting (HTTP-8)', () => { @@ -76,7 +67,10 @@ describe('method defaulting (HTTP-8)', () => { test('fails naming the missing method when a body is set with no method', () => { expect(() => - Request.newBuilder().url('https://example.com').body('x').build(), + Request.newBuilder() + .url('https://example.com') + .body(stringBody('x')) + .build(), ).toThrow('method is required'); }); }); diff --git a/packages/core/src/http/request.ts b/packages/core/src/http/request.ts index 2610f29..9105de4 100644 --- a/packages/core/src/http/request.ts +++ b/packages/core/src/http/request.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT // packages/core/src/http/request.ts +import type {Body} from '../body/body.js'; import type {Builder} from './builder.js'; import {requireField} from './builder.js'; import {UrlConstructionError, RequestBodyNotAllowedError} from './errors.js'; @@ -21,7 +22,7 @@ let createRequest: ( method: Method, url: URL, headers: Headers, - body: unknown, + body: Body | undefined, ) => Request; /** @@ -39,7 +40,7 @@ let createRequest: ( * const request = Request.newBuilder() * .method('POST') * .url('https://example.com/items') - * .body('payload') + * .body(stringBody('payload')) * .build(); * ``` * @@ -49,14 +50,14 @@ export class Request { readonly #method: Method; readonly #url: URL; readonly #headers: Headers; - readonly #body: unknown; + readonly #body: Body | undefined; // eslint-disable-next-line max-params -- private, builder-internal; field count fixed by the wire model (HTTP-6) private constructor( method: Method, url: URL, headers: Headers, - body: unknown, + body: Body | undefined, ) { this.#method = method; this.#url = url; @@ -113,13 +114,8 @@ export class Request { return this.#headers; } - /** - * The request body, or `undefined` when absent. - * - * Typed `unknown` on purpose: this phase only needs presence or absence to enforce HTTP-7/8. The - * body lifecycle — streaming, replayability, charset — is owned by a later phase. - */ - get body(): unknown { + /** The request body, or `undefined` when absent. */ + get body(): Body | undefined { return this.#body; } @@ -128,8 +124,7 @@ export class Request { * * The URL is compared by textual external form only, never by resolving the host — native URL * equality on some platforms resolves DNS, which blocks and is wrong for virtual hosts sharing an - * IP (HTTP-46). The body is compared by reference for now; value equality arrives with the real - * body model in a later phase. + * IP (HTTP-46). * * @param other - the request to compare against. * @returns `true` when every compared facet is equal. @@ -153,7 +148,7 @@ export class RequestBuilder implements Builder { #method: Method | undefined; #url: URL | undefined; #headers: Headers = Headers.newBuilder().build(); - #body: unknown; + #body: Body | undefined; /** * Sets the request method. @@ -194,12 +189,11 @@ export class RequestBuilder implements Builder { /** * Sets or clears the request body. * - * @param body - the body, or `null`/`undefined` to clear it. `null` normalizes to `undefined`: - * HTTP-7 rejects only a *non-null* body, so passing `null` clears exactly like `undefined`. + * @param body - the body, or `undefined` to clear it. * @returns this builder, for chaining. */ - body(body: unknown): this { - this.#body = body ?? undefined; + body(body: Body | undefined): this { + this.#body = body; return this; } diff --git a/packages/core/src/http/response.test.ts b/packages/core/src/http/response.test.ts index d3d8b46..91dd10a 100644 --- a/packages/core/src/http/response.test.ts +++ b/packages/core/src/http/response.test.ts @@ -1,17 +1,41 @@ -// SPDX-License-Identifier: MIT // packages/core/src/http/response.test.ts -// Exercises: HTTP-6 (response's required fields: request, protocol, status) +// Exercises: HTTP-6 (required fields), HTTP-41/BODY-14 (single-use body, same reference on repeat +// access), HTTP-41/BODY-15, HTTP-43 (idempotent close, releases the connection whether or not the body +// was read), HTTP-41/BODY-16 (convenience readers close in a finally-style guarantee), HTTP-42 +// (charset default and UTF-8 fallback) import {describe, expect, test} from 'bun:test'; -import {Response} from './response.js'; -import {Request} from './request.js'; +import {Headers} from './headers.js'; import {Protocol} from './protocol.js'; +import {Request} from './request.js'; +import {Response} from './response.js'; import {Status} from './status.js'; -import {Headers} from './headers.js'; function baseRequest(): Request { return Request.newBuilder().url('https://example.com').build(); } +function readableOf(text: string): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + controller.close(); + }, + }); +} + +function baseResponse( + body: ReadableStream | null = null, + headers: Headers = Headers.newBuilder().build(), +): Response { + return Response.newBuilder() + .request(baseRequest()) + .protocol(Protocol.HTTP_1_1) + .status(Status.of(200)) + .headers(headers) + .body(body) + .build(); +} + describe('required fields', () => { test('throws naming request when missing', () => { expect(() => @@ -42,60 +66,153 @@ describe('required fields', () => { }); describe('construction', () => { - test('carries the originating request, protocol, status, headers, and an optional reason phrase/body', () => { + test('carries the originating request, protocol, status, headers, and an optional reason phrase', () => { const request = baseRequest(); const response = Response.newBuilder() .request(request) .protocol(Protocol.HTTP_1_1) .status(Status.of(200)) .reasonPhrase('OK') - .body('payload') .build(); expect(response.request.equals(request)).toBe(true); expect(response.protocol.equals(Protocol.HTTP_1_1)).toBe(true); expect(response.status.equals(Status.of(200))).toBe(true); expect(response.reasonPhrase).toBe('OK'); - expect(response.body).toBe('payload'); }); - test('reason phrase and body are optional', () => { + test('reason phrase is optional, body defaults to null', () => { const response = Response.newBuilder() .request(baseRequest()) .protocol(Protocol.HTTP_1_1) .status(Status.of(204)) .build(); expect(response.reasonPhrase).toBeUndefined(); - expect(response.body).toBeUndefined(); + expect(response.body).toBeNull(); }); }); describe('newBuilder derivation', () => { test('deriving a builder and rebuilding does not affect the original', () => { - const original = Response.newBuilder() - .request(baseRequest()) - .protocol(Protocol.HTTP_1_1) - .status(Status.of(200)) - .build(); + const original = baseResponse(); original.newBuilder().status(Status.of(500)).build(); expect(original.status.code).toBe(200); }); }); -describe('headers (HTTP-6)', () => { - test('defaults to empty headers and carries what the builder was given', () => { - const bare = Response.newBuilder() - .request(baseRequest()) - .protocol(Protocol.HTTP_1_1) - .status(Status.of(204)) +describe('body (HTTP-41/BODY-14)', () => { + test('repeated access returns the same reference, not a replay', () => { + const stream = readableOf('x'); + const response = baseResponse(stream); + expect(response.body).toBe(stream); + expect(response.body).toBe(response.body); + }); +}); + +describe('bytes/text (BODY-16, HTTP-42)', () => { + test('bytes() reads the whole body', async () => { + const response = baseResponse(readableOf('hello')); + expect(new TextDecoder().decode(await response.bytes())).toBe('hello'); + }); + + test('bytes() on a null body returns empty', async () => { + expect(await baseResponse(null).bytes()).toEqual(new Uint8Array(0)); + }); + + test('text() defaults to UTF-8 when no content-type is declared', async () => { + expect(await baseResponse(readableOf('héllo')).text()).toBe('héllo'); + }); + + test('text() uses the declared charset', async () => { + const bytes = Uint8Array.from([0x68, 0xe9]); // "hé" in ISO-8859-1 + const stream = new ReadableStream({ + start: c => { + c.enqueue(bytes); + c.close(); + }, + }); + const headers = Headers.newBuilder() + .add('content-type', 'text/plain;charset=iso-8859-1') .build(); - expect(bare.headers.names()).toEqual([]); + expect(await baseResponse(stream, headers).text()).toBe('hé'); + }); - const response = bare - .newBuilder() - .headers(Headers.newBuilder().add('Content-Type', 'text/plain').build()) + test('text() falls back to UTF-8 when the declared charset is unrecognized', async () => { + const headers = Headers.newBuilder() + .add('content-type', 'text/plain;charset=bogus-charset') .build(); - expect(response.headers.get('content-type')).toBe('text/plain'); - expect(bare.headers.has('content-type')).toBe(false); + expect(await baseResponse(readableOf('ok'), headers).text()).toBe('ok'); + }); + + test('bytes() closes the response even though the read succeeded', async () => { + const response = baseResponse(readableOf('x')); + await response.bytes(); + expect(response.close()).resolves.toBeUndefined(); // idempotent, already closed + }); +}); + +describe('close (HTTP-41/BODY-15, HTTP-43)', () => { + test('cancels the body at most once however often close is called (BODY-15, HTTP-43)', async () => { + let cancels = 0; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.from([1])); + }, + }); + // Counting calls to cancel(), not the source's cancel callback: the Streams spec makes a second + // cancel() on an already-cancelled stream a resolved no-op that never reaches the source, so only + // the call count can show the guard working -- and the throw stands in for a transport whose + // cancel is not re-entrant, which is why HTTP-43 asks for at-most-once in the first place. + const delegate = stream.cancel.bind(stream); + stream.cancel = async (reason?: unknown): Promise => { + cancels += 1; + if (cancels > 1) + throw new Error('transport does not tolerate a double close'); + return delegate(reason); + }; + const response = baseResponse(stream); + await response.close(); + await response.close(); + await response.close(); + // Counted, not merely "did not throw": cancel() on an already-cancelled ReadableStream resolves + // quietly, so idempotence observed only as the absence of a throw tests nothing. The guard exists + // for transports whose cancel is not re-entrant. + expect(cancels).toBe(1); + }); + + test('a failed release is not remembered as a successful close', () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.from([1])); + }, + cancel() { + throw new Error('CONNECTION STUCK'); + }, + }); + const response = baseResponse(stream); + // Every caller sees the failure -- marking the response closed before awaiting would report a + // connection that was never released as released. + expect(response.close()).rejects.toThrow('CONNECTION STUCK'); + expect(response.close()).rejects.toThrow('CONNECTION STUCK'); + }); + + test('releases the connection even when the body was never read', async () => { + let cancelled = false; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.from([1])); + }, + cancel() { + cancelled = true; + }, + }); + await baseResponse(stream).close(); + expect(cancelled).toBe(true); + }); + + test('[Symbol.asyncDispose] delegates to close()', async () => { + const response = baseResponse(readableOf('x')); + await response[Symbol.asyncDispose](); + expect(response.close()).resolves.toBeUndefined(); }); }); diff --git a/packages/core/src/http/response.ts b/packages/core/src/http/response.ts index 8c9c68e..1a5de64 100644 --- a/packages/core/src/http/response.ts +++ b/packages/core/src/http/response.ts @@ -2,27 +2,14 @@ // packages/core/src/http/response.ts import type {Builder} from './builder.js'; import {requireField} from './builder.js'; -import type {Request} from './request.js'; +import {decodeText, resolveCharset} from './charset.js'; +import {Headers} from './headers.js'; import type {Protocol} from './protocol.js'; +import type {Request} from './request.js'; import type {Status} from './status.js'; -import {Headers} from './headers.js'; - -// eslint-disable-next-line max-params -- private, builder-internal plumbing; field count fixed by HTTP-6 -let createResponse: ( - request: Request, - protocol: Protocol, - status: Status, - reasonPhrase: string | undefined, - headers: Headers, - body: unknown, -) => Response; /** - * An immutable HTTP response: the originating request, the negotiated protocol, the status, an - * optional reason phrase, headers, and an optional body (HTTP-6). - * - * Status-range classification is reached through {@link Response.status} — `response.status.isSuccess`, - * `response.status.isError`, and the rest (HTTP-11). + * An HTTP response model (HTTP-6). * * @public */ @@ -32,16 +19,19 @@ export class Response { readonly #status: Status; readonly #reasonPhrase: string | undefined; readonly #headers: Headers; - readonly #body: unknown; + readonly #body: ReadableStream | null; + // Not `readonly` -- Object.freeze(this) below only freezes normal properties, never #private fields, + // so this can still track close state after construction (BODY-15, HTTP-43). + #closing: Promise | undefined; // eslint-disable-next-line max-params -- private, builder-internal; field count fixed by the wire model (HTTP-6) - private constructor( + constructor( request: Request, protocol: Protocol, status: Status, reasonPhrase: string | undefined, headers: Headers, - body: unknown, + body: ReadableStream | null, ) { this.#request = request; this.#protocol = protocol; @@ -52,30 +42,10 @@ export class Response { Object.freeze(this); } - static { - // eslint-disable-next-line max-params -- private, builder-internal plumbing; field count fixed by HTTP-6 - createResponse = (request, protocol, status, reasonPhrase, headers, body) => - new Response(request, protocol, status, reasonPhrase, headers, body); - } - - /** - * Starts an empty builder. - * - * @returns a fresh {@link ResponseBuilder}. - */ static newBuilder(): ResponseBuilder { return new ResponseBuilder(); } - /** - * Derives a builder pre-populated from this instance (HTTP-3). - * - * Every field it carries is itself immutable — `Request` freezes and defensively clones its URL, - * and `Headers`, `Status`, and `Protocol` are frozen values — so sharing them cannot leak - * mutability back into either instance. - * - * @returns a {@link ResponseBuilder} holding this response's state. - */ newBuilder(): ResponseBuilder { return new ResponseBuilder() .request(this.#request) @@ -86,42 +56,97 @@ export class Response { .body(this.#body); } - /** The request this response was produced for. */ get request(): Request { return this.#request; } - /** The negotiated protocol version. */ get protocol(): Protocol { return this.#protocol; } - /** The response status, which also carries the range classification (HTTP-11). */ get status(): Status { return this.#status; } - /** The reason phrase as sent, or `undefined` when the transport supplied none. */ get reasonPhrase(): string | undefined { return this.#reasonPhrase; } - /** The response headers — never null, possibly empty. */ get headers(): Headers { return this.#headers; } - /** - * The response body, or `undefined` when absent. Typed `unknown` until the body lifecycle lands - * in a later phase. - */ - get body(): unknown { + /** Single-use (BODY-14) -- the same reference every call, never a replay. */ + get body(): ReadableStream | null { return this.#body; } + + /** Reads the whole body as bytes, closing the response whether or not the read succeeds (BODY-16). */ + async bytes(): Promise { + if (this.#body === null) { + await this.close(); + return new Uint8Array(0); + } + const reader = this.#body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + // Serial by necessity: each read depends on the previous one advancing the cursor. + const {done, value} = await reader.read(); + if (done) break; + chunks.push(value); + total += value.length; + } + } finally { + // MUST precede close(): ReadableStream.cancel() rejects with TypeError on a locked stream, and + // reading to done does NOT release the lock. Without this the finally replaces the read value + // with a TypeError and bytes()/text() never succeed. + reader.releaseLock(); + await this.close(); + } + const result = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; + } + return result; + } + + /** Reads the whole body as text, defaulting to the media type's charset then UTF-8 (HTTP-42). */ + async text(): Promise { + const bytes = await this.bytes(); + return decodeText(bytes, resolveCharset(this.#headers.get('content-type'))); + } + + /** Idempotent; releases the underlying connection whether or not the body was read (BODY-15, HTTP-43). */ + async close(): Promise { + // Memoized rather than flag-guarded, the same shape BufferedSink.close settled on for IO-5/IO-41: + // a `#closed = true` set before the await reports a FAILED release as success to every later caller, + // over a connection that was never released. Handing every caller the same promise propagates the + // failure on every path while still cancelling at most once. + this.#closing ??= this.#release(); + return this.#closing; + } + + async #release(): Promise { + if (this.#body === null) return; + // BODY-15 forbids assuming the body was read, so an external consumer may still hold the reader + // lock -- cancel() rejects with TypeError in that case. Swallow only that: the caller asked to + // release the connection, and the lock holder's own close will finish the job. + await this.#body.cancel().catch((error: unknown) => { + if (!(error instanceof TypeError)) throw error; + }); + } + + async [Symbol.asyncDispose](): Promise { + await this.close(); + } } /** - * Accumulates response state and produces an immutable {@link Response}. + * Builder for {@link Response}. * * @public */ @@ -131,86 +156,43 @@ export class ResponseBuilder implements Builder { #status: Status | undefined; #reasonPhrase: string | undefined; #headers: Headers = Headers.newBuilder().build(); - #body: unknown; - - /** - * Sets the originating request. Required. - * - * @param request - the request this response answers. - * @returns this builder, for chaining. - */ + #body: ReadableStream | null = null; + request(request: Request): this { this.#request = request; return this; } - /** - * Sets the negotiated protocol. Required. - * - * @param protocol - the protocol the exchange used. - * @returns this builder, for chaining. - */ protocol(protocol: Protocol): this { this.#protocol = protocol; return this; } - /** - * Sets the response status. Required. - * - * @param status - the status received. - * @returns this builder, for chaining. - */ status(status: Status): this { this.#status = status; return this; } - /** - * Sets the reason phrase. - * - * @param reasonPhrase - the phrase as sent, or `undefined` when there was none. - * @returns this builder, for chaining. - */ reasonPhrase(reasonPhrase: string | undefined): this { this.#reasonPhrase = reasonPhrase; return this; } - /** - * Sets the response headers, replacing whatever was set before. - * - * @param headers - the headers received; already immutable, so held by reference. - * @returns this builder, for chaining. - */ headers(headers: Headers): this { this.#headers = headers; return this; } - /** - * Sets the response body. - * - * @param body - the body, or `undefined` when absent. - * @returns this builder, for chaining. - */ - body(body: unknown): this { + body(body: ReadableStream | null): this { this.#body = body; return this; } - /** - * Validates the required fields and constructs the response. - * - * @returns the frozen response. - * @throws {@link RequiredFieldError} when the request, protocol, or status was never set, - * naming whichever is missing (HTTP-4). - */ build(): Response { const request = requireField(this.#request, 'request'); const protocol = requireField(this.#protocol, 'protocol'); const status = requireField(this.#status, 'status'); - return createResponse( + return new Response( request, protocol, status, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9ae93a0..a522929 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -24,3 +24,38 @@ export { } from './seams/transport.js'; export type {OperationDescriptor} from './seams/operation.js'; export {buildRequest, OperationAssemblyError} from './seams/operation.js'; + +// Deliberately NOT `export * from './body/index.js';` — that barrel also carries withRequestLogging/ +// withResponseLogging, internal until Phase 7 supplies a Logger to drive them. Naming each public export +// here instead keeps that boundary enforced at the barrel, not by convention. +// The concrete body classes are exported as TYPES ONLY. Exporting the class as a value publishes +// `new ByteArrayBody(...)` as a field-wise constructor, which HTTP-2 forbids ("constructible only +// through their builder or dedicated factory") and which duplicates the factory functions for no +// stated need (NFR-3). Callers construct via the factories and annotate with the types. +export type {Body} from './body/body.js'; +export { + ConsumedBodyError, + FormBodyValidationError, + isBodyError, + MultipartBoundaryError, +} from './body/errors.js'; +export {HttpStatusError, toHttpError} from './body/http-status-error.js'; +export {materialize} from './body/materialize.js'; +export { + multipartBody, + type MultipartBody, + MultipartBodyBuilder, + type MultipartPart, +} from './body/multipart-body.js'; +export { + byteArrayBody, + type ByteArrayBody, + formUrlEncodedBody, + type FormUrlEncodedBody, + type FormUrlEncodedInput, + type FormUrlEncodedValue, + stringBody, + type StringBody, +} from './body/simple-bodies.js'; +export {streamBody, type StreamBody} from './body/stream-body.js'; +export {TypedResponse} from './body/typed-response.js'; diff --git a/packages/core/src/io/errors.test.ts b/packages/core/src/io/errors.test.ts index 4038132..c7f16bb 100644 --- a/packages/core/src/io/errors.test.ts +++ b/packages/core/src/io/errors.test.ts @@ -9,6 +9,7 @@ import { ClosedResourceError, EndOfStreamError, IoError, + isIoError, SourceContractViolationError, } from './errors.js'; @@ -17,13 +18,16 @@ describe('IoError tree', () => { expect(new IoError('boom')).toBeInstanceOf(DexpaceError); }); - test('every leaf descends from IoError', () => { - expect(new EndOfStreamError(3, 8)).toBeInstanceOf(IoError); + test('every leaf descends from DexpaceError directly, not through IoError (Phase 3b retrofit)', () => { + expect(new EndOfStreamError(3, 8)).toBeInstanceOf(DexpaceError); + expect(new EndOfStreamError(3, 8)).not.toBeInstanceOf(IoError); expect(new SourceContractViolationError('zero read')).toBeInstanceOf( - IoError, + DexpaceError, ); - expect(new ClosedResourceError('BufferedSource')).toBeInstanceOf(IoError); - expect(new AllocationLimitError(9, 8)).toBeInstanceOf(IoError); + expect(new ClosedResourceError('BufferedSource')).toBeInstanceOf( + DexpaceError, + ); + expect(new AllocationLimitError(9, 8)).toBeInstanceOf(DexpaceError); }); test('each error sets name from its own constructor', () => { @@ -60,19 +64,13 @@ describe('IoError tree', () => { expect(new AllocationLimitError(5, 4, {cause}).cause).toBe(cause); }); - test('EndOfStreamError chains a cause', () => { - const cause = new Error('underlying read failure'); - expect(new EndOfStreamError(1, 2, {cause}).cause).toBe(cause); - }); - - test('ClosedResourceError chains a cause', () => { - const cause = new Error('already closed'); - expect(new ClosedResourceError('ByteQueue', {cause}).cause).toBe(cause); - }); - - test('SourceContractViolationError carries its message and descends from IoError', () => { - const error = new SourceContractViolationError('returned zero bytes'); - expect(error.message).toBe('returned zero bytes'); - expect(error.name).toBe('SourceContractViolationError'); + test('isIoError groups every leaf, including bare IoError, without a class tier', () => { + expect(isIoError(new IoError('x'))).toBe(true); + expect(isIoError(new EndOfStreamError(1, 2))).toBe(true); + expect(isIoError(new SourceContractViolationError('x'))).toBe(true); + expect(isIoError(new ClosedResourceError('x'))).toBe(true); + expect(isIoError(new AllocationLimitError(1, 2))).toBe(true); + expect(isIoError(new DexpaceError('other'))).toBe(false); + expect(isIoError(new Error('plain'))).toBe(false); }); }); diff --git a/packages/core/src/io/errors.ts b/packages/core/src/io/errors.ts index bc53c0c..abe1557 100644 --- a/packages/core/src/io/errors.ts +++ b/packages/core/src/io/errors.ts @@ -26,7 +26,7 @@ export class IoError extends DexpaceError { * * @internal */ -export class EndOfStreamError extends IoError { +export class EndOfStreamError extends DexpaceError { readonly delivered: number; readonly requested: number; @@ -46,7 +46,7 @@ export class EndOfStreamError extends IoError { * * @internal */ -export class SourceContractViolationError extends IoError { +export class SourceContractViolationError extends DexpaceError { // See IoError's constructor above: keeps this bodiless subclass registered for bun's // function coverage. // eslint-disable-next-line @typescript-eslint/no-useless-constructor -- see comment above @@ -62,7 +62,7 @@ export class SourceContractViolationError extends IoError { * * @internal */ -export class ClosedResourceError extends IoError { +export class ClosedResourceError extends DexpaceError { readonly resource: string; constructor(resource: string, options?: ErrorOptions) { @@ -77,7 +77,7 @@ export class ClosedResourceError extends IoError { * * @internal */ -export class AllocationLimitError extends IoError { +export class AllocationLimitError extends DexpaceError { readonly requested: number; readonly limit: number; @@ -90,3 +90,28 @@ export class AllocationLimitError extends IoError { this.limit = limit; } } + +/** + * Groups every leaf in this file, including bare `IoError`, without reintroducing a class tier between + * them and `DexpaceError` — the corpus caps custom error hierarchies at two levels. Retrofits Phase 3a's + * shape, where the four leaves extended `IoError` (a 3-tier chain the checkpoint's `DomainModelError` fix + * should also have caught and didn't). + * + * @internal + */ +export function isIoError( + error: unknown, +): error is + | IoError + | EndOfStreamError + | SourceContractViolationError + | ClosedResourceError + | AllocationLimitError { + return ( + error instanceof IoError || + error instanceof EndOfStreamError || + error instanceof SourceContractViolationError || + error instanceof ClosedResourceError || + error instanceof AllocationLimitError + ); +} diff --git a/packages/core/src/io/index.ts b/packages/core/src/io/index.ts index 8fbdb99..7910892 100644 --- a/packages/core/src/io/index.ts +++ b/packages/core/src/io/index.ts @@ -13,6 +13,7 @@ export { ClosedResourceError, EndOfStreamError, IoError, + isIoError, SourceContractViolationError, } from './errors.js'; export { diff --git a/packages/core/src/seams/operation.test.ts b/packages/core/src/seams/operation.test.ts index d166bc1..0217b72 100644 --- a/packages/core/src/seams/operation.test.ts +++ b/packages/core/src/seams/operation.test.ts @@ -98,17 +98,20 @@ describe('SEAM-27: base-URL composition rules', () => { }); }); +import {stringBody} from '../body/simple-bodies.js'; + describe('operation headers and body projections are threaded through', () => { test('supplied headers and body appear on the built request', () => { const headers = Headers.newBuilder().add('X-Trace', 'abc').build(); + const body = stringBody('Fido'); const request = buildRequest('https://host', { method: 'POST', pathTemplate: '/pets', headers, - body: {name: 'Fido'}, + body, }); expect(request.headers.get('x-trace')).toBe('abc'); - expect(request.body).toEqual({name: 'Fido'}); + expect(request.body).toBe(body); }); }); diff --git a/packages/core/src/seams/operation.ts b/packages/core/src/seams/operation.ts index cb05c5a..260c36e 100644 --- a/packages/core/src/seams/operation.ts +++ b/packages/core/src/seams/operation.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT // packages/core/src/seams/operation.ts +import type {Body} from '../body/body.js'; import {Request} from '../http/request.js'; import type {Headers} from '../http/headers.js'; import type {QueryParams} from '../http/query-params.js'; @@ -68,7 +69,7 @@ export interface OperationDescriptor { * The operation's body. Carried, not encoded — serialization is a separate seam's concern * (SEAM-26). Defaults to absent. */ - readonly body?: unknown; + readonly body?: Body | undefined; } const PATH_PARAM_RE = /\{([^{}]+)\}/g; From be700927ebdee4f8e6956afa6764cda504b952c8 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 26 Aug 2026 00:34:39 +0300 Subject: [PATCH 3/8] =?UTF-8?q?fix(core):=20phase=203=20review=20=E2=80=94?= =?UTF-8?q?=20sink=20ownership,=20close=20guarantees,=20HTTP-2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine defects found reviewing the shipped io/ and body/ layers against the phase 3a/3b plans. Seven are wire-correctness or public-API. Sink ownership, three sites and one root cause. A WritableStream adapter declaring write and close but no abort silently swallows the delegate's abort — the default abort algorithm is a no-op — so withRequestLogging left the caller's sink open, locked, and never told the message was broken. It now forwards both teardown paths, and closes on behalf of a delegate that resolves without doing so. StreamBody.writeTo passes preventCancel: true: pipeTo's default cancels the caller's source when the sink fails, taking ownership BODY-8 leaves with the caller, and disagreeing with the declared-length path, which only releases its reader. Close guarantees. Response.bytes/text and toHttpError acquire the body reader inside the try. getReader() itself throws when an external consumer holds the lock — which BODY-15 forbids assuming away — so the one failure BODY-16's guarantee most needs to cover was the one that skipped the close and held the connection. Declared length vs bytes written. MultipartBody.writeTo verifies its total against contentLength, refusing an overrunning chunk before it is written. The shared framing routine keeps the framing honest but takes each part's own length on trust, and MultipartPart.body is the public Body interface, so a caller implementation could report one length and write another (HTTP-51). Every Body variant is frozen at construction, so contentLength cannot be reassigned after the fact (HTTP-1, XCUT-15). Public API. Response regained the private constructor and createResponse friend hook a rewrite had dropped, which had published a field-wise constructor (HTTP-2). [Symbol.asyncDispose] is removed from Response and LoggedResponseBody: it postdates engines.node ">=18.17", where the computed key binds the method to the string "undefined", and its type reached this package only through a dev-only global — so the published .d.ts did not compile for a consumer on the lib this package itself declares. The API report is back to zero (undocumented) members, from 62. New blocking gate verify:consumer-types compiles a throwaway consumer against the built .d.ts on the declared lib with types: [], which is the gate whose absence let the asyncDispose defect clear every other one. Verified to fail on the reintroduced defect and pass once reverted. Also: assertCount single-sourced in io/limits.ts and applied to TeeSink, the fourth size-taking surface, which had none (IO-3); BODY-25's zero-chunk rule applied on the exceeds-cap tail path, not only the drain; an over-reporting primitive source raises SourceContractViolationError instead of surfacing as an exhausted stream (IO-17); http/charset.ts's decodeText renamed decodeBodyText so it stops colliding with io/text-codec's deliberately different one. --- .changeset/phase3-conformance-fixes.md | 51 ++++++ .changeset/phase3-review-pass-2.md | 44 +++++ .github/workflows/ci.yml | 3 + CLAUDE.md | 1 + package.json | 2 + packages/core/etc/core.api.md | 64 -------- packages/core/src/body/body.ts | 23 +++ packages/core/src/body/errors.ts | 3 + packages/core/src/body/freeze-body.ts | 27 ++++ packages/core/src/body/http-status-error.ts | 13 +- packages/core/src/body/multipart-body.test.ts | 80 ++++++++++ packages/core/src/body/multipart-body.ts | 126 +++++++++++++-- .../src/body/request-body-logging.test.ts | 91 ++++++++++- .../core/src/body/request-body-logging.ts | 110 ++++++++++--- .../src/body/response-body-logging.test.ts | 45 +++++- .../core/src/body/response-body-logging.ts | 57 +++++-- packages/core/src/body/simple-bodies.test.ts | 47 +++++- packages/core/src/body/simple-bodies.ts | 34 ++++ packages/core/src/body/stream-body.test.ts | 56 ++++++- packages/core/src/body/stream-body.ts | 23 ++- packages/core/src/body/typed-response.ts | 8 + packages/core/src/http/charset.ts | 20 ++- packages/core/src/http/response.test.ts | 91 ++++++++++- packages/core/src/http/response.ts | 151 ++++++++++++++++-- packages/core/src/io/buffered-sink.ts | 9 +- packages/core/src/io/buffered-source.ts | 9 +- packages/core/src/io/byte-queue.ts | 8 +- packages/core/src/io/factories.test.ts | 34 +++- packages/core/src/io/factories.ts | 8 + packages/core/src/io/index.ts | 1 + packages/core/src/io/limits.ts | 18 +++ packages/core/src/io/tee-sink.test.ts | 28 +++- packages/core/src/io/tee-sink.ts | 5 + packages/core/src/io/text-codec.ts | 4 + scripts/verify-consumer-types.mjs | 132 +++++++++++++++ scripts/verify-runtime-floor.mjs | 1 + scripts/verify-seam-1.mjs | 1 + 37 files changed, 1257 insertions(+), 171 deletions(-) create mode 100644 .changeset/phase3-conformance-fixes.md create mode 100644 .changeset/phase3-review-pass-2.md create mode 100644 packages/core/src/body/freeze-body.ts create mode 100644 scripts/verify-consumer-types.mjs diff --git a/.changeset/phase3-conformance-fixes.md b/.changeset/phase3-conformance-fixes.md new file mode 100644 index 0000000..b35f88d --- /dev/null +++ b/.changeset/phase3-conformance-fixes.md @@ -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. diff --git a/.changeset/phase3-review-pass-2.md b/.changeset/phase3-review-pass-2.md new file mode 100644 index 0000000..8cdae5d --- /dev/null +++ b/.changeset/phase3-review-pass-2.md @@ -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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 899ac6f..4f8eaa7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index beaa561..d384494 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,7 @@ 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 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 diff --git a/package.json b/package.json index 270a991..e9e093e 100644 --- a/package.json +++ b/package.json @@ -29,10 +29,12 @@ "test": "bun test", "knowledge": "node scripts/knowledge.mjs", "test:knowledge": "node --test 'scripts/*.test.mjs'", + "bench": "bun run packages/core/src/io/byte-queue.bench.ts", "api": "cd packages/core && bun run api:ci", "lint:publish": "publint packages/core && attw --pack packages/core --ignore-rules cjs-resolves-to-esm", "audit": "bun audit --audit-level=high --prod", "verify:dual-consumption": "node scripts/verify-dual-consumption.mjs", + "verify:consumer-types": "node scripts/verify-consumer-types.mjs", "verify:seam-1": "node scripts/verify-seam-1.mjs", "verify:runtime-floor": "node scripts/verify-runtime-floor.mjs", "verify:node-floor": "node scripts/verify-node-floor.mjs" diff --git a/packages/core/etc/core.api.md b/packages/core/etc/core.api.md index 8614a45..bb60e45 100644 --- a/packages/core/etc/core.api.md +++ b/packages/core/etc/core.api.md @@ -6,13 +6,9 @@ // @public interface Body_2 { - // (undocumented) readonly contentLength: number; - // (undocumented) readonly kind: 'byte-array' | 'string' | 'stream' | 'form-urlencoded' | 'multipart'; - // (undocumented) readonly mediaType: string | undefined; - // (undocumented) readonly replayable: boolean; writeTo(sink: WritableStream): Promise; } @@ -29,15 +25,10 @@ export function buildRequest(baseUrl: string | URL, operation: OperationDescript // @public export class ByteArrayBody implements Body_2 { constructor(bytes: Uint8Array, mediaType?: string); - // (undocumented) readonly contentLength: number; - // (undocumented) readonly kind: "byte-array"; - // (undocumented) readonly mediaType: string | undefined; - // (undocumented) readonly replayable = true; - // (undocumented) writeTo(sink: WritableStream): Promise; } @@ -55,7 +46,6 @@ export function composeSignal(userSignal?: AbortSignal, timeoutMs?: number): Abo // @public export class ConsumedBodyError extends DexpaceError { constructor(bodyKind: string, options?: ErrorOptions); - // (undocumented) readonly bodyKind: string; } @@ -85,24 +75,17 @@ export class EtagParseError extends DomainModelError { // @public export class FormBodyValidationError extends DexpaceError { constructor(field: string, value: unknown, options?: ErrorOptions); - // (undocumented) readonly field: string; } // @public export class FormUrlEncodedBody implements Body_2 { constructor(input: FormUrlEncodedInput); - // (undocumented) readonly contentLength: number; - // (undocumented) readonly kind: "form-urlencoded"; - // (undocumented) readonly mediaType = "application/x-www-form-urlencoded"; - // (undocumented) readonly params: QueryParams; - // (undocumented) readonly replayable = true; - // (undocumented) writeTo(sink: WritableStream): Promise; } @@ -174,7 +157,6 @@ export class HttpStatusError extends DexpaceError { constructor(status: number, bodyBytes: Uint8Array | undefined, mediaType: string | undefined, options?: ErrorOptions); body(): Body_2 | undefined; preview(charset?: string): string | null; - // (undocumented) readonly status: number; } @@ -210,18 +192,12 @@ export type Method = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'CONNECT' | 'O // @public export class MultipartBody implements Body_2 { constructor(parts: readonly MultipartPart[], boundary?: string); - // (undocumented) readonly contentLength: number; - // (undocumented) readonly kind: "multipart"; - // (undocumented) readonly mediaType: string; - // (undocumented) static newBuilder(): MultipartBodyBuilder; newBuilder(): MultipartBodyBuilder; - // (undocumented) readonly replayable: boolean; - // (undocumented) writeTo(sink: WritableStream): Promise; } @@ -230,30 +206,22 @@ export function multipartBody(parts: readonly MultipartPart[], boundary?: string // @public export class MultipartBodyBuilder implements Builder { - // (undocumented) addPart(part: MultipartPart): this; - // (undocumented) boundary(boundary: string | undefined): this; - // (undocumented) build(): MultipartBody; - // (undocumented) parts(parts: readonly MultipartPart[]): this; } // @public export class MultipartBoundaryError extends DexpaceError { constructor(boundary: string, options?: ErrorOptions); - // (undocumented) readonly boundary: string; } // @public export interface MultipartPart { - // (undocumented) readonly body: Body_2; - // (undocumented) readonly filename?: string | undefined; - // (undocumented) readonly name: string; } @@ -383,25 +351,15 @@ export class RequiredFieldError extends DomainModelError { // @public class Response_2 { - // (undocumented) - [Symbol.asyncDispose](): Promise; - constructor(request: Request_2, protocol: Protocol, status: Status, reasonPhrase: string | undefined, headers: Headers_2, body: ReadableStream | null); get body(): ReadableStream | null; bytes(): Promise; close(): Promise; - // (undocumented) get headers(): Headers_2; - // (undocumented) static newBuilder(): ResponseBuilder; - // (undocumented) newBuilder(): ResponseBuilder; - // (undocumented) get protocol(): Protocol; - // (undocumented) get reasonPhrase(): string | undefined; - // (undocumented) get request(): Request_2; - // (undocumented) get status(): Status; text(): Promise; } @@ -409,19 +367,12 @@ export { Response_2 as Response } // @public export class ResponseBuilder implements Builder { - // (undocumented) body(body: ReadableStream | null): this; - // (undocumented) build(): Response_2; - // (undocumented) headers(headers: Headers_2): this; - // (undocumented) protocol(protocol: Protocol): this; - // (undocumented) reasonPhrase(reasonPhrase: string | undefined): this; - // (undocumented) request(request: Request_2): this; - // (undocumented) status(status: Status): this; } @@ -444,15 +395,10 @@ export class Status { // @public export class StreamBody implements Body_2 { constructor(stream: ReadableStream, mediaType?: string, contentLength?: number); - // (undocumented) readonly contentLength: number; - // (undocumented) readonly kind: "stream"; - // (undocumented) readonly mediaType: string | undefined; - // (undocumented) readonly replayable = false; - // (undocumented) writeTo(sink: WritableStream): Promise; } @@ -462,17 +408,11 @@ export function streamBody(stream: ReadableStream, mediaType?: strin // @public export class StringBody implements Body_2 { constructor(text: string, mediaType?: string); - // (undocumented) readonly contentLength: number; - // (undocumented) readonly kind: "string"; - // (undocumented) readonly mediaType: string; - // (undocumented) readonly replayable = true; - // (undocumented) readonly text: string; - // (undocumented) writeTo(sink: WritableStream): Promise; } @@ -491,14 +431,10 @@ export interface Transport { // @public export class TypedResponse { constructor(response: Response_2, parse: (response: Response_2) => Promise); - // (undocumented) get headers(): Response_2['headers']; - // (undocumented) get protocol(): string; - // (undocumented) get reason(): string | undefined; get request(): Request_2; - // (undocumented) get status(): Response_2['status']; value(): Promise; } diff --git a/packages/core/src/body/body.ts b/packages/core/src/body/body.ts index 9b6d5ec..04db52b 100644 --- a/packages/core/src/body/body.ts +++ b/packages/core/src/body/body.ts @@ -7,15 +7,38 @@ * @public */ export interface Body { + /** + * The discriminant that narrows this interface to a concrete variant, per the styleguide's + * discriminated-union-over-independent-classes pattern -- there is deliberately no base class. + */ readonly kind: 'byte-array' | 'string' | 'stream' | 'form-urlencoded' | 'multipart'; + /** + * The media type to send as `Content-Type`, or `undefined` when the body declares none. + * + * Absence is `undefined`, never `null`, matching the domain model everywhere else. + */ readonly mediaType: string | undefined; + /** + * The exact byte count `writeTo` will emit, or -1 when it is not known ahead of the write + * (BODY-35). A transport stamps this into `Content-Length`, so it must never disagree with the + * bytes actually written. + */ readonly contentLength: number; + /** + * Whether writing more than once yields byte-for-byte identical output (BODY-4/BODY-5). + * + * Consulted by Phase 5's retry, redirect, and auth steps before re-sending a request; a + * single-use body must be run through `materialize` first. + */ readonly replayable: boolean; /** * Writes the body once into `sink`, closing it on success and aborting it on failure so a partially * written body is never signalled to the transport as a complete one. * + * @param sink - the destination. The body owns closing it -- the caller only supplies it -- and + * aborts it rather than closing it when the write fails, so a truncated payload is never signalled + * downstream as a complete one. * @throws ConsumedBodyError when a single-use body is written a second time (BODY-3). * @throws EndOfStreamError when a stream body's byte count disagrees with its declared * `contentLength` (HTTP-39/BODY-10). diff --git a/packages/core/src/body/errors.ts b/packages/core/src/body/errors.ts index 95e05bf..13d37d5 100644 --- a/packages/core/src/body/errors.ts +++ b/packages/core/src/body/errors.ts @@ -18,6 +18,7 @@ import {DexpaceError} from '../http/errors.js'; * @public */ export class ConsumedBodyError extends DexpaceError { + /** The `Body.kind` of the variant that refused the write. */ readonly bodyKind: string; constructor(bodyKind: string, options?: ErrorOptions) { @@ -35,6 +36,7 @@ export class ConsumedBodyError extends DexpaceError { * @public */ export class MultipartBoundaryError extends DexpaceError { + /** The rejected boundary, exactly as supplied. */ readonly boundary: string; constructor(boundary: string, options?: ErrorOptions) { @@ -51,6 +53,7 @@ export class MultipartBoundaryError extends DexpaceError { * @public */ export class FormBodyValidationError extends DexpaceError { + /** The form field name whose value could not be rendered. */ readonly field: string; constructor(field: string, value: unknown, options?: ErrorOptions) { diff --git a/packages/core/src/body/freeze-body.ts b/packages/core/src/body/freeze-body.ts new file mode 100644 index 0000000..f40507b --- /dev/null +++ b/packages/core/src/body/freeze-body.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/body/freeze-body.ts +import type {Body} from './body.js'; + +/** + * Freezes a fully-constructed {@link Body}, as the last statement of its constructor. + * + * `readonly` is erased at run time, so without this a caller can reassign a body's own metadata after + * construction and desynchronize it from the bytes `writeTo` emits: + * + * ```ts + * const body = byteArrayBody(Uint8Array.from([1, 2, 3])); + * (body as {contentLength: number}).contentLength = 999; // declared 999, writes 3 + * ``` + * + * That is the same declared-length-versus-written-bytes drift `HTTP-51` makes `MultipartBody` share one + * framing routine to prevent, and that `HTTP-1`/`XCUT-15` make it defensively copy its parts array for -- + * left open one level up, on the field the transport actually stamps into `Content-Length`. + * + * A named helper rather than five inlined `Object.freeze(this)` calls so the reason lives in one place; + * the freeze is shallow and is never relied on to cascade, matching the domain-model convention + * `packages/core/src/http/` already follows. `#private` fields are unaffected, which is why + * `StreamBody`'s consumed-once flag still works on a frozen instance. + */ +export function freezeBody(body: Body): void { + Object.freeze(body); +} diff --git a/packages/core/src/body/http-status-error.ts b/packages/core/src/body/http-status-error.ts index a877552..56b4ec1 100644 --- a/packages/core/src/body/http-status-error.ts +++ b/packages/core/src/body/http-status-error.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT // packages/core/src/body/http-status-error.ts -import {decodeText, resolveCharset} from '../http/charset.js'; +import {decodeBodyText, resolveCharset} from '../http/charset.js'; import {DexpaceError} from '../http/errors.js'; import type {Response} from '../http/response.js'; import {invariant} from '../invariant.js'; @@ -18,6 +18,7 @@ const ERROR_BODY_CAP_BYTES = 1024 * 1024; // 1 MiB, HTTP-52/BODY-30 * @public */ export class HttpStatusError extends DexpaceError { + /** The response status code, always in HTTP-11's 400-599 error band (BODY-31). */ readonly status: number; readonly #bodyBytes: Uint8Array | undefined; readonly #mediaType: string | undefined; @@ -57,7 +58,7 @@ export class HttpStatusError extends DexpaceError { */ preview(charset?: string): string | null { if (this.#bodyBytes === undefined) return null; - return decodeText( + return decodeBodyText( this.#bodyBytes, charset ?? resolveCharset(this.#mediaType), ); @@ -84,10 +85,14 @@ export async function toHttpError( await response.close(); return new HttpStatusError(response.status.code, undefined, mediaType); } - const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let total = 0; + // Acquired INSIDE the try, for the same reason Response.bytes does it: `getReader()` throws when an + // external consumer already holds the lock, and acquiring it above the try skipped the close on + // exactly that path -- holding the connection open (HTTP-52/BODY-30). + let reader: ReadableStreamDefaultReader | undefined; try { + reader = response.body.getReader(); for (;;) { // Serial by necessity: each read depends on the previous one advancing the cursor. const {done, value} = await reader.read(); @@ -100,7 +105,7 @@ export async function toHttpError( } } finally { // Release before close(): cancel() rejects with TypeError on a locked stream (see Response.bytes). - reader.releaseLock(); + reader?.releaseLock(); await response.close(); } invariant( diff --git a/packages/core/src/body/multipart-body.test.ts b/packages/core/src/body/multipart-body.test.ts index a3ced6f..5b03d39 100644 --- a/packages/core/src/body/multipart-body.test.ts +++ b/packages/core/src/body/multipart-body.test.ts @@ -33,6 +33,39 @@ function oneByteStream(): ReadableStream { }); } +/** Awaits a rejection and returns its reason, failing loudly when the promise resolves. */ +async function rejection(promise: Promise): Promise { + try { + await promise; + } catch (error: unknown) { + return error as Error; + } + throw new Error('expected the promise to reject, but it resolved'); +} + +function collectingSink(): { + sink: WritableStream; + written: () => Uint8Array; +} { + const chunks: Uint8Array[] = []; + const sink = new WritableStream({ + write: c => void chunks.push(c), + }); + return { + sink, + written: () => { + const total = chunks.reduce((sum, c) => sum + c.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + out.set(c, offset); + offset += c.length; + } + return out; + }, + }; +} + async function drain(body: { writeTo: (sink: WritableStream) => Promise; }): Promise { @@ -269,3 +302,50 @@ describe('MultipartBody failure propagation (RECOV-12)', () => { expect(body.writeTo(sink)).rejects.toThrow('SOCKET GONE'); }); }); + +describe('the declared length is verified against what is written (HTTP-51)', () => { + // MultipartPart.body is the public `Body` interface, so a caller-supplied implementation can + // report one length and write another. The shared framing routine keeps the FRAMING consistent + // but takes each part's own contentLength on trust, which desynchronizes the value a transport + // stamps into Content-Length from what is actually on the socket. + function lyingBody(declared: number, actual: number): Body { + return { + kind: 'byte-array', + mediaType: undefined, + contentLength: declared, + replayable: true, + writeTo: async (sink: WritableStream): Promise => { + const writer = sink.getWriter(); + await writer.write(new Uint8Array(actual).fill(65)); + await writer.close(); + }, + }; + } + + test('a part that overruns its declared length is stopped before the extra bytes reach the sink', async () => { + const {sink, written} = collectingSink(); + const body = multipartBody([{name: 'a', body: lyingBody(1, 5)}], 'B'); + const declared = body.contentLength; + + expect((await rejection(body.writeTo(sink))).name).toBe('EndOfStreamError'); + // Same reasoning as StreamBody's overrun check: once the length is stamped, a byte past it sits + // where the peer reads it as the start of the next message. + expect(written().length).toBeLessThanOrEqual(declared); + }); + + test('a part that writes fewer bytes than it declared fails rather than sending a short body', async () => { + const {sink} = collectingSink(); + const body = multipartBody([{name: 'a', body: lyingBody(5, 1)}], 'B'); + expect((await rejection(body.writeTo(sink))).name).toBe('EndOfStreamError'); + }); + + test('an unknown-length composite is not length-checked at all', async () => { + // contentLength collapses to -1, so there is no declared value to disagree with. + const body = multipartBody( + [{name: 'a', body: streamBody(oneByteStream())}], + 'B', + ); + expect(body.contentLength).toBe(-1); + await body.writeTo(collectingSink().sink); + }); +}); diff --git a/packages/core/src/body/multipart-body.ts b/packages/core/src/body/multipart-body.ts index 801b3f4..6df3020 100644 --- a/packages/core/src/body/multipart-body.ts +++ b/packages/core/src/body/multipart-body.ts @@ -1,9 +1,11 @@ // SPDX-License-Identifier: MIT // packages/core/src/body/multipart-body.ts import type {Builder} from '../http/builder.js'; +import {EndOfStreamError} from '../io/errors.js'; import {invariant} from '../invariant.js'; import type {Body} from './body.js'; import {MultipartBoundaryError} from './errors.js'; +import {freezeBody} from './freeze-body.js'; import {assertHeaderSafeMediaType} from './media-type-safety.js'; import {withBodyWriter} from './write-body.js'; @@ -13,8 +15,11 @@ import {withBodyWriter} from './write-body.js'; * @public */ export interface MultipartPart { + /** The form field name, rendered into `Content-Disposition` and quoted/escaped (HTTP-51). */ readonly name: string; + /** An optional upload filename, quoted/escaped the same way as {@link MultipartPart.name}. */ readonly filename?: string | undefined; + /** The part's payload. Its `mediaType` becomes the part's `Content-Type` when present. */ readonly body: Body; } @@ -91,16 +96,43 @@ function computeContentLength( return total + trailerBytes(boundary).length; } -// Wraps a locked writer as a WritableStream whose close() does not close the real sink -- multiple parts -// share one underlying writer, and only the outer writeTo's own finally block closes it. -function nonClosingSink( +/** + * Counts what reaches the sink and refuses a chunk that would carry the message past `declared` + * (HTTP-51). + * + * The shared framing routine guarantees the declared length and the emitted bytes agree about the + * FRAMING, but it takes each part's own `contentLength` on trust -- and `MultipartPart.body` is the + * public `Body` interface, so a caller-supplied implementation can report one length and write + * another. That desynchronizes the value a transport stamps into `Content-Length` from what is + * actually on the socket, which is the precise drift HTTP-51 exists to prevent. + * + * Refused BEFORE the write, not tallied after the loop, for the same reason `StreamBody.#writeExactly` + * checks early: once the length is stamped, an overrun byte sits where the peer reads it as the start + * of the next message, and a thrown error cannot recall bytes already written. + */ +function boundedWriter( writer: WritableStreamDefaultWriter, -): WritableStream { - return new WritableStream({ - write: async chunk => { + declared: number, +): {write: (chunk: Uint8Array) => Promise; written: () => number} { + let written = 0; + return { + write: async (chunk: Uint8Array): Promise => { + if (declared !== -1 && written + chunk.length > declared) { + throw new EndOfStreamError(written + chunk.length, declared); + } + written += chunk.length; await writer.write(chunk); }, - }); + written: () => written, + }; +} + +// Wraps the bounded write as a WritableStream whose close() does not close the real sink -- multiple +// parts share one underlying writer, and only the outer writeTo's own scope closes it. +function nonClosingSink( + write: (chunk: Uint8Array) => Promise, +): WritableStream { + return new WritableStream({write}); } /** @@ -110,9 +142,13 @@ function nonClosingSink( * @public */ export class MultipartBody implements Body { + /** Discriminates this variant within the {@link Body} union. */ readonly kind = 'multipart' as const; + /** `multipart/form-data` carrying the boundary this instance frames its parts with. */ readonly mediaType: string; + /** The total framed byte count, or -1 when any part's own length is unknown (BODY-2). */ readonly contentLength: number; + /** `true` only when every part is replayable -- composite replayability (BODY-2). */ readonly replayable: boolean; readonly #parts: readonly MultipartPart[]; readonly #boundary: string; @@ -129,27 +165,65 @@ export class MultipartBody implements Body { this.contentLength >= trailerBytes(this.#boundary).length, `framing computed an impossible length ${String(this.contentLength)}`, ); + freezeBody(this); // HTTP-1: see freeze-body.ts } + /** + * Starts an empty builder (HTTP-3). + * + * @returns a fresh {@link MultipartBodyBuilder}. + */ static newBuilder(): MultipartBodyBuilder { return new MultipartBodyBuilder(); } - /** HTTP-3: pre-populated with this instance's parts and boundary, aliasing neither. */ + /** + * Derives a builder pre-populated with this instance's parts and boundary, aliasing neither (HTTP-3). + * + * @returns a {@link MultipartBodyBuilder} holding a copy of this body's state. + */ newBuilder(): MultipartBodyBuilder { return new MultipartBodyBuilder() .parts(this.#parts) .boundary(this.#boundary); } + /** + * Writes every part framed by this body's boundary, then the closing trailer, then closes `sink` + * (BODY-2). + * + * Two mechanisms keep {@link MultipartBody.contentLength} and these bytes from drifting (HTTP-51), + * and both are needed. The shared framing routine that computed the length also produces the + * framing here, which covers the delimiters and part headers; and the write is bounded and totalled + * against the declared length, which covers what the routine cannot -- each part's own reported + * `contentLength`, taken on trust from an interface any caller can implement. + * + * @param sink - the destination; this body's to close, the caller's only to supply. Each part + * receives a non-closing adapter over the same writer, so no part can end the message early. + * @throws EndOfStreamError when the bytes actually written disagree with + * {@link MultipartBody.contentLength} — which happens when a caller-supplied part `Body` reports one + * length and writes another (HTTP-51). + * @throws {@link ConsumedBodyError} when a single-use part is written a second time (BODY-3) -- a + * non-replayable composite needs no guard of its own; the offending part's own guard fires. + */ async writeTo(sink: WritableStream): Promise { await withBodyWriter(sink, async writer => { + const bounded = boundedWriter(writer, this.contentLength); for (const part of this.#parts) { - await writer.write(renderPartHeader(part, this.#boundary)); - await part.body.writeTo(nonClosingSink(writer)); - await writer.write(CRLF); + await bounded.write(renderPartHeader(part, this.#boundary)); + await part.body.writeTo(nonClosingSink(bounded.write)); + await bounded.write(CRLF); + } + await bounded.write(trailerBytes(this.#boundary)); + // HTTP-51: a part that writes FEWER bytes than it declared is the mirror of the overrun the + // bounded writer refuses, and just as wrong on the wire. Raised inside the writer scope so + // withBodyWriter aborts rather than signalling a clean close over a short body. + if ( + this.contentLength !== -1 && + bounded.written() !== this.contentLength + ) { + throw new EndOfStreamError(bounded.written(), this.contentLength); } - await writer.write(trailerBytes(this.#boundary)); }); } } @@ -178,24 +252,48 @@ export class MultipartBodyBuilder implements Builder { #parts: MultipartPart[] = []; #boundary: string | undefined; + /** + * Replaces the whole parts list, copying it so the builder never aliases the caller's array. + * + * @param parts - the parts, in the order they will be framed. + * @returns this builder, for chaining. + */ parts(parts: readonly MultipartPart[]): this { this.#parts = [...parts]; return this; } + /** + * Appends one part, keeping whatever was added before. + * + * @param part - the part to append. + * @returns this builder, for chaining. + */ addPart(part: MultipartPart): this { this.#parts.push(part); return this; } + /** + * Sets the boundary, or clears it so `build()` generates a fresh random one. + * + * Prefer the generated default; see {@link multipartBody} for the RFC 2046 non-appearance + * obligation a caller-supplied delimiter carries and that this class cannot check. + * + * @param boundary - an RFC 2046 `bchars` delimiter that appears in no part, or `undefined`. + * @returns this builder, for chaining. + */ boundary(boundary: string | undefined): this { this.#boundary = boundary; return this; } /** - * @throws MultipartBoundaryError when the configured boundary violates RFC 2046's bchars grammar - * (HTTP-51). + * Frames the accumulated parts into an immutable {@link MultipartBody}. + * + * @returns the frozen body. + * @throws {@link MultipartBoundaryError} when the configured boundary violates RFC 2046's bchars + * grammar (HTTP-51). * @throws MediaTypeParseError when a part's media type contains a control character or non-ASCII byte * (HTTP-26/HTTP-51). */ diff --git a/packages/core/src/body/request-body-logging.test.ts b/packages/core/src/body/request-body-logging.test.ts index c167f0d..6380af3 100644 --- a/packages/core/src/body/request-body-logging.test.ts +++ b/packages/core/src/body/request-body-logging.test.ts @@ -3,14 +3,37 @@ // Exercises: BODY-17 (mirror + forward the full untruncated payload), BODY-18 (tap clears at the start // of every write), BODY-19 (tap cap, full payload unaffected), BODY-20 (partial-failure snapshot), BODY-21 // (replayable/materialize pass through, preserving the tap CAP without sharing its buffer), BODY-37 (no -// backing-buffer escape hatch) +// backing-buffer escape hatch), plus the decorator's own sink ownership: an abort must reach the +// primary sink rather than stopping at the adapter (RECOV-12) import {describe, expect, test} from 'bun:test'; import fc from 'fast-check'; import {InvariantViolation} from '../invariant.js'; import {withRequestLogging} from './request-body-logging.js'; +import {rejection} from '../io/test-support/rejection.js'; +import type {Body} from './body.js'; +import {ConsumedBodyError} from './errors.js'; import {byteArrayBody} from './simple-bodies.js'; import {streamBody} from './stream-body.js'; +/** Sentinel distinguishing "abort() never ran" from "abort(undefined)". */ +const NOT_ABORTED = Symbol('not-aborted'); + +/** A healthy sink that records which teardown path the body took. */ +function observableSink(): { + sink: WritableStream; + aborted: () => unknown; + closed: () => boolean; +} { + let aborted: unknown = NOT_ABORTED; + let closed = false; + const sink = new WritableStream({ + write: () => undefined, + close: () => void (closed = true), + abort: reason => void (aborted = reason), + }); + return {sink, aborted: () => aborted, closed: () => closed}; +} + function collectingSink(): { sink: WritableStream; written: () => Uint8Array; @@ -190,3 +213,69 @@ describe('materialize does not alias the tap (BODY-21)', () => { expect([...materialized.snapshot()]).toEqual([1, 2]); }); }); + +describe('the decorator owns the sink it was handed (BODY-17, RECOV-12)', () => { + // The adapter stream the tee hands the delegate must forward BOTH teardown paths. Without an + // `abort` algorithm on it the delegate's abort stops at the decorator -- the adapter's default + // abort is a no-op -- so the real sink is never told the message is broken and a truncated body + // can be committed downstream as a complete one. + + test('a delegate failure aborts the primary sink rather than closing it', async () => { + // A declared length the stream cannot satisfy: withBodyWriter aborts, and that abort has to + // reach the caller's sink through the tee. + const {sink, aborted, closed} = observableSink(); + const short = streamBody(bytesStream(1, 2), undefined, 5); + const logged = withRequestLogging(short, 10); + + expect((await rejection(logged.writeTo(sink))).name).toBe( + 'EndOfStreamError', + ); + expect(aborted()).not.toBe(NOT_ABORTED); + expect(closed()).toBe(false); + }); + + test('a delegate that refuses before writing still tears the primary sink down', async () => { + // ConsumedBodyError is raised before the adapter is ever touched, so neither of its handlers + // runs and only writeTo's own catch can release the writer it took. + const {sink, aborted, closed} = observableSink(); + const body = streamBody(bytesStream()); + await body.writeTo(new WritableStream()); + const logged = withRequestLogging(body, 10); + + expect(await rejection(logged.writeTo(sink))).toBeInstanceOf( + ConsumedBodyError, + ); + expect(aborted()).toBeInstanceOf(ConsumedBodyError); + expect(closed()).toBe(false); + }); + + test('a delegate that resolves without closing the adapter still closes the primary', async () => { + // Body.writeTo's contract is that the body closes the sink it was given. A delegate that just + // resolves would otherwise strand the caller's sink open and locked, with nothing thrown. + const {sink, aborted, closed} = observableSink(); + const rogue: Body = { + kind: 'byte-array', + mediaType: undefined, + contentLength: 1, + replayable: true, + writeTo: async (target: WritableStream): Promise => { + const writer = target.getWriter(); + await writer.write(Uint8Array.from([1])); + writer.releaseLock(); // resolves without close() or abort() + }, + }; + + await withRequestLogging(rogue, 10).writeTo(sink); + expect(closed()).toBe(true); + expect(aborted()).toBe(NOT_ABORTED); + }); + + test('a successful write closes the primary sink and never aborts it', async () => { + const {sink, aborted, closed} = observableSink(); + await withRequestLogging(byteArrayBody(Uint8Array.from([1])), 10).writeTo( + sink, + ); + expect(closed()).toBe(true); + expect(aborted()).toBe(NOT_ABORTED); + }); +}); diff --git a/packages/core/src/body/request-body-logging.ts b/packages/core/src/body/request-body-logging.ts index 41666ca..7de0cd6 100644 --- a/packages/core/src/body/request-body-logging.ts +++ b/packages/core/src/body/request-body-logging.ts @@ -6,6 +6,11 @@ import {MAX_BYTE_ARRAY_LENGTH} from '../io/limits.js'; import type {Body} from './body.js'; import {materialize} from './materialize.js'; +/** + * A {@link Body} that also mirrors what it writes into a bounded, readable tap (BODY-17..21). + * + * @internal + */ export interface LoggedBody extends Body { /** A copy of the tap's current contents -- at most tapCapBytes of the most recent write (BODY-19). */ snapshot(): Uint8Array; @@ -13,12 +18,64 @@ export interface LoggedBody extends Body { materialize(): Promise; } +/** + * One `writeTo` call's plumbing: the primary sink's writer, this wrapper's tap, and the cap. + * + * Extracted from the closure so the adapter-stream construction can live in its own function without + * tripping `max-params`, mirroring `response-body-logging.ts`'s `DrainState`. + */ +interface TapState { + readonly writer: WritableStreamDefaultWriter; + readonly tap: ByteQueue; + readonly cap: number; + /** Whether the delegate has already ended the adapter, by closing or aborting it. */ + settled: boolean; +} + +/** + * The adapter stream handed to the delegate: mirrors up to `cap` bytes of each chunk, then forwards the + * chunk whole (BODY-17, BODY-19). + * + * The `abort` handler is load-bearing, not symmetry for its own sake. A `Body.writeTo` aborts its sink + * on failure so the transport learns the message is broken (see `write-body.ts`); without an `abort` + * algorithm here the adapter's default is a no-op, so the abort STOPS AT THE DECORATOR -- the real sink + * is left open, still locked, and a truncated body can be committed downstream as a complete one. + */ +function tappedSink(state: TapState): WritableStream { + return new WritableStream({ + write: async chunk => { + if (state.tap.size < state.cap) { + const room = state.cap - state.tap.size; + // BODY-20/IO-27: mirror BEFORE forwarding, so a failing primary write still captures + // the chunk that failed. + state.tap.writeBytes( + room >= chunk.length ? chunk : chunk.subarray(0, room), + ); + } + await state.writer.write(chunk); // BODY-19: the full payload always reaches the primary + invariant( + state.tap.size <= state.cap, + `tap grew past its ${String(state.cap)}-byte cap`, + ); + }, + close: async () => { + state.settled = true; + await state.writer.close(); + }, + abort: async (reason: unknown) => { + state.settled = true; + await state.writer.abort(reason); + }, + }); +} + /** * Mirrors up to tapCapBytes of each writeTo call into an internal tap while forwarding the full, * untruncated payload to the primary sink (BODY-17). The tap clears at the start of every write so a * retry against a replayable delegate does not accumulate stale bytes (BODY-18). No handle onto the tap's - * backing buffer is exposed -- snapshot() is the only way to read it (BODY-37). `@internal` -- unwired - * until Phase 7 supplies a Logger to drive it. + * backing buffer is exposed -- snapshot() is the only way to read it (BODY-37). + * + * @internal Unwired until Phase 7 supplies a Logger to drive it. */ export function withRequestLogging( delegate: Body, @@ -38,7 +95,7 @@ export function withRequestLogging( // in the materialized wrapper silently rewrites the preview the pre-materialization wrapper is still // holding -- which is precisely what a Phase 7 retry loop does between attempts. const tap = new ByteQueue(); - return { + return Object.freeze({ kind: inner.kind, mediaType: inner.mediaType, contentLength: inner.contentLength, @@ -47,34 +104,35 @@ export function withRequestLogging( }, async writeTo(sink: WritableStream): Promise { tap.clear(); // BODY-18 - const writer = sink.getWriter(); - const tapped = new WritableStream({ - write: async chunk => { - if (tap.size < cap) { - const room = cap - tap.size; - // BODY-20/IO-27: mirror BEFORE forwarding, so a failing primary write still captures - // the chunk that failed. - tap.writeBytes( - room >= chunk.length ? chunk : chunk.subarray(0, room), - ); - } - await writer.write(chunk); // BODY-19: the full payload always reaches the primary - invariant( - tap.size <= cap, - `tap grew past its ${String(cap)}-byte cap`, - ); - }, - close: async () => { - await writer.close(); - }, - }); - await inner.writeTo(tapped); + const state: TapState = { + writer: sink.getWriter(), + tap, + cap, + settled: false, + }; + try { + await inner.writeTo(tappedSink(state)); + } catch (error: unknown) { + // A delegate that refuses before it ever touches the adapter -- ConsumedBodyError on a + // second write of a single-use body -- reaches neither handler in `tappedSink`, so the + // primary writer would stay locked and open forever. Best-effort, and never allowed to + // displace the primary failure (RECOV-12). + if (!state.settled) { + await state.writer.abort(error).catch(() => undefined); + } + throw error; + } + // `Body.writeTo`'s contract is that the body closes the sink it was given. This wrapper is + // the one place that takes a writer on behalf of someone else's `Body`, so a delegate that + // resolves without closing would strand the caller's sink open and locked with nothing + // thrown to notice it by. Honouring the contract on the delegate's behalf is the repair. + if (!state.settled) await state.writer.close(); }, snapshot(): Uint8Array { return tap.snapshot(); }, materialize: async () => wrap(await materialize(inner)), - }; + }); } return wrap(delegate); diff --git a/packages/core/src/body/response-body-logging.test.ts b/packages/core/src/body/response-body-logging.test.ts index 6108c07..c35a20e 100644 --- a/packages/core/src/body/response-body-logging.test.ts +++ b/packages/core/src/body/response-body-logging.test.ts @@ -20,6 +20,16 @@ function readableOf(...chunks: number[][]): ReadableStream { }); } +/** Awaits a rejection and returns its reason, failing loudly when the promise resolves instead. */ +async function rejection(promise: Promise): Promise { + try { + await promise; + } catch (error: unknown) { + return error as Error; + } + throw new Error('expected a rejection, but the promise resolved'); +} + async function readAll( stream: ReadableStream, ): Promise { @@ -98,8 +108,12 @@ describe('withResponseLogging lifecycle (BODY-27, 28)', () => { expect([...logged.snapshot()]).toEqual([1, 2]); }); - test('[Symbol.asyncDispose] delegates to close()', async () => { - await withResponseLogging(readableOf([1]), 100)[Symbol.asyncDispose](); + test('teardown is close() only -- no [Symbol.asyncDispose] on the >=18.17 floor', () => { + // See Response's matching assertion: the symbol is undefined on the declared floor, so declaring + // it binds the method to the string "undefined". Absence is the assertion. + const logged = withResponseLogging(readableOf([1]), 100); + expect(Object.keys(logged)).not.toContain('undefined'); + expect(typeof logged.close).toBe('function'); }); }); @@ -241,6 +255,33 @@ describe('delegate stream contract (BODY-25)', () => { }); }); +describe('the tail path enforces the same chunk contract (BODY-25)', () => { + test('a zero-length chunk after the cap is raised, not enqueued', async () => { + // The drain stops at the cap, so a violating chunk arriving afterwards is read by tailStream, not + // drainOnce. A rule that holds in one regime and not the other makes the same upstream pass or + // fail depending only on how big the body happened to be. + let pulls = 0; + const delegate = new ReadableStream({ + pull(controller) { + pulls += 1; + if (pulls === 1) { + controller.enqueue(Uint8Array.from([1, 2, 3, 4])); + return; + } + controller.enqueue(new Uint8Array(0)); + }, + }); + const logged = withResponseLogging(delegate, 2); + const tail = await logged.read(); + + expect((await rejection(readAll(tail))).name).toBe( + 'SourceContractViolationError', + ); + // BODY-26: cached like any other delegate failure, so error() still reports it. + expect(logged.error()).toBeInstanceOf(SourceContractViolationError); + }); +}); + describe('snapshot is a drain trigger (BODY-22)', () => { test('calling snapshot starts the drain, without a read()', async () => { const logged = withResponseLogging(readableOf([1, 2, 3]), 100); diff --git a/packages/core/src/body/response-body-logging.ts b/packages/core/src/body/response-body-logging.ts index 7652a0c..7563b2e 100644 --- a/packages/core/src/body/response-body-logging.ts +++ b/packages/core/src/body/response-body-logging.ts @@ -6,7 +6,18 @@ import {SourceContractViolationError} from '../io/errors.js'; import {MAX_BYTE_ARRAY_LENGTH} from '../io/limits.js'; import {ConsumedBodyError} from './errors.js'; -export interface LoggedResponseBody extends AsyncDisposable { +/** + * A lazily-draining, bounded capture wrapper over a raw response body stream (BODY-22..29). + * + * Teardown is `close()` only. `[Symbol.asyncDispose]` is deliberately absent: it postdates the declared + * `engines.node` floor (`>=18.17`), where the computed key evaluates to `undefined` and binds the method + * to the string `"undefined"` instead -- wrong, silent, and only at run time. This matches the decision + * every Phase 3a resource already ships with. Revisit when the checkpoint's floor bump lands and adds it + * to all of them at once, rather than to two classes out of seven. + * + * @internal + */ +export interface LoggedResponseBody { /** * Returns a stream serving the body. Lazy -- nothing is read from the delegate until the first call * (BODY-22). Fits-cap regime: every call, including calls after the first, returns a fresh @@ -20,6 +31,11 @@ export interface LoggedResponseBody extends AsyncDisposable { error(): Error | null; /** Captured size iff fully captured within the cap, else the delegate's declared length (BODY-29). */ readonly contentLength: number; + /** + * Releases the delegate. Idempotent, and shared with the exceeds-cap tail stream's own completion so + * the delegate is cancelled at most once however close is reached (BODY-27). The captured bytes + * survive, so `snapshot()` still works afterwards (BODY-28). + */ close(): Promise; } @@ -55,15 +71,28 @@ async function closeDelegate(state: DrainState): Promise { } /** - * Reads until EOF (fits regime) or until the cap is reached (exceeds regime, leaving the delegate open - * and the overflow chunk staged). BODY-26: a failure is cached, never allowed to truncate silently. - * * BODY-25: a delegate chunk of zero bytes is a stream-contract violation, not a no-op and never EOF -- * EOF is signalled only by `{done: true}`. `ReadableStreamDefaultReader.read()` carries no requested * count, so the requirement's "for a positive requested count" has no literal analog, but the tolerant * reading is the wrong one to pick: `RetentionWindow` raises on the same input under IO-17's identical * rule, and a response body reaches both this tee and `BufferedSource`, so a divergence would make one * upstream fail or succeed depending only on which wrapper it passed through. + * + * Applied on BOTH read paths -- `drainOnce` and the exceeds-cap tail -- because a rule that holds in one + * regime and not the other makes the same upstream pass or fail depending only on how big the body + * happened to be. + */ +function assertNonEmptyChunk(value: Uint8Array): void { + if (value.length === 0) { + throw new SourceContractViolationError( + 'source delivered 0 bytes without signalling end of stream', + ); + } +} + +/** + * Reads until EOF (fits regime) or until the cap is reached (exceeds regime, leaving the delegate open + * and the overflow chunk staged). BODY-26: a failure is cached, never allowed to truncate silently. */ async function drainOnce(state: DrainState): Promise { try { @@ -75,11 +104,7 @@ async function drainOnce(state: DrainState): Promise { await closeDelegate(state); return; } - if (value.length === 0) { - throw new SourceContractViolationError( - 'source delivered 0 bytes without signalling end of stream', - ); // BODY-25 - } + assertNonEmptyChunk(value); if (state.captured.size + value.length <= state.cap) { state.captured.writeBytes(value); continue; @@ -157,6 +182,15 @@ function tailStream(state: DrainState): ReadableStream { controller.close(); return; } + try { + assertNonEmptyChunk(value); // BODY-25, same rule as the drain + } catch (error: unknown) { + // Cached like any other delegate failure so `error()` still reports it (BODY-26); the throw + // errors this stream, which is what the consumer of the tail actually observes. + state.failure = + error instanceof Error ? error : new Error(String(error)); + throw state.failure; + } controller.enqueue(value); }, async cancel() { @@ -166,7 +200,9 @@ function tailStream(state: DrainState): ReadableStream { } /** - * Wraps a raw response body stream (BODY-22..29). `@internal` -- unwired until Phase 7 supplies a Logger. + * Wraps a raw response body stream (BODY-22..29). + * + * @internal Unwired until Phase 7 supplies a Logger to drive it. */ export function withResponseLogging( delegate: ReadableStream, @@ -214,6 +250,5 @@ export function withResponseLogging( return state.regime === 'fits' ? state.captured.size : declaredLength; }, close: () => closeDelegate(state), - [Symbol.asyncDispose]: () => closeDelegate(state), }; } diff --git a/packages/core/src/body/simple-bodies.test.ts b/packages/core/src/body/simple-bodies.test.ts index 2965924..68c0016 100644 --- a/packages/core/src/body/simple-bodies.test.ts +++ b/packages/core/src/body/simple-bodies.test.ts @@ -3,10 +3,13 @@ // Exercises: HTTP-36/BODY-1 (mediaType, contentLength, replayable, writeTo), HTTP-38/BODY-35 (replayable // by source; form-urlencoded uses "+" for space, distinct from RFC 3986 query encoding; a field value // that cannot be rendered is raised, never dropped), HTTP-26/HTTP-51 (a media type is header-safe), -// RECOV-12 (a close failure never masks the primary write failure) +// RECOV-12 (a close failure never masks the primary write failure), HTTP-1/XCUT-15 (frozen at +// construction, so the declared length cannot be desynced from the bytes writeTo emits) import {describe, expect, test} from 'bun:test'; import {MediaTypeParseError} from '../http/errors.js'; import {FormBodyValidationError} from './errors.js'; +import {multipartBody} from './multipart-body.js'; +import {streamBody} from './stream-body.js'; import { byteArrayBody, formUrlEncodedBody, @@ -186,3 +189,45 @@ describe('form field values (HTTP-38/BODY-35)', () => { ); }); }); + +describe('every Body variant is frozen at construction (HTTP-1)', () => { + // `readonly` is erased at run time. Without the freeze a caller can reassign contentLength after + // construction and desync the value a transport stamps into Content-Length from the bytes writeTo + // emits -- the same drift HTTP-51 makes MultipartBody share one framing routine to prevent. + const variants = (): {name: string; body: object}[] => [ + {name: 'ByteArrayBody', body: byteArrayBody(Uint8Array.from([1, 2, 3]))}, + {name: 'StringBody', body: stringBody('abc')}, + {name: 'FormUrlEncodedBody', body: formUrlEncodedBody({a: 'b'})}, + { + name: 'StreamBody', + body: streamBody( + new ReadableStream({ + start: c => { + c.close(); + }, + }), + ), + }, + { + name: 'MultipartBody', + body: multipartBody([{name: 'a', body: stringBody('x')}], 'B'), + }, + ]; + + for (const {name, body} of variants()) { + test(`${name} is frozen and refuses a contentLength reassignment`, () => { + expect(Object.isFrozen(body)).toBe(true); + expect(() => { + (body as {contentLength: number}).contentLength = 999; + }).toThrow(TypeError); + }); + } + + test('the declared length still matches the bytes written after a reassignment attempt', async () => { + const body = byteArrayBody(Uint8Array.from([1, 2, 3])); + expect(() => { + (body as {contentLength: number}).contentLength = 999; + }).toThrow(TypeError); + expect((await drain(body)).length).toBe(body.contentLength); + }); +}); diff --git a/packages/core/src/body/simple-bodies.ts b/packages/core/src/body/simple-bodies.ts index 7a7c593..db2c8af 100644 --- a/packages/core/src/body/simple-bodies.ts +++ b/packages/core/src/body/simple-bodies.ts @@ -4,6 +4,7 @@ import {QueryParams, type QueryParamsBuilder} from '../http/query-params.js'; import {invariant} from '../invariant.js'; import type {Body} from './body.js'; import {FormBodyValidationError} from './errors.js'; +import {freezeBody} from './freeze-body.js'; import {assertHeaderSafeMediaType} from './media-type-safety.js'; import {withBodyWriter} from './write-body.js'; @@ -13,9 +14,13 @@ import {withBodyWriter} from './write-body.js'; * @public */ export class ByteArrayBody implements Body { + /** Discriminates this variant within the {@link Body} union. */ readonly kind = 'byte-array' as const; + /** The declared media type, or `undefined` when the caller supplied none. */ readonly mediaType: string | undefined; + /** The exact byte count `writeTo` will emit -- always known for an in-memory body. */ readonly contentLength: number; + /** Always `true`: the bytes are held in memory, so every write is byte-for-byte identical. */ readonly replayable = true; readonly #bytes: Uint8Array; @@ -27,8 +32,14 @@ export class ByteArrayBody implements Body { this.#bytes = Uint8Array.from(bytes); this.mediaType = mediaType; this.contentLength = this.#bytes.length; + freezeBody(this); } + /** + * Writes the held bytes into `sink`, then closes it (BODY-1). Repeatable. + * + * @param sink - the destination; this body's to close, the caller's only to supply. + */ async writeTo(sink: WritableStream): Promise { await withBodyWriter(sink, async writer => { if (this.#bytes.length > 0) await writer.write(this.#bytes); @@ -56,10 +67,15 @@ export function byteArrayBody( * @public */ export class StringBody implements Body { + /** Discriminates this variant within the {@link Body} union. */ readonly kind = 'string' as const; + /** Defaults to `text/plain; charset=utf-8`, matching the UTF-8 encoding `writeTo` emits. */ readonly mediaType: string; + /** The UTF-8 byte count, which is not the character count for non-ASCII text. */ readonly contentLength: number; + /** Always `true`: the text is held in memory, so every write is byte-for-byte identical. */ readonly replayable = true; + /** The source text, exactly as supplied. */ readonly text: string; readonly #bytes: Uint8Array; @@ -69,8 +85,15 @@ export class StringBody implements Body { this.mediaType = mediaType; this.#bytes = new TextEncoder().encode(text); this.contentLength = this.#bytes.length; + freezeBody(this); } + /** + * Writes the UTF-8 encoding of {@link StringBody.text} into `sink`, then closes it (BODY-1). + * Repeatable. + * + * @param sink - the destination; this body's to close, the caller's only to supply. + */ async writeTo(sink: WritableStream): Promise { await withBodyWriter(sink, async writer => { if (this.#bytes.length > 0) await writer.write(this.#bytes); @@ -162,10 +185,15 @@ function toQueryParams(input: FormUrlEncodedInput): QueryParams { * @public */ export class FormUrlEncodedBody implements Body { + /** Discriminates this variant within the {@link Body} union. */ readonly kind = 'form-urlencoded' as const; + /** Fixed at `application/x-www-form-urlencoded` -- the encoding defines the media type. */ readonly mediaType = 'application/x-www-form-urlencoded'; + /** The byte count of the encoded form, always known. */ readonly contentLength: number; + /** Always `true`: the encoded form is held in memory (BODY-35). */ readonly replayable = true; + /** The normalized parameters, whatever input shape they were built from. */ readonly params: QueryParams; readonly #bytes: Uint8Array; @@ -179,8 +207,14 @@ export class FormUrlEncodedBody implements Body { ); this.#bytes = new TextEncoder().encode(encoded); this.contentLength = this.#bytes.length; + freezeBody(this); } + /** + * Writes the encoded form into `sink`, then closes it (BODY-1). Repeatable. + * + * @param sink - the destination; this body's to close, the caller's only to supply. + */ async writeTo(sink: WritableStream): Promise { await withBodyWriter(sink, async writer => { if (this.#bytes.length > 0) await writer.write(this.#bytes); diff --git a/packages/core/src/body/stream-body.test.ts b/packages/core/src/body/stream-body.test.ts index 396b929..3530875 100644 --- a/packages/core/src/body/stream-body.test.ts +++ b/packages/core/src/body/stream-body.test.ts @@ -5,7 +5,8 @@ // natural exhaustion), HTTP-39/BODY-10 (declared length verified, short stream raises // delivered-of-declared, and an overrunning stream is stopped BEFORE the extra bytes reach the sink), // IO-3 (a contentLength below the -1 sentinel is rejected), HTTP-26/HTTP-51 (a media type is -// header-safe), RECOV-12 (a close failure never masks the primary write failure) +// header-safe), RECOV-12 (a close failure never masks the primary write failure), HTTP-1 (frozen at +// construction so the declared length cannot be desynced from the written bytes) import {describe, expect, test} from 'bun:test'; import {MediaTypeParseError} from '../http/errors.js'; import {InvariantViolation} from '../invariant.js'; @@ -13,6 +14,9 @@ import {EndOfStreamError} from '../io/errors.js'; import {ConsumedBodyError} from './errors.js'; import {streamBody} from './stream-body.js'; +/** Sentinel distinguishing "cancel() never ran" from "cancel() ran with undefined". */ +const NOT_CANCELLED = Symbol('not-cancelled'); + function readableOf(...chunks: number[][]): ReadableStream { return new ReadableStream({ start(controller) { @@ -45,6 +49,56 @@ function collectingSink(): { }; } +describe('caller stream ownership (BODY-8)', () => { + test('a sink failure does not cancel the caller stream on the unknown-length path', async () => { + // `pipeTo`'s default (`preventCancel: false`) cancels the SOURCE when the destination errors, + // which takes cancellation ownership away from the caller on exactly the failure path -- and + // disagrees with the declared-length path below, which only releases its reader. + let cancelReason: unknown = NOT_CANCELLED; + const source = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.from([1, 2, 3])); + }, + cancel(reason) { + cancelReason = reason; + }, + }); + const failing = new WritableStream({ + write: () => { + throw new Error('SOCKET GONE'); + }, + }); + + expect(streamBody(source).writeTo(failing)).rejects.toThrow('SOCKET GONE'); + await Promise.resolve(); + expect(cancelReason).toBe(NOT_CANCELLED); + }); + + test('a sink failure does not cancel the caller stream on the declared-length path either', async () => { + let cancelReason: unknown = NOT_CANCELLED; + const source = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.from([1, 2, 3])); + controller.close(); + }, + cancel(reason) { + cancelReason = reason; + }, + }); + const failing = new WritableStream({ + write: () => { + throw new Error('SOCKET GONE'); + }, + }); + + expect(streamBody(source, undefined, 3).writeTo(failing)).rejects.toThrow( + 'SOCKET GONE', + ); + await Promise.resolve(); + expect(cancelReason).toBe(NOT_CANCELLED); + }); +}); + describe('StreamBody properties and writeTo (BODY-1, BODY-9)', () => { test('is always single-use, regardless of declared length (BODY-9)', () => { expect(streamBody(readableOf([1, 2]), undefined, 2).replayable).toBe(false); diff --git a/packages/core/src/body/stream-body.ts b/packages/core/src/body/stream-body.ts index c473f1e..c0ba250 100644 --- a/packages/core/src/body/stream-body.ts +++ b/packages/core/src/body/stream-body.ts @@ -4,6 +4,7 @@ import {EndOfStreamError} from '../io/errors.js'; import {invariant} from '../invariant.js'; import type {Body} from './body.js'; import {ConsumedBodyError} from './errors.js'; +import {freezeBody} from './freeze-body.js'; import {assertHeaderSafeMediaType} from './media-type-safety.js'; import {withBodyWriter} from './write-body.js'; @@ -13,11 +14,17 @@ import {withBodyWriter} from './write-body.js'; * @public */ export class StreamBody implements Body { + /** Discriminates this variant within the {@link Body} union. */ readonly kind = 'stream' as const; + /** The declared media type, or `undefined` when the caller supplied none. */ readonly mediaType: string | undefined; + /** The caller-declared byte count, or -1 when unknown (BODY-10). */ readonly contentLength: number; + /** Always `false` -- Node's `ReadableStream` has no generic mark/reset (BODY-9). */ readonly replayable = false; readonly #stream: ReadableStream; + // Not `readonly`, and deliberately unaffected by `freezeBody(this)` below: freeze never touches + // `#private` fields, so BODY-3's consumed-once guard still works on a frozen instance. #consumed = false; constructor( @@ -33,14 +40,28 @@ export class StreamBody implements Body { this.#stream = stream; this.mediaType = mediaType; this.contentLength = contentLength; + freezeBody(this); // HTTP-1 } + /** + * Writes every byte of the wrapped stream into `sink`, then closes it (BODY-1). + * + * @param sink - the destination; this body's to close, the caller's only to supply. + * @throws {@link ConsumedBodyError} on a second call -- this body is single-use (BODY-3). + * @throws EndOfStreamError when a declared `contentLength` disagrees with the bytes the stream + * actually yields, in either direction (HTTP-39/BODY-10). + */ async writeTo(sink: WritableStream): Promise { if (this.#consumed) throw new ConsumedBodyError('stream'); this.#consumed = true; // set before the first await -- BODY-3's race-safety guard if (this.contentLength < 0) { - await this.#stream.pipeTo(sink); + // BODY-8: `preventCancel` is load-bearing, not a default worth inheriting. `pipeTo`'s default + // (`preventCancel: false`) cancels the SOURCE when the destination fails -- taking cancellation + // ownership away from the caller on exactly the failure path where they need it, and + // contradicting `#writeExactly` below, which only releases its reader. Without it one class + // has two opposite ownership rules depending on whether a length was declared. + await this.#stream.pipeTo(sink, {preventCancel: true}); return; } await this.#writeExactly(sink, this.contentLength); diff --git a/packages/core/src/body/typed-response.ts b/packages/core/src/body/typed-response.ts index bbba8fd..d4d6055 100644 --- a/packages/core/src/body/typed-response.ts +++ b/packages/core/src/body/typed-response.ts @@ -23,18 +23,25 @@ export class TypedResponse { this.#parse = parse; } + /** The response status, carrying HTTP-11's range classification. Never touches the body. */ get status(): Response['status'] { return this.#response.status; } + /** The response headers. Never touches the body. */ get headers(): Response['headers'] { return this.#response.headers; } + /** The negotiated protocol as its lower-case wire token, e.g. `http/1.1`. */ get protocol(): string { return this.#response.protocol.token; // lower-case token string (Protocol.token) } + /** + * The reason phrase as sent, or `undefined` when the transport supplied none -- following + * `Response.reasonPhrase` rather than re-converting absence to `null`. + */ get reason(): string | undefined { return this.#response.reasonPhrase; } @@ -49,6 +56,7 @@ export class TypedResponse { * subsequent calls return the same parsed value (or re-throw the same error) without re-parsing or * re-reading the body (HTTP-44). Concurrent first callers share the single in-flight parse (HTTP-45). * + * @returns the parsed value. * @throws Whatever the parser raises -- rethrown identically on every later call, never re-parsed. */ value(): Promise { diff --git a/packages/core/src/http/charset.ts b/packages/core/src/http/charset.ts index 8abe1c1..a1910b2 100644 --- a/packages/core/src/http/charset.ts +++ b/packages/core/src/http/charset.ts @@ -16,10 +16,24 @@ export function resolveCharset(mediaType: string | undefined): string { } /** - * Decodes with `charset`, falling back to UTF-8 when the label is unknown (HTTP-42). `TextDecoder` - * throws a RangeError on an unrecognized label, which callers on an error path are least able to handle. + * Decodes a whole message body with `charset`, falling back to UTF-8 when the label is unknown + * (HTTP-42). `TextDecoder` throws a RangeError on an unrecognized label, which callers on an error path + * are least able to handle. + * + * NOT interchangeable with `io/text-codec.ts`'s `decodeText`, despite the similar shape -- the two + * disagree by design and the name says so: + * + * - This one is whole-body decoding at the HTTP layer. It delegates every label to `TextDecoder`, so + * `iso-8859-1` follows the WHATWG Encoding Standard's mapping onto windows-1252 (0x80 decodes to + * U+20AC), and it consumes a leading BOM, which is what a caller of `Response.text()` expects. + * - `io/text-codec.decodeText` is per-FRAGMENT decoding at the byte layer. It implements true + * ISO-8859-1 so that IO-13's write/read round-trip holds against `encodeText`, and sets + * `ignoreBOM` so a U+FEFF appearing mid-stream survives as ordinary data (SSE-12). + * + * Reaching for the wrong one silently changes bytes. Pick by layer: message bodies here, stream + * fragments there. */ -export function decodeText(bytes: Uint8Array, charset: string): string { +export function decodeBodyText(bytes: Uint8Array, charset: string): string { try { return new TextDecoder(charset).decode(bytes); } catch { diff --git a/packages/core/src/http/response.test.ts b/packages/core/src/http/response.test.ts index 91dd10a..547493e 100644 --- a/packages/core/src/http/response.test.ts +++ b/packages/core/src/http/response.test.ts @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT // packages/core/src/http/response.test.ts // Exercises: HTTP-6 (required fields), HTTP-41/BODY-14 (single-use body, same reference on repeat // access), HTTP-41/BODY-15, HTTP-43 (idempotent close, releases the connection whether or not the body @@ -10,6 +11,16 @@ import {Request} from './request.js'; import {Response} from './response.js'; import {Status} from './status.js'; +/** Awaits a rejection and returns its reason, failing loudly when the promise resolves. */ +async function rejection(promise: Promise): Promise { + try { + await promise; + } catch (error: unknown) { + return error as Error; + } + throw new Error('expected the promise to reject, but it resolved'); +} + function baseRequest(): Request { return Request.newBuilder().url('https://example.com').build(); } @@ -137,6 +148,15 @@ describe('bytes/text (BODY-16, HTTP-42)', () => { expect(await baseResponse(stream, headers).text()).toBe('hé'); }); + test('text() falls back to UTF-8 when the content-type itself is unparseable', async () => { + // Distinct from an unrecognized *charset* below: here MediaType.parse throws before any charset + // is read. HTTP-42's fallback has to cover absent, unparseable, and unrecognized alike. + const headers = Headers.newBuilder() + .add('content-type', 'not a media type at all') + .build(); + expect(await baseResponse(readableOf('ok'), headers).text()).toBe('ok'); + }); + test('text() falls back to UTF-8 when the declared charset is unrecognized', async () => { const headers = Headers.newBuilder() .add('content-type', 'text/plain;charset=bogus-charset') @@ -210,9 +230,74 @@ describe('close (HTTP-41/BODY-15, HTTP-43)', () => { expect(cancelled).toBe(true); }); - test('[Symbol.asyncDispose] delegates to close()', async () => { + test('teardown is close() only -- no [Symbol.asyncDispose] on the >=18.17 floor', () => { + // The symbol postdates engines.node ">=18.17", where the computed key evaluates to `undefined` + // and binds the method to the string "undefined" instead. Asserting its ABSENCE is what keeps it + // from being reintroduced ahead of the floor bump that would make it real on every resource owner. const response = baseResponse(readableOf('x')); - await response[Symbol.asyncDispose](); - expect(response.close()).resolves.toBeUndefined(); + expect( + Object.getOwnPropertyNames(Object.getPrototypeOf(response)), + ).not.toContain('undefined'); + expect(typeof response.close).toBe('function'); + }); +}); + +describe('the close guarantee survives a locked body (BODY-16)', () => { + // `getReader()` itself throws when an external consumer already holds the lock, and BODY-15 + // forbids assuming the body was never touched. Acquiring the reader above the try meant the one + // failure BODY-16's guarantee most needs to cover was the one that skipped close entirely. + function lockedResponse(): {response: Response; released: () => boolean} { + let released = false; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.from([1])); + }, + cancel() { + released = true; + }, + }); + const response = baseResponse(stream); + stream.getReader(); // an external consumer takes the lock + return {response, released: () => released}; + } + + test('bytes() still closes the response when the body is already locked', async () => { + const {response, released} = lockedResponse(); + expect((await rejection(response.bytes())).name).toBe('TypeError'); + // The connection is released as far as this response can release it; the external lock holder's + // own close finishes the job, exactly as close() already documents. + expect(released()).toBe(false); + // Idempotent and already-closed: a second close is a no-op rather than a second cancel attempt. + await response.close(); + }); + + test('text() inherits the same guarantee', async () => { + const {response} = lockedResponse(); + expect((await rejection(response.text())).name).toBe('TypeError'); + await response.close(); + }); +}); + +describe('construction is builder-only (HTTP-2)', () => { + test('the constructor is unreachable from outside the module', () => { + // `Response` is exported as a VALUE, so a public field-wise constructor would let a caller skip + // build()'s required-field validation and would appear in the emitted .d.ts. The private + // constructor plus the createResponse friend hook is what prevents both. + // + // The assertion is the @ts-expect-error itself: every argument below is well-typed and the arity + // is right, so privacy is the ONLY reason this line errors. If the private constructor is ever + // lost, the suppression becomes unused and `tsc` fails the build. + const args = [ + baseRequest(), + Protocol.HTTP_1_1, + Status.of(200), + undefined, + Headers.newBuilder().build(), + null, + ] as const; + const construct = (): unknown => + // @ts-expect-error -- HTTP-2: constructible only through ResponseBuilder, never directly. + new Response(...args); + expect(construct()).toBeInstanceOf(Response); }); }); diff --git a/packages/core/src/http/response.ts b/packages/core/src/http/response.ts index 1a5de64..4f2b0b3 100644 --- a/packages/core/src/http/response.ts +++ b/packages/core/src/http/response.ts @@ -2,14 +2,35 @@ // packages/core/src/http/response.ts import type {Builder} from './builder.js'; import {requireField} from './builder.js'; -import {decodeText, resolveCharset} from './charset.js'; +import {decodeBodyText, resolveCharset} from './charset.js'; import {Headers} from './headers.js'; import type {Protocol} from './protocol.js'; import type {Request} from './request.js'; import type {Status} from './status.js'; +// eslint-disable-next-line max-params -- private, builder-internal plumbing; field count fixed by HTTP-6 +let createResponse: ( + request: Request, + protocol: Protocol, + status: Status, + reasonPhrase: string | undefined, + headers: Headers, + body: ReadableStream | null, +) => Response; + /** - * An HTTP response model (HTTP-6). + * An immutable HTTP response: the originating request, the negotiated protocol, the status, an + * optional reason phrase, headers, and a single-use body stream (HTTP-6). + * + * Status-range classification is reached through {@link Response.status} — `response.status.isSuccess`, + * `response.status.isError`, and the rest (HTTP-11). + * + * Owns the body's connection, released by {@link Response.close}. Teardown is `close()` only, with no + * `[Symbol.asyncDispose]`: the symbol postdates the declared `engines.node` floor (`>=18.17`), where the + * computed key evaluates to `undefined` and binds the method to the string `"undefined"` — wrong, + * silent, and only at run time — and its type reaches this package only through a dev-only global, so a + * consumer compiling against the published `.d.ts` on the same `lib` this package declares cannot build. + * Matches every Phase 2/3a resource-owning class. Revisit when the checkpoint's floor bump lands. * * @public */ @@ -25,7 +46,7 @@ export class Response { #closing: Promise | undefined; // eslint-disable-next-line max-params -- private, builder-internal; field count fixed by the wire model (HTTP-6) - constructor( + private constructor( request: Request, protocol: Protocol, status: Status, @@ -42,10 +63,34 @@ export class Response { Object.freeze(this); } + static { + // TypeScript has no friend classes, so ResponseBuilder reaches the private constructor through this + // module-scoped hook, assigned exactly once. HTTP-2: no public field-wise constructor may appear in + // the emitted `.d.ts`, or a consumer can construct around build()'s required-field validation. + // eslint-disable-next-line max-params -- private, builder-internal plumbing; field count fixed by HTTP-6 + createResponse = (request, protocol, status, reasonPhrase, headers, body) => + new Response(request, protocol, status, reasonPhrase, headers, body); + } + + /** + * Starts an empty builder. + * + * @returns a fresh {@link ResponseBuilder}. + */ static newBuilder(): ResponseBuilder { return new ResponseBuilder(); } + /** + * Derives a builder pre-populated from this instance (HTTP-3). + * + * Every field it carries is itself immutable — `Request` freezes and defensively clones its URL, + * and `Headers`, `Status`, and `Protocol` are frozen values — so sharing them cannot leak + * mutability back into either instance. The body stream is shared by reference, since it is + * single-use by definition (BODY-14) and a copy would be a replay. + * + * @returns a {@link ResponseBuilder} holding this response's state. + */ newBuilder(): ResponseBuilder { return new ResponseBuilder() .request(this.#request) @@ -56,22 +101,27 @@ export class Response { .body(this.#body); } + /** The request this response was produced for. */ get request(): Request { return this.#request; } + /** The negotiated protocol version. */ get protocol(): Protocol { return this.#protocol; } + /** The response status, which also carries the range classification (HTTP-11). */ get status(): Status { return this.#status; } + /** The reason phrase as sent, or `undefined` when the transport supplied none. */ get reasonPhrase(): string | undefined { return this.#reasonPhrase; } + /** The response headers — never null, possibly empty. */ get headers(): Headers { return this.#headers; } @@ -81,16 +131,27 @@ export class Response { return this.#body; } - /** Reads the whole body as bytes, closing the response whether or not the read succeeds (BODY-16). */ + /** + * Reads the whole body as bytes, closing the response whether or not the read succeeds (BODY-16). + * + * @returns every byte of the body, or an empty array when there is no body. + * @throws Whatever the body stream raises mid-read, and a `TypeError` when an external consumer + * already holds the body's reader lock. The connection is released in every case. + */ async bytes(): Promise { if (this.#body === null) { await this.close(); return new Uint8Array(0); } - const reader = this.#body.getReader(); const chunks: Uint8Array[] = []; let total = 0; + // Acquired INSIDE the try. `getReader()` itself throws a TypeError when an external consumer + // already holds the lock, and BODY-15 forbids assuming the body was never touched -- so acquiring + // it above the try meant the one failure BODY-16's guarantee most needs to cover was the one that + // skipped the close entirely, leaving the connection held. + let reader: ReadableStreamDefaultReader | undefined; try { + reader = this.#body.getReader(); for (;;) { // Serial by necessity: each read depends on the previous one advancing the cursor. const {done, value} = await reader.read(); @@ -102,7 +163,7 @@ export class Response { // MUST precede close(): ReadableStream.cancel() rejects with TypeError on a locked stream, and // reading to done does NOT release the lock. Without this the finally replaces the read value // with a TypeError and bytes()/text() never succeed. - reader.releaseLock(); + reader?.releaseLock(); await this.close(); } const result = new Uint8Array(total); @@ -114,13 +175,32 @@ export class Response { return result; } - /** Reads the whole body as text, defaulting to the media type's charset then UTF-8 (HTTP-42). */ + /** + * Reads the whole body as text, closing the response the same way {@link Response.bytes} does. + * + * Decodes with the charset declared by `content-type`, falling back to UTF-8 when it is absent, + * unparseable, or an unrecognized label (HTTP-42). + * + * @returns the decoded body, or the empty string when there is no body. + * @throws Whatever {@link Response.bytes} throws, which this delegates to -- including the + * `TypeError` an externally locked body produces. The connection is released in every case. + */ async text(): Promise { const bytes = await this.bytes(); - return decodeText(bytes, resolveCharset(this.#headers.get('content-type'))); + return decodeBodyText( + bytes, + resolveCharset(this.#headers.get('content-type')), + ); } - /** Idempotent; releases the underlying connection whether or not the body was read (BODY-15, HTTP-43). */ + /** + * Releases the underlying connection whether or not the body was ever read (BODY-15, HTTP-43). + * + * Idempotent, and safe to call while an external consumer still holds the body's reader lock. + * + * @throws Whatever cancelling the body stream raises, other than the `TypeError` a locked stream + * reports — that one is expected here and swallowed. + */ async close(): Promise { // Memoized rather than flag-guarded, the same shape BufferedSink.close settled on for IO-5/IO-41: // a `#closed = true` set before the await reports a FAILED release as success to every later caller, @@ -139,14 +219,10 @@ export class Response { if (!(error instanceof TypeError)) throw error; }); } - - async [Symbol.asyncDispose](): Promise { - await this.close(); - } } /** - * Builder for {@link Response}. + * Accumulates response state and produces an immutable {@link Response}. * * @public */ @@ -158,41 +234,86 @@ export class ResponseBuilder implements Builder { #headers: Headers = Headers.newBuilder().build(); #body: ReadableStream | null = null; + /** + * Sets the originating request. Required. + * + * @param request - the request this response answers. + * @returns this builder, for chaining. + */ request(request: Request): this { this.#request = request; return this; } + /** + * Sets the negotiated protocol. Required. + * + * @param protocol - the protocol the exchange used. + * @returns this builder, for chaining. + */ protocol(protocol: Protocol): this { this.#protocol = protocol; return this; } + /** + * Sets the response status. Required. + * + * @param status - the status received. + * @returns this builder, for chaining. + */ status(status: Status): this { this.#status = status; return this; } + /** + * Sets the reason phrase. + * + * @param reasonPhrase - the phrase as sent, or `undefined` when there was none. + * @returns this builder, for chaining. + */ reasonPhrase(reasonPhrase: string | undefined): this { this.#reasonPhrase = reasonPhrase; return this; } + /** + * Sets the response headers, replacing whatever was set before. + * + * @param headers - the headers received; already immutable, so held by reference. + * @returns this builder, for chaining. + */ headers(headers: Headers): this { this.#headers = headers; return this; } + /** + * Sets the response body. + * + * @param body - the single-use body stream, or `null` when the response carries none. + * `null` rather than `undefined` here mirrors WHATWG `fetch`'s `Response.body` deliberately; + * `Request.body` keeps the domain model's `undefined` convention. + * @returns this builder, for chaining. + */ body(body: ReadableStream | null): this { this.#body = body; return this; } + /** + * Validates the required fields and constructs the response. + * + * @returns the frozen response. + * @throws {@link RequiredFieldError} when the request, protocol, or status was never set, + * naming whichever is missing (HTTP-4). + */ build(): Response { const request = requireField(this.#request, 'request'); const protocol = requireField(this.#protocol, 'protocol'); const status = requireField(this.#status, 'status'); - return new Response( + return createResponse( request, protocol, status, diff --git a/packages/core/src/io/buffered-sink.ts b/packages/core/src/io/buffered-sink.ts index 1e3aa21..d389788 100644 --- a/packages/core/src/io/buffered-sink.ts +++ b/packages/core/src/io/buffered-sink.ts @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT // packages/core/src/io/buffered-sink.ts -import {invariant} from '../invariant.js'; import type {ByteQueue} from './byte-queue.js'; import {ClosedResourceError, EndOfStreamError} from './errors.js'; +import {assertCount} from './limits.js'; import type {Sink} from './sink.js'; import {encodeText} from './text-codec.js'; @@ -163,10 +163,3 @@ export class BufferedSink implements Sink { if (this.#closed) throw new ClosedResourceError('BufferedSink'); } } - -function assertCount(count: number): void { - invariant( - Number.isInteger(count) && count >= 0, - `count must be a non-negative integer, got ${String(count)}`, - ); -} diff --git a/packages/core/src/io/buffered-source.ts b/packages/core/src/io/buffered-source.ts index d180978..4545a95 100644 --- a/packages/core/src/io/buffered-source.ts +++ b/packages/core/src/io/buffered-source.ts @@ -3,7 +3,7 @@ import {invariant} from '../invariant.js'; import {ByteQueue, copyBytes} from './byte-queue.js'; import {ClosedResourceError, EndOfStreamError} from './errors.js'; -import {assertAllocatable, END_OF_STREAM} from './limits.js'; +import {assertAllocatable, assertCount, END_OF_STREAM} from './limits.js'; import {RetentionWindow, type Cursor} from './retention-window.js'; import {assertDecodable, decodeText} from './text-codec.js'; @@ -346,10 +346,3 @@ const READ_CHUNK = 16 * 1024; const BRIDGE_CHUNK = 16 * 1024; const NEWLINE = 0x0a; const CARRIAGE_RETURN = 0x0d; - -function assertCount(count: number): void { - invariant( - Number.isInteger(count) && count >= 0, - `count must be a non-negative integer, got ${String(count)}`, - ); -} diff --git a/packages/core/src/io/byte-queue.ts b/packages/core/src/io/byte-queue.ts index 161948b..75fbd5a 100644 --- a/packages/core/src/io/byte-queue.ts +++ b/packages/core/src/io/byte-queue.ts @@ -4,6 +4,7 @@ import {invariant} from '../invariant.js'; import {AllocationLimitError, EndOfStreamError} from './errors.js'; import { assertAllocatable, + assertCount, END_OF_STREAM, MAX_BYTE_ARRAY_LENGTH, } from './limits.js'; @@ -280,13 +281,6 @@ export class ByteQueue { } } -function assertCount(count: number): void { - invariant( - Number.isInteger(count) && count >= 0, - `count must be a non-negative integer, got ${String(count)}`, - ); -} - /** * A genuinely independent copy of `bytes`. * diff --git a/packages/core/src/io/factories.test.ts b/packages/core/src/io/factories.test.ts index 2026a38..3f993be 100644 --- a/packages/core/src/io/factories.test.ts +++ b/packages/core/src/io/factories.test.ts @@ -2,8 +2,10 @@ // packages/core/src/io/factories.test.ts // Exercises: IO-30 (factory half — fresh, independent, empty buffers; stream, byte-array, and // foreign-primitive wrapping; the byte-array source is an independent copy), IO-17 (a primitive -// source returning 0 for a positive request fails loudly) +// source returning 0 for a positive request fails loudly, and one that misreports its transferred +// count in either direction is a contract violation rather than an exhausted stream) import {describe, expect, test} from 'bun:test'; +import {ByteQueue} from './byte-queue.js'; import {SourceContractViolationError} from './errors.js'; import { bufferedSinkOverPrimitive, @@ -133,3 +135,33 @@ describe('bufferedSourceOverPrimitive residue handling (IO-1, IO-17)', () => { expect([...(await source.readBytes())]).toEqual([10, 11, 20, 21]); }); }); + +describe('a foreign primitive source that misreports its count (IO-17)', () => { + test('over-reporting is a contract violation, not an exhausted stream', async () => { + // Left to `takeBytes` this surfaced as `EndOfStreamError: delivered 2 of 99 bytes` -- reporting a + // foreign source's broken accounting as end-of-stream, the exact confusion IO-17 forbids. + const source = bufferedSourceOverPrimitive({ + read(dest: ByteQueue): number { + dest.writeBytes(Uint8Array.from([1, 2])); + return 99; + }, + }); + const error = await rejection(source.readBytes()); + expect(error).toBeInstanceOf(SourceContractViolationError); + expect(error.message).toContain('appended only 2'); + // No close(): the pull failure already errored the stream, so cancel() would reject with the very + // same error. Matches the zero-read case above. + }); + + test('under-reporting is a contract violation too', async () => { + const source = bufferedSourceOverPrimitive({ + read(dest: ByteQueue): number { + dest.writeBytes(Uint8Array.from([1, 2, 3])); + return 1; + }, + }); + expect(await rejection(source.readBytes())).toBeInstanceOf( + SourceContractViolationError, + ); + }); +}); diff --git a/packages/core/src/io/factories.ts b/packages/core/src/io/factories.ts index 6bf5bb8..5dc6cbd 100644 --- a/packages/core/src/io/factories.ts +++ b/packages/core/src/io/factories.ts @@ -86,6 +86,14 @@ export function bufferedSourceOverPrimitive( 'foreign source returned 0 bytes for a positive request', ); } + // IO-17: over-reporting is a contract violation too, and must say so. Left to `takeBytes` it + // surfaced as `EndOfStreamError: delivered 2 of 99 bytes` -- reporting a foreign source's + // broken accounting as an exhausted stream, which is the exact confusion IO-17 forbids. + if (staging.size < read) { + throw new SourceContractViolationError( + `foreign source reported ${String(read)} bytes but appended only ${String(staging.size)}`, + ); + } const chunk = staging.takeBytes(read); // IO-17: appending more than it reported is a contract violation too. Silently dropping the // excess is how bytes go missing with no error at all. diff --git a/packages/core/src/io/index.ts b/packages/core/src/io/index.ts index 7910892..93f9e0a 100644 --- a/packages/core/src/io/index.ts +++ b/packages/core/src/io/index.ts @@ -28,6 +28,7 @@ export { } from './factories.js'; export { assertAllocatable, + assertCount, END_OF_STREAM, MAX_BYTE_ARRAY_LENGTH, } from './limits.js'; diff --git a/packages/core/src/io/limits.ts b/packages/core/src/io/limits.ts index b7283c6..7c00049 100644 --- a/packages/core/src/io/limits.ts +++ b/packages/core/src/io/limits.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT // packages/core/src/io/limits.ts +import {invariant} from '../invariant.js'; import {AllocationLimitError} from './errors.js'; /** @@ -39,3 +40,20 @@ export function assertAllocatable(count: number): void { throw new AllocationLimitError(count, MAX_BYTE_ARRAY_LENGTH); } } + +/** + * IO-3's eager guard: a negative or non-integer count is an argument error, rejected BEFORE any I/O so + * neither the source nor the destination is touched. + * + * Single-sourced here for the same reason `assertAllocatable` is. It previously existed as three + * byte-for-byte copies (`byte-queue.ts`, `buffered-source.ts`, `buffered-sink.ts`) and `TeeSink` — the + * fourth size-taking surface — had none at all, so a negative count reached it and was rejected only + * indirectly, by whichever `ByteQueue` call happened to run first. That is exactly the drift the + * "a rule applied in two shapes is a rule that drifts" note above warns about. + */ +export function assertCount(count: number): void { + invariant( + Number.isInteger(count) && count >= 0, + `count must be a non-negative integer, got ${String(count)}`, + ); +} diff --git a/packages/core/src/io/tee-sink.test.ts b/packages/core/src/io/tee-sink.test.ts index 9f080e5..32560d8 100644 --- a/packages/core/src/io/tee-sink.test.ts +++ b/packages/core/src/io/tee-sink.test.ts @@ -6,7 +6,8 @@ // IO-28 (no direct backing-buffer handle), IO-29 (flush/close/emit forward to the primary only), // IO-42 (write after close rejects with the source intact), // IO-13 (the tap mirrors the primary's exact encoded bytes, and refuses a label identically), -// IO-16 (the tee's own writable bridge still feeds the tap) +// IO-16 (the tee's own writable bridge still feeds the tap), +// IO-3 (a negative count is an argument error, rejected before any transfer) import {describe, expect, test} from 'bun:test'; import fc from 'fast-check'; import {BufferedSink} from './buffered-sink.js'; @@ -275,3 +276,28 @@ describe('TeeSink as a first-class sink (IO-16, IO-25)', () => { expect(tee.snapshot().length).toBe(0); }); }); + +describe('argument validation (IO-3)', () => { + test('a negative count is rejected before the source or the tap is touched', async () => { + // Previously reached the tee unchecked and was rejected only indirectly, by whichever ByteQueue + // call happened to run first -- and not at all on the count === 0 and short-source early returns. + const {stream, chunkSizes} = collectingWritableStream(); + const tee = new TeeSink(BufferedSink.overStream(stream)); + const source = queueOf(Uint8Array.from([1, 2, 3])); + + expect((await rejection(tee.write(source, -1))).name).toBe( + 'InvariantViolation', + ); + expect(source.size).toBe(3); + expect(tee.snapshot().length).toBe(0); + expect(chunkSizes()).toEqual([]); + }); + + test('a negative count is rejected even when the source is empty', async () => { + const {stream} = collectingWritableStream(); + const tee = new TeeSink(BufferedSink.overStream(stream)); + expect((await rejection(tee.write(new ByteQueue(), -1))).name).toBe( + 'InvariantViolation', + ); + }); +}); diff --git a/packages/core/src/io/tee-sink.ts b/packages/core/src/io/tee-sink.ts index 322045b..dc6e560 100644 --- a/packages/core/src/io/tee-sink.ts +++ b/packages/core/src/io/tee-sink.ts @@ -3,6 +3,7 @@ import {invariant} from '../invariant.js'; import {ByteQueue} from './byte-queue.js'; import {ClosedResourceError, EndOfStreamError, IoError} from './errors.js'; +import {assertCount} from './limits.js'; import type {Sink} from './sink.js'; import {encodeText} from './text-codec.js'; @@ -61,6 +62,10 @@ export class TeeSink implements Sink { * exactly that, so the tap records what was ATTEMPTED, not what reached the wire. */ async write(src: ByteQueue, count: number): Promise { + // IO-3: a negative or non-integer count is an argument error, rejected here rather than left to + // whichever `ByteQueue` call happens to run first -- which reported it only as a side effect of + // `copyTo`, and not at all on the `count === 0` and short-source paths that return early. + assertCount(count); // IO-42: reject before consuming from `src` or touching the tap. if (this.#primary.closed) throw new ClosedResourceError('TeeSink'); if (src.size < count) throw new EndOfStreamError(src.size, count); diff --git a/packages/core/src/io/text-codec.ts b/packages/core/src/io/text-codec.ts index 6c1e33e..43df212 100644 --- a/packages/core/src/io/text-codec.ts +++ b/packages/core/src/io/text-codec.ts @@ -63,6 +63,10 @@ export function encodeText(text: string, charset: string): Uint8Array { * * Every other label goes to `TextDecoder`, which is correct for them. * + * NOT interchangeable with `http/charset.ts`'s `decodeBodyText`, which decodes a whole message body at + * the HTTP layer, delegates `iso-8859-1` to `TextDecoder`'s windows-1252 mapping, and consumes a leading + * BOM. This one is per-fragment decoding at the byte layer. See that function's note for the full split. + * * `ignoreBOM: true` is REQUIRED, not incidental. The decoder is applied per fragment — per line, per * counted read — so the default (strip a leading U+FEFF) deletes a BOM anywhere a fragment happens to * begin, not just at the start of a stream. That silently drops the first three bytes of a body, breaking diff --git a/scripts/verify-consumer-types.mjs b/scripts/verify-consumer-types.mjs new file mode 100644 index 0000000..a6f17ed --- /dev/null +++ b/scripts/verify-consumer-types.mjs @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: MIT +// scripts/verify-consumer-types.mjs +// +// Compiles a throwaway consumer against the BUILT `.d.ts` using the same `lib` and `target` this +// workspace declares, with `types: []` so nothing from devDependencies leaks in. +// +// This gate exists because a real defect got all the way through every other one. `Response` shipped +// an `async [Symbol.asyncDispose]()` that type-checked in-repo only because `@types/bun` — a +// dev-only global — supplies the symbol. A consumer on `lib: ["ES2022", "DOM"]`, which is what this +// workspace itself declares, got `TS2550: Property 'asyncDispose' does not exist on type +// 'SymbolConstructor'` and could not build at all. `typecheck` passed (dev types present), `build` +// passed, `api` passed, `lint:publish` passed (publint and attw check resolution and export shape, +// not whether the declarations resolve), and `verify:dual-consumption` passed because it runs `node`, +// not `tsc`. +// +// The `lib`/`target` are read from tsconfig.base.json rather than hardcoded, so the gate tracks the +// declared baseline instead of drifting away from it. +import assert from 'node:assert/strict'; +import {execFileSync} from 'node:child_process'; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join, resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const repoRoot = resolve(fileURLToPath(new URL('..', import.meta.url))); +const base = JSON.parse( + readFileSync(join(repoRoot, 'tsconfig.base.json'), 'utf8'), +); +const {lib, target} = base.compilerOptions; +assert.ok( + Array.isArray(lib) && lib.length > 0, + 'tsconfig.base.json must declare a lib array', +); + +const built = join(repoRoot, 'packages', 'core', 'dist', 'index.js'); +const tsc = join(repoRoot, 'node_modules', '.bin', 'tsc'); + +// Checked up front, not left to the catch below. A missing prerequisite reported through the +// type-failure path would read as "the published .d.ts is broken", which is the one message this +// gate must never send falsely. +assert.ok( + existsSync(tsc), + `tsc not found at ${tsc} — run \`bun install\` before this gate`, +); +assert.ok( + existsSync(built), + `built package not found at ${built} — run \`bun run build\` before this gate`, +); +const workDir = mkdtempSync(join(tmpdir(), 'dexpace-consumer-types-')); + +// Exercises the surface most likely to reference a declaration the consumer's lib cannot resolve: +// the resource-owning class, an async iterable/stream type, a generic, and a factory. +const consumer = ` +import { + type Body, + byteArrayBody, + materialize, + Response, + Status, + toHttpError, + TypedResponse, +} from ${JSON.stringify(built)}; + +export function readBody(response: Response): Promise { + return response.text(); +} +export function release(response: Response): Promise { + return response.close(); +} +export function stream(response: Response): ReadableStream | null { + return response.body; +} +export function replay(body: Body): Promise { + return materialize(body); +} +export function typed(wrapper: TypedResponse): Promise { + return wrapper.value(); +} +export const bytes: Body = byteArrayBody(new Uint8Array([1]), 'application/octet-stream'); +export const errorOf = toHttpError; +export const ok: number = Status.of(200).code; +`; + +const tsconfig = { + compilerOptions: { + target, + lib, + module: 'nodenext', + moduleResolution: 'nodenext', + strict: true, + noEmit: true, + // The whole point: no ambient globals from devDependencies. A consumer installing this package + // gets exactly `lib` plus whatever they install themselves. + types: [], + skipLibCheck: false, + }, + include: ['consumer.ts'], +}; + +try { + writeFileSync(join(workDir, 'consumer.ts'), consumer); + writeFileSync( + join(workDir, 'tsconfig.json'), + JSON.stringify(tsconfig, null, 2), + ); + + execFileSync(tsc, ['-p', join(workDir, 'tsconfig.json')], { + stdio: 'pipe', + encoding: 'utf8', + }); +} catch (error) { + const detail = `${error.stdout ?? ''}${error.stderr ?? ''}`.trim(); + console.error( + 'consumer-types check FAILED: the published .d.ts does not compile against this workspace\n' + + `own declared lib (${lib.join(', ')}) with types: [].\n\n${detail}\n\n` + + 'A declaration is reaching for a global that only a devDependency supplies. Either drop it, or\n' + + 'add the lib entry to tsconfig.base.json and raise engines.node to a runtime that has it.', + ); + process.exit(1); +} finally { + rmSync(workDir, {recursive: true, force: true}); +} + +console.log( + `consumer-types check passed: dist/*.d.ts compiles on lib [${lib.join(', ')}] with types: []`, +); diff --git a/scripts/verify-runtime-floor.mjs b/scripts/verify-runtime-floor.mjs index 9272dbc..1fa9161 100644 --- a/scripts/verify-runtime-floor.mjs +++ b/scripts/verify-runtime-floor.mjs @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT // scripts/verify-runtime-floor.mjs // // NFR-10 / the design doc's "Runtime-floor discipline" gate: a publishable diff --git a/scripts/verify-seam-1.mjs b/scripts/verify-seam-1.mjs index 55ceeab..fe8df1a 100644 --- a/scripts/verify-seam-1.mjs +++ b/scripts/verify-seam-1.mjs @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT // scripts/verify-seam-1.mjs import assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; From bd8f49c1a1becf066297e31b4e51015ae1c617e4 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 26 Aug 2026 00:34:50 +0300 Subject: [PATCH 4/8] docs: expand phase 3 open findings, correct checkpoint status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phase 3b plan lists the 2026-07-25 checkpoint as a signed-off prerequisite. It has no commit and every box is unchecked — but §5.1 landed in bunfig.toml and half of §5.3 landed in errors.ts, which is exactly what made the claim look true to a spot check. Records the measured status of all twelve §5 items rather than the flat "it did not run". Grows the phase 3b execution findings from two rows to seven. E1 and E2 gain verified version numbers and measured blast radius; E3-E7 are new: §5.3 applied to 2 of 10 error leaves and stopped, §5.7 no isolated linker configured, §5.9 no test:node script exists although the 3b plan's own gate sequence calls it, §5.10 none of the eleven model files carries the #private justification, §5.8 stale NFR-14 reason. Resolves phase 4b's F1 to branch (b). Two of its premises were false: the floor was never raised, and SuppressedError arrived in Node 24.0.0 with the full Explicit Resource Management proposal rather than in the 18.18.0/20.4.0 symbols backport — so branch (a) means dropping Node 18, 20 and 22 outright. esnext.disposable supplies Symbol.asyncDispose's type but not SuppressedError's runtime, so E1's floor bump does not fix F1 and must not be read as doing so, including by 5a, 6b and 6c. Adds the phase 3b checklist, which was missing entirely, and records this phase's own residuals separately from the checkpoint's — among them the multipart boundary non-appearance limitation, which is documented rather than partially checked because a StreamBody part's bytes do not exist until the write. --- ...26-07-24-phase3a-io-contracts-checklist.md | 6 +- ...-07-25-phase3b-body-lifecycle-checklist.md | 99 +++++++++++++++ ...2026-07-23-nodejs-sdk-v1-roadmap-design.md | 114 +++++++++++++++++- ...026-07-25-phase3b-body-lifecycle-design.md | 100 ++++++++++++--- 4 files changed, 295 insertions(+), 24 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-25-phase3b-body-lifecycle-checklist.md diff --git a/docs/superpowers/plans/2026-07-24-phase3a-io-contracts-checklist.md b/docs/superpowers/plans/2026-07-24-phase3a-io-contracts-checklist.md index 066b0ed..abc02e1 100644 --- a/docs/superpowers/plans/2026-07-24-phase3a-io-contracts-checklist.md +++ b/docs/superpowers/plans/2026-07-24-phase3a-io-contracts-checklist.md @@ -13,7 +13,7 @@ requirement ID in `docs/product-spec/05-i-o-contracts.md`, as dispositioned by |---|---|---|---|---| | IO-1 | MUST | Tail-append, transferred count, ≥1 when non-exhausted, 0 for count 0, −1 at end, never over-deliver | ✅ | Task 2 (`ByteQueue.read`), Task 6 (`BufferedSource.read`); partial-then-EOF asserted at both | | IO-2 | MUST | A 0-count read returns 0 and never reports end-of-stream | ✅ | Tasks 2, 6 — checked **before** exhaustion, commented as load-bearing at both sites, asserted on a fresh and an exhausted source | -| IO-3 | MUST | Negative count rejected as an argument error before any I/O | ✅ | Tasks 2, 6, 9 via `assertCount`/`invariant`; asserted to leave both source and destination untouched | +| IO-3 | MUST | Negative count rejected as an argument error before any I/O | ✅ | Tasks 2, 6, 9, 10 via `assertCount`, single-sourced in `limits.ts`; asserted to leave both source and destination untouched. **Corrected during Phase 3b:** the guard shipped as three byte-for-byte copies and `TeeSink` — the fourth size-taking surface — had none, so a negative count reached it and was rejected only indirectly by whichever `ByteQueue` call ran first, and not at all on its `count === 0` and short-source early returns | | IO-4 | MUST | Sink write removes exactly N from the source HEAD; fails rather than writing partially | ✅ | Task 2 (`ByteQueue.write`), Task 9 (`BufferedSink.write`); nothing reaches the wire on the short-source path | | IO-5 | MUST | Sink exposes flush; source and sink both closeable | ✅ | Task 9 | | IO-18 | SHOULD | `emit` (cheap handoff) distinguished from `flush` (full force-out); in-memory may no-op returning self | ✅ | Task 9 | @@ -35,7 +35,7 @@ requirement ID in `docs/product-spec/05-i-o-contracts.md`, as dispositioned by |---|---|---|---|---| | IO-11 | MUST | `exhausted()`, single-byte read, count-less read of all remaining (empty when exhausted) | ✅ | Task 6 | | IO-12 | MUST | Exact-count read returns exactly N or fails; never short | ✅ | Task 6, asserted across chunk boundaries and on the short path | -| IO-13 | MUST | UTF-8 and explicit-charset reads, with symmetric write-side encodings | ✅ (read) / ⚠️ (write, bounded) | Task 7 (read: any `TextDecoder` label), Task 9 (write: **UTF-8 and ISO-8859-1 only**), plus two `fast-check` round-trip property tests in `buffered-sink.test.ts` — sink-out/source-back through UTF-8, and through ISO-8859-1 asserting one byte per code point, which is what distinguishes an honored charset from a silent UTF-8 re-encoding. `TeeSink`'s own `writeUtf8`/`writeString` are asserted to mirror the primary's exact encoded bytes and to refuse an unsupported label identically. `TextEncoder` is UTF-8-only and `SEAM-1` forbids an encoding dependency, so full symmetry is unreachable; any other label throws rather than silently re-encoding. Ledgered deviation | +| IO-13 | MUST | UTF-8 and explicit-charset reads, with symmetric write-side encodings | ✅ (read) / ⚠️ (write, bounded) | Task 7 (read: any `TextDecoder` label; `text-codec.ts`'s `decodeText` implements true ISO-8859-1 and sets `ignoreBOM`, and is deliberately **not** interchangeable with `http/charset.ts`'s whole-body `decodeBodyText` — the names were disambiguated in Phase 3b), Task 9 (write: **UTF-8 and ISO-8859-1 only**), plus two `fast-check` round-trip property tests in `buffered-sink.test.ts` — sink-out/source-back through UTF-8, and through ISO-8859-1 asserting one byte per code point, which is what distinguishes an honored charset from a silent UTF-8 re-encoding. `TeeSink`'s own `writeUtf8`/`writeString` are asserted to mirror the primary's exact encoded bytes and to refuse an unsupported label identically. `TextEncoder` is UTF-8-only and `SEAM-1` forbids an encoding dependency, so full symmetry is unreachable; any other label throws rather than silently re-encoding. Ledgered deviation | | IO-14 | MUST | Line read consumes the terminator; `\n` and `\r\n` both terminate; lone `\r` is content; final unterminated line as-is; absent when exhausted first | ✅ | Task 7, including a `fast-check` property test with **adversarially generated chunk boundaries**, so a terminator straddling two stream chunks is covered — the case the requirement's rationale names and hand-picked examples miss | | IO-15 | MUST | Skip advances exactly N, fails if fewer remain; `skip(0)` a no-op even at/after EOF | ✅ | Task 6 | | IO-16 | SHOULD | Read-only host-native byte-stream bridge; symmetric writable bridge; closing the bridge closes the owner | ✅ | Task 12. Host-native means `ReadableStream`/`WritableStream` for this port, per `sdk-design/03` §3.1 — no `node:` import; Task 13 Step 9 greps to enforce that | @@ -56,7 +56,7 @@ requirement ID in `docs/product-spec/05-i-o-contracts.md`, as dispositioned by | ID | Level | Requirement gist | Status | Where | |---|---|---|---|---| -| IO-17 | MUST | Write-all pumps to exhaustion, terminates only on −1; a foreign source's zero-read for a positive request is an I/O error, never EOF and never spun on | ✅ | Task 11 (`writeAll`); the violation is raised in Task 5's `#pullOnce` and driven by `protocolViolatingStream` | +| IO-17 | MUST | Write-all pumps to exhaustion, terminates only on −1; a foreign source's zero-read for a positive request is an I/O error, never EOF and never spun on | ✅ | Task 11 (`writeAll`); the violation is raised in Task 5's `#pullOnce` and driven by `protocolViolatingStream`. **Corrected during Phase 3b:** a primitive source that *over*-reported its transferred count was left to `ByteQueue.takeBytes` and surfaced as `EndOfStreamError: delivered 2 of 99 bytes` — a foreign source's broken accounting reported as an exhausted stream, the exact confusion this requirement forbids. Both misreport directions now raise `SourceContractViolationError` and are asserted | | IO-25 | MUST | Tee mirrors into the tap AND forwards the full untruncated payload; the wire body is never reduced | ✅ | Task 10, plus **the most important property test in §5**: for arbitrary write sequences and arbitrary tap caps, the primary receives the exact concatenation of every written byte | | IO-26 | MUST | Tap capacity limit; default effectively unbounded; a limit of 0 mirrors nothing while forwarding everything | ✅ | Task 10 (`Number.POSITIVE_INFINITY` default, spelled as a value rather than a magic number); all three cases asserted | | IO-27 | MUST | Mirror BEFORE forwarding; clear staging even on a failed write so no stale bytes prepend | ✅ | Task 10, both clauses asserted; staging cleared in a `finally` so it holds on the throwing path | diff --git a/docs/superpowers/plans/2026-07-25-phase3b-body-lifecycle-checklist.md b/docs/superpowers/plans/2026-07-25-phase3b-body-lifecycle-checklist.md new file mode 100644 index 0000000..d237bbb --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-phase3b-body-lifecycle-checklist.md @@ -0,0 +1,99 @@ +# Phase 3b — Body Lifecycle Implementation Plan — Checklist + +Verification of [2026-07-25-phase3b-body-lifecycle.md](./2026-07-25-phase3b-body-lifecycle.md) against every +requirement ID in `docs/product-spec/06-request-and-response-body-lifecycle.md`, as dispositioned by +`docs/superpowers/specs/2026-07-25-phase3b-body-lifecycle-design.md`. + +**Legend:** ✅ Implemented and tested — 📄 Contract-obligation-only (this phase guarantees the property; a later +phase consults it) — ⏳ Deferred (named target phase) — 🚫 Not built (permanent simplification, named reason). + +## 6.1 The body model + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| HTTP-36 / BODY-1 | MUST | Body produces bytes via a single write-to-sink operation; reports media type, content length (-1 unknown), replayability | ✅ | Task 2 (`Body` interface), Tasks 3/4/6 (five concrete variants). `writeTo` takes the platform `WritableStream`, which is what keeps all of `src/io/` `@internal` | +| BODY-2 | MUST | Composite replayability; declared length collapses to unknown if any part's is | ✅ | Task 6; both directions asserted, plus the `-1` collapse | +| HTTP-51 | SHOULD | One shared framing routine drives both the declared length and the emitted bytes | ✅ | Task 6 (`renderPartHeader` called by `computeContentLength` **and** `writeTo`), plus a `fast-check` property that declared length equals bytes written for arbitrary part sets. Header parameter values are quoted/escaped and CR/LF stripped; a second property asserts an arbitrary part name never injects extra framing CRLFs. The shared routine alone is **not** sufficient: it takes each part's own `contentLength` on trust, and `MultipartPart.body` is the `@public` `Body` interface, so a caller implementation can report one length and write another. `writeTo` therefore also verifies its total against the declared value — refusing an overrunning chunk before it reaches the sink, and raising inside the writer scope on a short total so the sink is aborted rather than cleanly closed. Both directions asserted | +| HTTP-51 (boundary) | SHOULD | Caller-supplied boundary validated | ✅ (grammar) / ⚠️ (non-appearance) | Task 6. RFC 2046 puts two duties on the sender: a `bchars`-valid delimiter, and one appearing in no part. The first is enforced (`MultipartBoundaryError`); the second **cannot** be checked here, because a `StreamBody` part's bytes do not exist until the write, and a partial scan would read as a complete guarantee. Mitigated by generating the boundary by default — 32 random characters from Web Crypto — and by documenting the obligation on both entry points. Ledgered | +| BODY-3 / HTTP-37 | MUST | Materialize-once; the consumed-once guard is checked before the first suspension point | ✅ | Task 4 (`StreamBody.#consumed`, set before the first `await`), Task 5 (`materialize` holds no state of its own — `writeTo` can be called directly, and the guard must cover that path too). Property test over N concurrent callers: exactly one drains, every other observes `ConsumedBodyError` | +| BODY-8 | MUST | Stream ownership stated per variant | ✅ | Task 4. `StreamBody.writeTo` never cancels the caller's stream — **on either path**. The declared-length path only `releaseLock()`s, and the unknown-length path passes `preventCancel: true`, because `pipeTo`'s default cancels the source when the destination fails. Both asserted with a `cancel` spy | +| BODY-9 | SHOULD | Mark/reset replay on a stream body | ✅ (bounded) | Task 4 — `StreamBody` is always single-use. Node's `ReadableStream` has no generic mark/reset; a caller wanting replay calls `materialize` or uses `byteArrayBody`. Ledgered | +| HTTP-39 / BODY-10 | MUST | Declared length verified; a stream that disagrees fails rather than sending a truncated or overrunning body | ✅ | Task 4's `#writeExactly`. The overrun check runs **before** the write, not after the loop: once a transport has stamped `Content-Length`, an extra byte is already on the socket and no thrown error can recall it. A short stream raises `EndOfStreamError(delivered, declared)` from inside the writer scope, so the sink is aborted rather than cleanly closed | +| HTTP-38 / BODY-35 | MUST | Replayability classified by source; form-urlencoded always replayable, `+` for space | ✅ | Task 3. Encoding routes through Phase 1's `QueryParams`, so the RFC 3986 rules are single-sourced and only the `%20`→`+` swap is local; a postcondition asserts no literal space survives | +| BODY-4, BODY-5 | MUST | Replayability and idempotency gate a re-send | 📄 | Contract-obligation-only. This phase guarantees `replayable` is correct; Phase 5's retry/redirect/auth steps consult it. No task builds consultation | +| HTTP-40 / BODY-11, BODY-12, BODY-13, BODY-36 | MUST | File-backed body | ⏳ Phase 8a | Needs `node:fs`, against core's zero-`node:`-import invariant. Resolved in Phase 8a's design as a structural recognition contract plus a `@dexpace/body-file` package. In the roadmap's Deferred Items Log | +| HTTP-2 / HTTP-3 | MUST | Builder-based models expose a pre-populated `newBuilder()`; never a public field-wise constructor | ✅ | Task 6 adds `MultipartBodyBuilder` with static and instance `newBuilder()`, copying the parts list so the builder never aliases the source. `HTTP-2` holds two ways: concrete body classes are exported **as types only** (never as values, so `new ByteArrayBody(...)` is unreachable), and `Response` — which *is* a value export — keeps its `private constructor` plus the `createResponse` friend hook. The committed API report is the mechanical gate for both | +| HTTP-1 / XCUT-15 | MUST | A constructed model cannot drift from what it emits | ✅ | Tasks 3/4/6 — every variant calls `freezeBody(this)` last, so `contentLength` cannot be reassigned after construction; `MultipartBody` additionally deep-copies its parts array. Asserted across all five variants | + +## 6.2 Response body + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| HTTP-41 / BODY-14 | MUST | Single-use body; repeat access returns the same reference, not a replay | ✅ | Task 8 — `Response.body` is a plain getter over a `#private` field, so this needs no separate guard | +| HTTP-41 / BODY-15, HTTP-43 | MUST | Idempotent close; releases the connection whether or not the body was read | ✅ | Task 8. Memoized on the close *promise*, not a boolean: a flag set before the `await` reports a failed release as success to every later caller. Tolerates a body locked by an external consumer, since `BODY-15` forbids assuming the body was read | +| HTTP-41 / BODY-16 | MUST | Convenience readers close in a finally-style guarantee | ✅ | Task 8. Two ordering constraints, both load-bearing and both asserted. `reader.releaseLock()` is the first statement of the `finally`, before the close: `cancel()` rejects with `TypeError` on a locked stream and reading to `{done: true}` does not release the lock, so the reverse order turns every successful read into a rejection. And the reader is acquired **inside** the try: `getReader()` itself throws when an external consumer already holds the lock — which `BODY-15` forbids assuming away — so acquiring it above the try skipped the close on exactly the path the guarantee most needs to cover | +| HTTP-42 | MUST | `text()` uses the declared charset, falling back to UTF-8 | ✅ | Task 8 via `http/charset.ts`. Falls back for an absent, unparseable, **and** unrecognized label; all three asserted | +| HTTP-44, HTTP-45 | MUST | Raw fields without touching the body; parse-once memoized including failure; concurrent first callers serialized | ✅ | Task 9. The promise is cached before the first `await`, and the parse is wrapped in an `async` IIFE so a parser that throws *synchronously* is memoized too — a bare `??=` would re-run the handler against a body whose bytes are already gone | + +## 6.3 Request-body logging + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| BODY-17 | MUST | Mirror into the tap AND forward the full untruncated payload | ✅ | Task 10, plus the property test that carries this phase's most important invariant: for arbitrary payloads and arbitrary caps, the primary receives the exact concatenation of every written byte. The adapter stream forwards **both** teardown paths — `close` and `abort` — so a delegate failure reaches the caller's sink rather than stopping at the decorator, and `writeTo`'s own `catch` releases the writer when a delegate refuses before touching the adapter at all | +| BODY-18 | MUST | The tap clears at the start of every write | ✅ | Task 10 — asserted across two writes of a replayable delegate, which is what a Phase 7 retry loop does | +| BODY-19 | MUST | Tap capacity cap; the full payload is unaffected by it | ✅ | Task 10; cap of 0, cap below payload, and cap above payload all asserted | +| BODY-20 | SHOULD | A partial failure still yields the bytes mirrored up to that point | ✅ | Task 10 — mirror-before-forward, so the chunk that failed is captured | +| BODY-21 | MUST | The materialized form stays wrapped and keeps its tap cap | ✅ | Task 10. `materialize()` is a member, not the free function, because the return type must stay `LoggedBody`. Each wrapper gets its **own** tap buffer rather than sharing one: two live wrappers over a single `ByteQueue` means the materialized wrapper's `BODY-18` clear-on-write silently rewrites the preview the pre-materialization wrapper still holds | +| BODY-37 | MUST | No writable-buffer escape hatch; `snapshot()` is the only read path | ✅ | Task 10 — the tap is closure-scoped, asserted absent from the wrapper's own keys. Restates `IO-28` at this layer | + +## 6.4 Response-body logging + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| BODY-22 | MUST | Lazy; the delegate is drained exactly once | ✅ | Task 11 — the drain is memoized on one in-flight promise. `snapshot()` is a trigger alongside `read()`, and being synchronous it starts the drain and returns what has been captured so far rather than awaiting it, which is what lets `BODY-26`'s "snapshot returns the partial bytes without throwing" hold | +| BODY-23 | MUST | Fits-cap: full capture, every later read a fresh non-consuming view | ✅ | Task 11 | +| BODY-24 | MUST | Exceeds-cap: prefix then live tail, exactly once; a second read fails | ✅ | Task 11. The tail is pull-driven, one chunk per `pull()` — an eager `start()` loop would materialize the whole remainder of exactly the oversized bodies the cap exists to keep off the heap | +| BODY-25 | MUST | A zero-byte delivery is never treated as end-of-stream | ✅ | Task 11. `ReadableStreamDefaultReader.read()` carries no requested count, so the clause has no *literal* analog — but the tolerant reading made the same upstream succeed or fail depending only on which wrapper it passed through, since `RetentionWindow` raises on the same input under the identically-worded `IO-17`. Enforced on **both** read paths, the drain and the exceeds-cap tail | +| BODY-26 | MUST | A drain failure is cached, not propagated-and-forgotten; partial capture is never presented as complete | ✅ | Task 11 — `read()` re-throws on every call, `snapshot()` returns the partial bytes without throwing, `error()` surfaces it **without triggering a drain**. All three asserted | +| BODY-27 | MUST | The delegate is closed at most once across every close path | ✅ | Task 11 — one close-once guard shared by the wrapper's own close and the one-shot tail's completion and cancel. The reader's lock is released before the cancel, same trap as `Response.bytes` | +| BODY-28 | MUST | The captured buffer survives close | ✅ | Task 11 — depends on `IO-42`'s explicit in-memory carve-out, which is why Phase 3a's `ByteQueue.close()` deliberately leaves its read surface usable | +| BODY-29 | SHOULD | Reported length is the captured size only when the whole body fit | ✅ | Task 11 — the delegate's declared length otherwise, since the capture is only a bounded prefix | + +## 6.5 Error bodies and caps + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| HTTP-52 / BODY-30 | MUST | 4xx/5xx buffered up to a fixed 1 MiB and re-served replayably; buffering inside the close-guaranteeing scope | ✅ | Task 12. The loop keeps draining past the cap so the connection is still released, and drops the excess | +| BODY-31 | MUST | Error statuses only; a non-error response is handed back with its body intact | ✅ | Task 12 — gated on `Status.isError` (`HTTP-11`'s 400–599 band), **not** a bare `code < 400`, which would sweep a non-standard 6xx that `HTTP-10` requires `Status.of` to accept into the error path and consume a body `BODY-31` says must be returned intact | +| BODY-32 | MUST | Every byte-capped capture operation validates its cap | ✅ | Tasks 10 and 11 — both `tapCapBytes` and `capBytes` reject a negative value and clamp to the platform max. An unvalidated negative cap makes `size < cap` permanently false and silently mirrors nothing. The capless-snapshot clause is inherited: every `snapshot()` here delegates to `ByteQueue.snapshot()`, which already raises `AllocationLimitError` over the platform max (`IO-9`) | +| BODY-33 | SHOULD | Non-consuming error-body preview | ✅ | Task 12 — served from the buffered copy, so it is repeatable; `null` for no body. Decodes with the response's declared charset falling back to UTF-8, and never raises a `RangeError` out of a method on an error object | +| BODY-34 | MUST | One shared preview-size cap | ✅ (parameter) / ⏳ Phase 7 (value) | Tasks 10 and 11 each take it as a parameter; Phase 7 supplies the single config value that feeds both when it wires a real `Logger`. `toHttpError`'s 1 MiB cap is explicitly **not** this cap — `HTTP-52` fixes its value, so it cannot be the configurable one | + +## Cross-cutting plan obligations + +| Obligation | Source | Status | Where | +|---|---|---|---| +| No runtime dependency added | `SEAM-1` | ✅ | `verify:seam-1`; `dependencies` stays `{}` | +| No `node:` import in core | `sdk-design/03` §3.1 | ✅ | `grep -rn "from 'node:" packages/core/src/` is empty. Web Crypto, `TextEncoder`/`TextDecoder` and Web Streams are platform globals | +| The published `.d.ts` compiles for a consumer | `NFR-10`; new `verify:consumer-types` gate | ✅ | Compiles a throwaway consumer against the built declarations using the `lib`/`target` read from `tsconfig.base.json`, with `types: []`. Added because a real defect cleared every other gate: `typecheck` passes on dev-only ambient globals, `build` emits regardless, `api` only compares a report, `lint:publish` checks resolution and export shape, and `verify:dual-consumption` runs `node`, not `tsc`. Verified to fail on the reintroduced defect | +| Nothing from `src/io/` enters the public surface | Phase 3a's open promotion question | ✅ | Answered by `writeTo` taking the platform `WritableStream`: `io/` stays `@internal` indefinitely. The API report carries no `ByteQueue`/`BufferedSource`/`BufferedSink`/`TeeSink`/`IoError`, and neither logging tee | +| `http/` does not import `io/` | Plan Global Constraints | ✅ | `body/` is `io/`'s only new consumer. Of the *stream-shaped* types the constraint names, it takes `ByteQueue` alone — never `BufferedSource`/`BufferedSink`/`TeeSink`, whose reader/writer-bound, `ByteQueue`-and-count-shaped signatures do not compose with `writeTo`'s chunk-shaped sink. It additionally imports two `io/` error leaves and `MAX_BYTE_ARRAY_LENGTH`, which the constraint does not restrict and which exist precisely to be reused rather than duplicated. `http/request.ts`'s `Body` import is type-only and erases | +| Every public symbol documented | CLAUDE.md; `api-extractor` | ✅ | `packages/core/etc/core.api.md` contains **zero** `(undocumented)` markers. This regressed to 62 during implementation — `Response`'s wholesale rewrite also dropped 11 of Phase 1's own TSDoc blocks — and is now mechanically clean | +| Property tests where invariants exist | styleguide 11.5 | ✅ | Task 5 (`materialize` concurrency), Task 6 (framing length ×1, header injection ×1), Task 10 (tap independence), Task 11 (two-regime completeness) | +| Assertion density | styleguide ch05 §5.7 | ✅ | `invariant` preconditions on both caps and on `contentLength`; postconditions in `materialize`, `computeContentLength`, `drainOnce`, `toHttpError`'s buffer loop, and the form encoder | +| Negative space and cleanup | styleguide 11.9, 13.9 | ✅ | Double-close on `Response` and the response wrapper; write-after-consumed; read-after-tail-consumed; a delegate failure aborting rather than closing the primary sink; a caller stream that must not be cancelled | +| Every test file cites its requirement IDs | Phase 1 convention, for Phase 9 | ✅ | Top-of-file comment in all ten `body/` test files, both modified `http/` ones, and the `io/` files this phase touched | +| SPDX header on line 1 | `NFR-13` | ✅ | Every file under `packages/core/src/`. `http/response.test.ts` lost it in Task 8's wholesale rewrite and has it back | +| 80% aggregate coverage floor | `NFR-5` | ✅ | Well above; `bun test` runs coverage by default | +| Changeset committed | Consumer-facing change | ✅ | `RequestBuilder.body` and `ResponseBuilder.body` both narrow from `unknown`, which `api-design.md` classes as breaking. Released as **minor** under semver's 0.x initial-development carve-out, with the pointer recorded — this is D1's decision, taken | + +## Deferred out of this phase + +| Item | Target | Note | +|---|---|---| +| `FileBody` (`HTTP-40`/`BODY-11`/`BODY-12`/`BODY-13`/`BODY-36`) | Phase 8a | Needs `node:fs`. Already in the roadmap's Deferred Items Log | +| `BODY-34`'s single shared preview-cap **value** | Phase 7 | This phase ships both tees' parameters; Phase 7 owns the `Logger`/config surface that threads one value through them | +| `BODY-4`/`BODY-5` replayability **consultation** | Phase 5 | Retry, redirect, and auth read the property this phase guarantees | +| Wiring either logging tee to a real `Logger` | Phase 7 | Mechanism ships now because the IDs are §6; nothing constructs one yet. Matches Phase 2 shipping `Serde`'s interface with no implementation | +| `[Symbol.asyncDispose]` on `Response`, `LoggedResponseBody`, `Transport`, and Phase 3a's four resource owners | Checkpoint §5.4 | Blocked on the `engines.node` floor bump. Must land on all seven at once — see the ledger row | +| Removing `DomainModelError` as a class tier | Checkpoint §5.2 | A breaking change to a barrel-exported class. `io/`'s leaves are already flat; the Phase 1 tier is the residual | diff --git a/docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md b/docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md index 1c9b16a..434319b 100644 --- a/docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md +++ b/docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md @@ -301,8 +301,8 @@ close to tolerate. Every other close failure propagates as before. | # | Sev | Finding | Where | Resolution | |---|---|---|---|---| -| D1 | major — **OPEN, needs a decision** | Task 13 Step 6 specifies a **minor** changeset on the reasoning that `Request.body`'s move from `unknown` to `Body \| undefined` is "not breaking for any real caller, since `unknown` accepted nothing usable before." That premise is false — `unknown` accepted *everything*, which is exactly why Task 7 Step 1 has to rewrite every `.body('x')` call site in the existing suite. `api-design.md:72` classes a narrowed parameter type as breaking, requiring MAJOR. `ResponseBuilder.body` narrows the same way | PLAN Task 13 Step 6; `api-design.md:72` | **Undecided.** Either (a) ship it as **major**, which is what the corpus rule says and what the plan now instructs by default, or (b) if `@dexpace/core` is still pre-1.0 and the repo's release policy treats 0.x breaks as minor, keep minor and record the policy pointer. The plan carries both branches with the false justification deleted; pick one before Task 13 runs. Settle once — Phases 4a/4b/5 narrow Phase-1 placeholder types the same way | -| D2 | major — **OPEN, blocked on unwritten code** | Three Phase-1/3a symbols the 3b plan now calls could not be verified: `MAX_ARRAY_BYTES` (assumed exported from `io/byte-queue.ts`, backing `AllocationLimitError`'s `limit` argument — used by both logging tees' `BODY-32` cap clamp), `Status.isError` (used by `toHttpError`'s `BODY-31` gate, replacing a `code < 400` that wrongly swept non-standard 6xx into the error path), and `Protocol.token` (used by `TypedResponse`). `packages/` does not exist on the planning branch, so none could be checked | PLAN Task 10, 11 (`MAX_ARRAY_BYTES`), Task 12 (`Status.isError`), Task 9 (`Protocol.token`) | **Not a design decision — a verification the executing agent must do first.** Task 11's Interfaces block carries a "Verify before writing" note. If a name differs, use the real one; do **not** add a second constant or a local `isError` helper. If `Status` genuinely has no `isError`, `HTTP-11`'s classification is itself a Phase-1 gap and the gate becomes `code >= 400 && code <= 599` pending that fix | +| D1 | major — **CLOSED (3b execution)** | Task 13 Step 6 specifies a **minor** changeset on the reasoning that `Request.body`'s move from `unknown` to `Body \| undefined` is "not breaking for any real caller, since `unknown` accepted nothing usable before." That premise is false — `unknown` accepted *everything*, which is exactly why Task 7 Step 1 has to rewrite every `.body('x')` call site in the existing suite. `api-design.md:72` classes a narrowed parameter type as breaking, requiring MAJOR. `ResponseBuilder.body` narrows the same way | PLAN Task 13 Step 6; `api-design.md:72` | **Resolved: branch (b), minor.** `@dexpace/core` is `0.0.0`, and semver's own initial-development carve-out () puts a 0.x breaking change out as minor; the pointer is recorded in the changeset itself, not only here. Revisit at 1.0, when the carve-out stops applying and Phases 4a/4b/5's identical narrowings become real majors. The alternatives were (a) ship it as **major**, which is what the corpus rule says and what the plan now instructs by default, or (b) if `@dexpace/core` is still pre-1.0 and the repo's release policy treats 0.x breaks as minor, keep minor and record the policy pointer. The plan carries both branches with the false justification deleted; pick one before Task 13 runs. Settle once — Phases 4a/4b/5 narrow Phase-1 placeholder types the same way | +| D2 | major — **CLOSED (3b execution)** | Three Phase-1/3a symbols the 3b plan now calls could not be verified: `MAX_ARRAY_BYTES` (assumed exported from `io/byte-queue.ts`, backing `AllocationLimitError`'s `limit` argument — used by both logging tees' `BODY-32` cap clamp), `Status.isError` (used by `toHttpError`'s `BODY-31` gate, replacing a `code < 400` that wrongly swept non-standard 6xx into the error path), and `Protocol.token` (used by `TypedResponse`). `packages/` does not exist on the planning branch, so none could be checked | PLAN Task 10, 11 (`MAX_ARRAY_BYTES`), Task 12 (`Status.isError`), Task 9 (`Protocol.token`) | **Verified against the real code.** All three exist and are used: the constant is `MAX_BYTE_ARRAY_LENGTH` in `io/limits.ts` (not `MAX_ARRAY_BYTES` in `io/byte-queue.ts` — the real name was used, no duplicate added), and `Status.isError` and `Protocol.token` are both present as assumed, so `HTTP-11`'s classification is not a Phase-1 gap. Original guidance, kept for the record: Task 11's Interfaces block carries a "Verify before writing" note. If a name differs, use the real one; do **not** add a second constant or a local `isError` helper. If `Status` genuinely has no `isError`, `HTTP-11`'s classification is itself a Phase-1 gap and the gate becomes `code >= 400 && code <= 599` pending that fix | **Applied without needing a decision** (recorded so the reasoning survives): `BODY-34`'s "one shared cap" contradiction resolved in the plan's favour — the shared preview cap covers the two logging tees, and @@ -324,6 +324,87 @@ framing length, `StreamBody`'s `contentLength`, `drainOnce`'s cap, and `toHttpEr and 4a still ship zero, so 4b's F2 remains open as a project-level question for Phase 10 — 3b is now a second data point alongside 4c that the rule is applicable, not just aspirational. +## Open Findings — Phase 3b Execution (2026-08-25, expanded 2026-08-26) + +Findings that surfaced only once Phase 3b's plan was actually executed, across three review passes. Nearly all +are **checkpoint-owned**, not 3b-owned: the 3b design took the checkpoint +(`plans/2026-07-25-checkpoint-scaffold-through-phase3a.md`) as a signed-off prerequisite, and it has not run. +Every box in that document is unchecked and no commit implements it. + +### Why nobody noticed: the checkpoint was cherry-picked, not skipped + +The more useful framing than "the checkpoint did not run" is that **parts of it did**, which is exactly what made +the 3b plan's prerequisite claim plausible to whoever wrote it. Measured status of every `§5` item as of +2026-08-26: + +| § | Item | Status | +|---|---|---| +| 5.1 | Coverage floor as a *blocking* gate | **Done** — `bunfig.toml` carries `coverage = true`, `coverageThreshold = 0.8` | +| 5.2 | Flatten the `DomainModelError` tier | **Open** — E2 below | +| 5.3 | Error leaves carry identifying `readonly` fields | **Partial** — 2 of 10; E3 below | +| 5.4 | `Symbol.asyncDispose` + floor bump + `lib` entry | **Open** — E1 below | +| 5.5 | Bounded collections vs `RetentionWindow`/tap | **No action needed** — confirmatory in the checkpoint itself | +| 5.6 | `AbortSignal.any` composition | **No action needed** — confirmatory | +| 5.7 | Flat hoisting lets a package resolve an undeclared dependency | **Open** — E4 below | +| 5.8 | `NFR-14`'s stale "no direct Bun equivalent" reason | **Open** — E7 below | +| 5.9 | `bun test` proves nothing about the Node runtime | **Open** — E5 below, the largest | +| 5.10 | Per-class `#private` justification comments | **Open** — E6 below | +| 5.11 | Phase 4 pre-commitment: `Stage` must not be an `enum` | Not yet due (Phase 4) | +| 5.12 | Tooling conflicts already resolved by the plans | Recorded only | + +Partial application is worse here than none at all. `§5.1` is visible in `bunfig.toml` and half of `§5.3` is +visible in `errors.ts`, so a reader checking whether the checkpoint had landed would have found evidence that it +had. **Verify a prerequisite against the artifact it was supposed to produce, not against a spot check.** + +| # | Sev | Finding | Where | Resolution | +|---|---|---|---|---| +| E1 | **blocker — CLOSED in 3b, reopened against checkpoint §5.4** | 3b shipped `[Symbol.asyncDispose]` on `Response` and `LoggedResponseBody` on the strength of the design's claim that "the floor is bumped and `lib` extended before 3b starts". Neither happened: `engines.node` is still `">=18.17"` and `lib` is `["ES2022", "DOM", "DOM.AsyncIterable"]`. Two consequences, both real: below Node 18.18 the computed key evaluates to `undefined` and binds the method to the string `"undefined"`; and the symbol's *type* reaches the package only through a dev-only global, so a consumer compiling against the published `.d.ts` on this repo's own declared `lib` fails with `TS2550: Property 'asyncDispose' does not exist on type 'SymbolConstructor'`. No gate covered it — `verify:dual-consumption` runs `node`, not `tsc` | `packages/core/package.json`; `tsconfig.base.json`; 3b design §"Response Body" | **3b reverted to `close()`-only**, matching the decision Phase 3a shipped and every other resource owner still carries, with both classes now asserting the symbol's *absence* so it cannot be reintroduced ahead of the floor. Re-adding it is checkpoint §5.4's job and must land on all seven owners at once — `Transport`, `ByteQueue`, `BufferedSource`, `BufferedSink`, `RetentionWindow`, `Response`, `LoggedResponseBody`. **Version numbers now verified**, discharging §5.4's own "verify against the actual Node release notes before writing the number" instruction: `Symbol.dispose`/`Symbol.asyncDispose` first shipped in **Node 18.18.0**, backported to **20.4.0** — symbols only, not the `using` syntax. So §5.4's "believed 18.18.0" was right and the bump really is patch-level: `>=18.17` → `>=18.18.0`. **Note for 4b's F1:** that finding assumed the floor had already been "raised at most to `18.18.0` at the 2026-07-25 checkpoint" and that `esnext.disposable` was in `lib`. Neither premise holds — see F1's own amended row | +| E2 | major — **OPEN, checkpoint §5.2** | 3b's Task 1 flattened `io/`'s four error leaves off `IoError` on the stated basis that checkpoint §5.2 had already flattened Phase 1's `DomainModelError` tier. It had not, so the taxonomy is now *mixed*: `DexpaceError → EndOfStreamError` is two levels while `DexpaceError → DomainModelError → RequiredFieldError` is still three | `packages/core/src/http/errors.ts`; 3b design §"Error Tree" | **Deliberately not fixed in 3b.** Removing `DomainModelError` deletes a class exported from the public barrel that consumers can `instanceof` — a breaking API change belonging to the checkpoint. The residual is strictly smaller than what preceded it (`io/` no longer adds a second independent violation) and is recorded in 3b's ledger and checklist. **Blast radius, measured:** ten leaves extend it — `RequiredFieldError`, `HeaderValidationError`, `MediaTypeParseError`, `ProtocolParseError`, `UrlConstructionError`, `RequestOptionsValidationError`, `EtagParseError`, `HttpRangeValidationError`, `RequestConditionsValidationError`, `RequestBodyNotAllowedError` — all in one file, and `DomainModelError` itself is a runtime value export, so `instanceof` narrowing on it is live public API. §5.2 pre-specifies the replacement (an exported `isDomainModelError` type-guard union, never a re-subclass), and 3b already proved that pattern twice in-tree with `isIoError` and `isBodyError`. **Sequencing:** §5.2's own note — "Phase 4's error families then land as leaves on `DexpaceError` too, which is what keeps the flattening from being undone one phase later". **Ten queued phases introduce new SDK error types** — 4a (`DuplicateContextKeyError`), 4c (five, including `PillarCollisionError`, `CrossStageEditError`, `ReservedStageError`), 5b (`NonReplayableBodyError`, `SchemeDowngradeError`), 5c (`AuthResolutionError`, `PlaintextCredentialError`, `DigestChallengeUnsupportedError`), 6a (`SerdeError`, `SerializationError`, `DeserializationError`), 6b (`SseStreamError`, `SseLineTooLongError`), 6c (`PaginationError`), 8a (`TransportFailureError`), and 5a/8b, which reuse rather than define. Counted from the phase design docs 2026-08-26; 4b and 7a/7b define none. Every one of those that ships before the flatten is another tier decision taken against the wrong parent. Owned by checkpoint §5.2 | +| E3 | major — **OPEN, checkpoint §5.3** | §5.3 requires every error subclass to carry its identifying inputs as sanitized `readonly` fields, because `JSON.stringify(error)` and structured-log field enumeration bypass `.message` entirely. It was applied to **two** leaves and stopped: `RequiredFieldError` carries `fieldName`, `HeaderValidationError` carries `kind` + `escapedName`. The other **eight** carry nothing — their identifying data exists only interpolated into the message string, which is precisely the shape the rule forbids. Not raised by any of Phase 3b's three review passes either; found only when the checkpoint was audited item by item | `packages/core/src/http/errors.ts` | **Open.** Same file and same ten classes as E2, so doing §5.2 and §5.3 in one pass is strictly cheaper than two. §5.3 also specifies the sanitization shape per leaf: the offending *name* control-character-escaped, the offending *value* never stored raw (a `valueLength`, a masked minimum fragment, or no field at all), and for `MediaTypeParseError` the failing token/offset rather than the full input. It further asks for a file comment on `errors.ts` recording *why* fields are sanitized at construction — that comment is what stops a later contributor "restoring" the raw value | +| E4 | major — **OPEN, checkpoint §5.7** | No isolated linker is configured. `bunfig.toml` carries only a `[test]` block and there is no `.npmrc` at all, so the install is flat-hoisted by default. Under flat hoisting `@dexpace/core` can import a package it never declared and still pass every gate — including `verify:seam-1`, which reads the `dependencies` map rather than what the code actually resolves. That is the one phantom-dependency failure mode `SEAM-1`'s gate structurally cannot see | `bunfig.toml` (no linker key); no `.npmrc`; `scripts/verify-seam-1.mjs` | **Open.** §5.7 requires confirming the exact linker option against the pinned Bun version before writing it. Low effort, and it strengthens a `SEAM-1` guarantee the project treats as foundational | +| E5 | **blocker — OPEN, checkpoint §5.9** | The largest gap, and it has already bitten: **no `test:node` script exists**, yet the 3b plan's Task 13 Step 3 gate sequence calls `bun run test:node` — so that plan cannot be executed as written. `node-floor-conformance` still pins `18.17.0` alone, so current LTS is never exercised, directly contradicting the "in addition to current LTS" half of the rule. All 516 tests run on Bun | `.github/workflows/ci.yml`; root `package.json` scripts; 3b plan Task 13 Step 3 | **Open, and decaying with every phase.** For this codebase specifically: Bun's Web Streams, `AbortSignal` and async-iteration are independent implementations of Node's, and `io/` — chunk boundaries, backpressure timing, microtask ordering — is exactly where they diverge. The `no node: imports` grep proves runtime-*agnostic imports*, a far weaker claim than runtime-*correct on Node*. §5.9 explicitly **rejects** the obvious fix of moving to `vitest`/`node:test`: `docs/knowledge/testing.md` mandates `bun:test` symbol imports, `setSystemTime`, and `--concurrent`, so swapping runners is a styleguide-chapter deviation plus a whole-suite rewrite that buys nothing for the pure-logic majority | +| E6 | minor — **OPEN, checkpoint §5.10** | §5.10 ratifies the `#private` *choice* for wire-model classes but calls the missing per-declaration justification "a real, uncorrected gap" — the corpus wants the reason where a reader meets the field, not in a plan document they will never open. **None** of the eleven `packages/core/src/http/` model files carries one. Measured 2026-08-26 by grepping for a comment naming runtime privacy or citing `HTTP-1`/`SEAM-29` near a `#private` declaration: four files matched and all four were false positives — unrelated `HTTP-10`/`HTTP-11`/`HTTP-13`/`HTTP-18` requirement citations in ordinary TSDoc | `packages/core/src/http/*.ts` | **Open.** One short comment per declaring class (not per field), naming the runtime-privacy requirement and citing `HTTP-1`/`SEAM-29`. §5.10 also asks that the `http-domain-model.md` conflict entry then be resolved as a carve-out **scoped to wire-model classes only**, so it cannot read as blanket permission for `#private` elsewhere | +| E7 | minor — **OPEN, checkpoint §5.8** | The scaffold checklist defers `NFR-14` on the reasoning that pnpm's `catalog:` protocol "has no direct Bun equivalent". Bun has since added workspace catalogs. The *conclusion* (defer to Phase 8) is still right — with one package there is nothing to deduplicate — but the stated reason is wrong, and §5.8's point is that a wrong reason is worse than an open item: at Phase 8 someone reads "no Bun equivalent" and either hand-syncs versions or reopens the pnpm decision | `plans/2026-07-23-scaffold-milestone-checklist.md:45`; two `docs/knowledge` lines | **Open.** Correct the reason, keep the ⏳ status and the Phase 8 target. §5.8 requires confirming the catalog schema against the pinned Bun version before writing any of it | + +### Suggested order + +**Before Phase 4 starts:** + +1. **E2 + E3 together**, in one pass over `packages/core/src/http/errors.ts`. Same ten classes, same file, and + E2's sequencing argument means every phase that ships first adds leaves to a tier that is about to be removed. +2. **E1** (§5.4's three parts, which do not work separately). Cheaper now than when the checkpoint was written: + the new `verify:consumer-types` gate mechanically proves a `lib` entry that is declared but whose floor was + not raised, and proves the reverse too. +3. **Read 4b's F1 before designing against it** — amended 2026-08-26 with the verified `SuppressedError` + version facts, which resolve it to branch (b). See "F1 resolution — the verified version facts" under + "Open Findings — Phase 4b Validation Review" further down this document. That amendment changes 4b's design + input, not just its wording, and F1 already notes the resolution has to land in 5a, 6b and 6c at the same + time. + +**Not blocking Phase 4, ordered by how fast they decay:** E5 (grows with every phase that adds Node-divergent +surface — Phase 4c's pipelines and Phase 8's transports most of all), then E4, E6, E7. + +### Phase-3-owned residuals + +Distinct from the checkpoint items above: these belong to Phase 3 itself and are recorded in its ledger and +checklist rather than being anyone else's to close. + +| Item | Level | Disposition | +|---|---|---| +| Multipart boundary **non-appearance** in part content | `HTTP-51`, ⚠️ partial | RFC 2046 puts two duties on the sender; only the `bchars` grammar half is checkable here, because a `StreamBody` part's bytes do not exist until the write and a partial scan would read as a complete guarantee. Mitigated by generating a 32-character Web Crypto boundary by default and documenting the obligation on both caller-supplied entry points. Revisit only if demand for caller-chosen boundaries appears | +| `StreamBody` always single-use, no mark/reset | `BODY-9` (SHOULD), bounded | Node's `ReadableStream` has no generic mark/reset. Closes only if the platform gains one | +| `BODY-34`'s shared preview-cap **value** | ⏳ Phase 7 | Both tees take the parameter today; Phase 7 owns the `Logger`/config surface that threads one value through them | +| `BODY-4`/`BODY-5` replayability **consultation** | ⏳ Phase 5 | Phase 3 guarantees the property is correct; retry/redirect/auth consult it | +| `FileBody` (`HTTP-40`/`BODY-11`/`12`/`13`/`36`) | ⏳ Phase 8a | Already resolved in 8a's design as `@dexpace/body-file` plus a structural `Body.kind === 'file'` contract | +| Both logging tees unwired to any `Logger` | ⏳ Phase 7 | Mechanism ships now because the IDs are `§6`; nothing constructs one yet. Matches Phase 2 shipping `Serde` with no implementation | + +Also worth carrying forward, since three separate defects in 3b traced to the same root: **a `Body`/sink decorator +must forward BOTH teardown paths.** A `WritableStream` adapter that declares `write` and `close` but no `abort` +silently swallows the delegate's abort — the default abort algorithm is a no-op — leaving the real sink open and +locked and letting a truncated body be committed downstream as a complete one. Likewise `pipeTo`'s default +`preventCancel: false` cancels the *source* when the destination fails, which takes cancellation ownership away +from the caller (`BODY-8`). Phase 4c's stage pipeline and Phase 8a's transports both wrap sinks; both inherit this. + ## Open Findings — Phase 4b Validation Review (2026-07-28) A validation pass over `specs/2026-07-25-phase4b-recovery-chain-design.md` and @@ -345,7 +426,7 @@ the same false premise, so whichever resolution lands has to land in all four at | # | Sev | Finding | Where | Resolution | |---|---|---|---|---| -| F1 | **blocker** — OPEN | `SuppressedError` does not exist on the declared runtime floor. `engines.node` is `">=18.17"`, raised at most to `18.18.0` at the 2026-07-25 checkpoint (which exposes `Symbol.dispose`/`Symbol.asyncDispose` only — Node backported those two symbols; `SuppressedError` is a V8 global from the full Explicit Resource Management proposal). `esnext.disposable` in `lib` supplies its *type*, so `new SuppressedError(...)` type-checks and then throws `ReferenceError` at call time — the exact `NFR-10` trap `tooling-and-quality-gates.md:60-61` describes. `bun test` passes locally; the `node-floor-conformance` job pinned to `18.17.0`, `verify:node-floor` and `test:node` all fail | PLAN:19-20 (Tech Stack, claims it is "already available since Phase 3b's checkpoint lib bump" — false), PLAN:804, SPEC:124; also 5a plan:36, 6b design:163, 6c design:192 | **Undecided.** Either (a) raise `engines.node` past the first release shipping Explicit Resource Management — a consumer-visible breaking change, and the checkpoint at plan:57 forbids unsanctioned floor moves — or (b) a runtime-guarded `suppress(primary, secondary)` helper in `packages/core/src/` using native `SuppressedError` when `globalThis.SuppressedError` exists and attaching a `suppressed` property otherwise, matching the guarded shape already sanctioned for `Symbol.asyncDispose`. Confirm the first supporting Node release before choosing (a). **Partially applied 2026-07-28:** the false Tech Stack claim is deleted and replaced with a blocking notice at the top of the plan stating the real constraint; the mechanism itself is untouched pending the (a)/(b) call | +| F1 | **blocker** — OPEN | `SuppressedError` does not exist on the declared runtime floor. `engines.node` is `">=18.17"`, raised at most to `18.18.0` at the 2026-07-25 checkpoint (which exposes `Symbol.dispose`/`Symbol.asyncDispose` only — Node backported those two symbols; `SuppressedError` is a V8 global from the full Explicit Resource Management proposal). `esnext.disposable` in `lib` supplies its *type*, so `new SuppressedError(...)` type-checks and then throws `ReferenceError` at call time — the exact `NFR-10` trap `tooling-and-quality-gates.md:60-61` describes. `bun test` passes locally; the `node-floor-conformance` job pinned to `18.17.0`, `verify:node-floor` and `test:node` all fail | PLAN:19-20 (Tech Stack, claims it is "already available since Phase 3b's checkpoint lib bump" — false), PLAN:804, SPEC:124; also 5a plan:36, 6b design:163, 6c design:192 | **Resolved 2026-08-26: take branch (b)** — the runtime-guarded `suppress()` helper. The "confirm the first supporting Node release" condition this row left open is now discharged, and it settles the choice rather than merely informing it; two of this row's own premises also turn out to be false. See "F1 resolution — the verified version facts" below the table. **Partially applied 2026-07-28:** the false Tech Stack claim is deleted and replaced with a blocking notice at the top of the plan stating the real constraint; the mechanism itself is untouched pending implementation of (b) | | F2 | major — OPEN | Zero assertions across the whole `recovery/` module — a dozen functions, no `invariant()` call, against `assertions.md:6-7`'s 2-per-function module average (and `styleguide-overview.md:22-23` Rule 8). Neither document acknowledges the rule or argues an exemption. Concretely: no `apply()` checks that a step returned a value at all, so a step returning `undefined` poisons the fold silently. Project-wide inconsistency, not 4b's alone — Phases 1/2/3b/4a ship zero, 4c ships fifteen | PLAN:463-479, 818-859, 964-966, 1352-1370 | **Undecided.** Either postcondition assertions at the fold sites, or a Deviation Ledger row. Worth settling at the project level (Phase 10) rather than per-phase | | F3 | major — ✅ applied | SPEC:270 still says "the only new failure surface is `wrapCancellation()`'s `invariant()` crash" — stale text from a superseded draft. SPEC:194-204, SPEC:279 and PLAN:63-74 all state the opposite. An agent executing from the File Layout section would restore the `invariant()`, and because the helper runs inside `dispatchWithRecovery`'s own `catch`, that throw bypasses the response and recovery chains — the one failure mode `RECOV-2` exists to prevent | SPEC:270-271 | Replace with `assertNever`'s `InvariantViolation` crash, matching the already-correct PLAN:89-90 | | F4 | minor — ✅ applied | Spec never designs the `assertNever` addition Task 1 builds. PLAN modifies `packages/core/src/invariant.ts` (new exported symbol, two tests, its own commit); SPEC's File Layout lists only `recovery/` | SPEC:258-268 vs PLAN:102-103, 124-197 | Add the `invariant.ts` line to the spec's File Layout with a one-line note that `fold()` is the codebase's first discriminated-union `switch` | @@ -356,6 +437,33 @@ the same false premise, so whichever resolution lands has to land in all four at | F9 | minor — ✅ applied | `fold(outcome, onSuccess, onFailure)` takes three positional parameters, tripping `function-design.md:22-23` ("options object at 3 or more"), which is one stricter than the lint gate (`max-params: ['error', 3]` errors at four). Passes CI while violating the corpus. Phase 2's shipped `Transport.send(request, options?, signal?)` is the same shape | SPEC:36, PLAN:320 | Ledger row recording it as deliberate (matching `Transport.send`), or `fold(outcome, {onSuccess, onFailure})`. See the corpus conflict below | | F10 | minor — ✅ applied | `statusMappingStep` is a module-level `const` arrow, against `function-design.md:18-21` ("top-level named `function` declarations… arrows are reserved for inline callbacks"). `func-style`'s `allowArrowFunctions: true` will not catch it, and named declarations survive in stack traces — which matters for a function whose whole job is to `throw` | SPEC:227, PLAN:1081 | `export async function statusMappingStep(...)` plus `statusMappingStep satisfies ResponseStep` to keep the conformance check | +### F1 resolution — the verified version facts + +Two of F1's premises are false, and the second changes which branch is affordable. + +**1. The floor was never raised.** F1 assumed `engines.node` had been "raised at most to `18.18.0` at the +2026-07-25 checkpoint" and that `esnext.disposable` was in `lib`. The checkpoint has not run at all — see +"Open Findings — Phase 3b Execution", finding E1. `engines.node` is still `">=18.17"` and `lib` is +`["ES2022", "DOM", "DOM.AsyncIterable"]`. + +**2. `SuppressedError` needs a far higher floor than `Symbol.asyncDispose`.** These are not the same bump, and +F1 treats them as comparable. Node backported the `Symbol.dispose`/`Symbol.asyncDispose` *symbols alone* in +**18.18.0** and **20.4.0**. `SuppressedError` belongs to the full Explicit Resource Management proposal, which +shipped in **V8 13.8 / Chromium 134** and reached Node only in **24.0.0**. So F1's branch (a) — "raise +`engines.node` past the first release shipping Explicit Resource Management" — is not a patch bump from 18.18. +It means `>=24.0.0`, **dropping Node 18, 20 and 22 outright**, which is disproportionate to the need and is +exactly the kind of unsanctioned floor move the checkpoint at plan:57 forbids. + +Branch **(b)** therefore wins on cost rather than as a compromise: a runtime-guarded +`suppress(primary, secondary)` helper in `packages/core/src/`, using native `SuppressedError` when +`globalThis.SuppressedError` exists and attaching a `suppressed` property otherwise. + +**A third point that must not be lost when E1 lands.** `esnext.disposable` in `lib` supplies +`Symbol.asyncDispose`'s *type*; it does **not** supply `SuppressedError`'s *runtime*. The +type-checks-then-throws-`ReferenceError` trap F1 describes therefore survives E1's floor bump intact. Adding the +`lib` entry is not a fix for F1 and must not be read as one — including by Phases 5a, 6b and 6c, which reach for +native `SuppressedError` on the same false premise and which F1 already notes must be resolved together. + **Corpus conflict surfaced, not a finding.** `function-design.md:22-23` requires an options object at 3+ parameters while `function-design.md:40-41` sets `max-params: ['error', 3]`, which errors only at four — the prose is one parameter stricter than its own stated enforcement. F9 is filed against the prose; if the lint threshold is the diff --git a/docs/superpowers/specs/2026-07-25-phase3b-body-lifecycle-design.md b/docs/superpowers/specs/2026-07-25-phase3b-body-lifecycle-design.md index 50715fc..c0e6bb4 100644 --- a/docs/superpowers/specs/2026-07-25-phase3b-body-lifecycle-design.md +++ b/docs/superpowers/specs/2026-07-25-phase3b-body-lifecycle-design.md @@ -147,17 +147,30 @@ class Response { readonly body: ReadableStream | null; // single-use (BODY-14) text(): Promise; // BODY-16, HTTP-42 bytes(): Promise; // BODY-16 - close(): Promise; // BODY-15, HTTP-43 - [Symbol.asyncDispose](): Promise; // delegates to close() — checkpoint §5.4's now-bumped floor applies + close(): Promise; // BODY-15, HTTP-43 — the only teardown interface; see below } ``` -`Response` owns a resource (the body's connection) it releases via `close()`, so the checkpoint's §5.4 fix — already a -prerequisite of this phase, floor bumped and `lib` extended before 3b starts — applies here too: -`[Symbol.asyncDispose]` is the primary teardown interface, `close()` a retained delegate, so `await using response = -...` works. The response-body logging wrapper (below) gets the same treatment for the same reason; `Body` itself -does not, since no variant in this phase owns a closeable resource it must release (`StreamBody` is explicitly -caller-owned, per `BODY-8`). +**Teardown is `close()` only — no `[Symbol.asyncDispose]`.** This design was written assuming the checkpoint's §5.4 +fix (floor bump to the first Node release exposing `Symbol.dispose`/`Symbol.asyncDispose`, plus the matching `lib` +entry) had already landed as a prerequisite. It had not: at implementation time `engines.node` was still `">=18.17"` +and `tsconfig.base.json`'s `lib` was `["ES2022", "DOM", "DOM.AsyncIterable"]`, with none of Phase 2's `Transport` or +Phase 3a's `ByteQueue`/`BufferedSource`/`BufferedSink`/`RetentionWindow` carrying the symbol. Shipping it on +`Response` alone would have meant: + +- **A run-time trap on the declared floor.** `Symbol.asyncDispose` evaluates to `undefined` below Node 18.18, so the + computed key binds the method to the string `"undefined"` — precisely the failure Phase 3a's design named when it + declined the symbol, and it fails silently at run time, not at build time. +- **A broken published `.d.ts`.** The symbol's *type* reaches this package only through a dev-only global. A consumer + compiling against the built package on the same `lib` this repo declares gets + `TS2550: Property 'asyncDispose' does not exist on type 'SymbolConstructor'`. No gate covers this — + `verify:dual-consumption` runs `node`, not `tsc`. +- **An inconsistent taxonomy.** Two of seven resource-owning classes would have it and five would not. + +So this phase keeps Phase 3a's shipped decision, and both `Response` and the response-body logging wrapper assert the +absence rather than leaving it implicit. Adding it back is checkpoint §5.4's job, in one pass across all seven owners, +once the floor actually moves. Ledgered below. `Body` itself never needed it: no variant in this phase owns a +closeable resource (`StreamBody` is explicitly caller-owned, per `BODY-8`). Mirrors the `writeTo` decision: the public surface is the platform `ReadableStream`, not an internal wrapper. `text()`/`bytes()` drain the reader with a plain manual chunk-accumulate-and-concatenate loop — **no `io/` @@ -250,7 +263,7 @@ way to read the tap. **Response-body logging wrapper (`BODY-22`–`29`):** ```typescript -interface LoggedResponseBody extends AsyncDisposable { +interface LoggedResponseBody { // close() only — same reason as Response, above read(): Promise>; snapshot(): Uint8Array; // non-consuming; partial bytes even after a failed drain (BODY-26) error(): Error | null; // the cached drain failure, WITHOUT triggering a drain (BODY-26) @@ -318,7 +331,8 @@ third instance of the same violation: ``` DexpaceError (Phase 2 root) -├── RequiredFieldError, HeaderValidationError, … (Phase 1, flattened per checkpoint §5.2) +├── DomainModelError (Phase 1 — checkpoint §5.2 has NOT run; still a +│ └── RequiredFieldError, HeaderValidationError, … class tier. See the note below the diagram) ├── CancellationError, OperationAssemblyError (Phase 2, already flat) ├── IoError (Phase 3a — unchanged; already a flat leaf, used bare │ at 4 sites in buffered-source/-sink.ts, tee-sink.ts @@ -332,6 +346,19 @@ DexpaceError (Phase 2 root) └── HttpStatusError (3b, new — BODY-30/31) ``` +**The Phase 1 tier is still three deep.** This section assumed checkpoint §5.2 had already removed +`DomainModelError` as a class tier. It has not run, so after this phase's retrofit the taxonomy is *mixed*: +`DexpaceError → EndOfStreamError` is two levels while `DexpaceError → DomainModelError → RequiredFieldError` is +still three. That is strictly better than before — the `io/` leaves no longer add a *second* independent violation — +but it is a real residual, and it is deliberately **not** fixed here: removing `DomainModelError` deletes a class +exported from the public barrel that consumers can `instanceof`, which is a breaking API change belonging to +checkpoint §5.2, not to a body-lifecycle phase. Ledgered below and owned by the checkpoint (roadmap finding E2). + +A second checkpoint item lives in the same file and should be done in the same pass: §5.3 requires every error +leaf to carry its identifying inputs as sanitized `readonly` fields, and it was applied to two of the ten Phase 1 +leaves and stopped (roadmap finding E3). Whoever opens `http/errors.ts` for the flattening is already touching +every class E3 names. + Grouping is restored the way the checkpoint prescribed, and lands on the lighter of its two sanctioned options: an exported type-guard union per category (`isIoError(e): e is IoError | EndOfStreamError | ...`, `isBodyError(e): e is ConsumedBodyError | MultipartBoundaryError`) rather than a `kind` discriminant field on `DexpaceError` — the @@ -348,10 +375,19 @@ Phase 3a's design doc left open whether `§5` would be promoted to the public ba What *does* go public, for the first time since Phase 2: the `Body` interface, the *types* of its concrete variants and their factory functions, `MultipartPart`, `MultipartBodyBuilder`, `materialize`, `TypedResponse`, -`HttpStatusError`/`toHttpError`, and the two new error leaves a caller can actually trigger and -needs to catch — `ConsumedBodyError` (double-write on a single-use body) and `MultipartBoundaryError` (an invalid -caller-supplied boundary) — matching Phase 1's precedent that domain-model validation errors are public, not -internal. The logging tees and `toHttpError`'s internals stay `@internal` (unwired until Phase 7). +`HttpStatusError`/`toHttpError`, and the error leaves a caller can actually trigger and +needs to catch — `ConsumedBodyError` (double-write on a single-use body), `MultipartBoundaryError` (an invalid +caller-supplied boundary), and `FormBodyValidationError` (a form field that cannot be rendered) — matching Phase 1's +precedent that domain-model validation errors are public, not internal. `formUrlEncodedBody`'s input widened past +this design's `ReadonlyMap` during implementation, which brings `FormUrlEncodedInput` and +`FormUrlEncodedValue` public alongside it; both are ledgered below. + +**Every public symbol carries a TSDoc block**, including each member of each exported class and interface. +`api-extractor` records an undocumented reachable member as `(undocumented)` in the committed report, so the +enforcement point is mechanical: `packages/core/etc/core.api.md` must contain zero occurrences of that marker, and +`bun run api` fails CI on any drift. It is also the gate for `HTTP-2`: a `constructor(...)` line appearing under a +class the barrel exports **as a value** means a public field-wise constructor escaped the builder, and the private +constructor plus its `createX` friend hook is what keeps it out. The logging tees and `toHttpError`'s internals stay `@internal` (unwired until Phase 7). **Two consequences a barrel edit alone won't enforce.** First, the concrete body *classes* are exported as types only, never as values: exporting the class exposes `new ByteArrayBody(...)` as a public field-wise constructor, @@ -379,14 +415,24 @@ packages/core/src/body/ request-body-logging.ts # withRequestLogging tee decorator (@internal) response-body-logging.ts # response-body logging wrapper, two regimes (@internal) http-status-error.ts # HttpStatusError, toHttpError() - errors.ts # ConsumedBodyError, MultipartBoundaryError + errors.ts # ConsumedBodyError, MultipartBoundaryError, FormBodyValidationError + write-body.ts # withBodyWriter — the one writer scope every variant shares + media-type-safety.ts # header-safety validation for a Body's media type + freeze-body.ts # freezeBody — HTTP-1's freeze, single-sourced index.ts # barrel — Body/variants/factories/TypedResponse/HttpStatusError/ConsumedBodyError/ - # MultipartBoundaryError re-exported from src/index.ts; logging tees stay internal-only + # MultipartBoundaryError/FormBodyValidationError re-exported from src/index.ts; + # logging tees stay internal-only ``` +The last three files are not in this design's original plan; each earned its place during implementation and is +ledgered below. They exist as named modules rather than inlined code for the same reason `io/limits.ts`'s +`assertAllocatable` does: each encodes a rule applied at four or five call sites, and a rule applied in five shapes +is a rule that drifts. + Also modifies (not creates): `packages/core/src/http/request.ts` and `response.ts` (real body types replace -Phase 1's `unknown` placeholder; `Response` gains `text()`/`bytes()`/`close()`), and `packages/core/src/io/errors.ts` -(the flattening retrofit above). +Phase 1's `unknown` placeholder; `Response` gains `text()`/`bytes()`/`close()`), a new +`packages/core/src/http/charset.ts` (`HTTP-42`'s charset resolution, shared by `Response.text()` and +`HttpStatusError.preview()`), and `packages/core/src/io/errors.ts` (the flattening retrofit above). ## Deviation Ledger (for Phase 10) @@ -400,6 +446,24 @@ Phase 1's `unknown` placeholder; `Response` gains `text()`/`bytes()`/`close()`), | Logging tees and `toHttpError`'s preview machinery shipped `@internal`, unwired to any `Logger` | none — matches Phase 2's `Serde` precedent | No `Logger`/config surface exists until Phase 7 | | `BODY-34`'s shared preview cap covers the two logging tees only, not `toHttpError` | `BODY-34` (MUST), read literally as "all three" | `HTTP-52` *fixes* the error-body cap at 1 MiB, so it cannot also be the configurable shared value. The two capture sites `BODY-34` actually names — request-side tee and response-side drain — do share one cap | | Concrete `Body` classes exported from the public barrel as types only, never as values | none — required by `HTTP-2` | Exporting the class as a value publishes a field-wise constructor, which `HTTP-2` forbids; the factory functions are the sanctioned construction path and the classes remain usable as type annotations | +| `close()` only, no `[Symbol.asyncDispose]`, on `Response` and `LoggedResponseBody` | this design's own §"Response Body", which assumed checkpoint §5.4 had landed | The checkpoint has not run: `engines.node` is still `">=18.17"` and `lib` carries no `esnext.disposable`. Below Node 18.18 the computed key evaluates to `undefined` and binds the method to the string `"undefined"`; and the symbol's type reaches this package only through a dev-only global, so a consumer compiling against the published `.d.ts` on this repo's own declared `lib` fails with `TS2550`. Two of seven resource owners would have carried it. Matches Phase 3a's shipped decision; **owned by checkpoint §5.4**, to be added to all seven at once | +| Phase 1's `DomainModelError` tier left three-deep while `io/`'s leaves are flattened | checkpoint §5.2, which this design's error-tree diagram assumed had already run | Removing `DomainModelError` deletes a barrel-exported class consumers can `instanceof` — a breaking API change that belongs to the checkpoint, not to a body phase. The residual is a *mixed* taxonomy, strictly better than the two independent violations that preceded it. **Owned by checkpoint §5.2** | +| `write-body.ts` — one shared `withBodyWriter` scope for all five variants | this design's File Layout, which had each variant close its own sink | The naive `try { … } finally { await writer.close() }` is wrong twice: closing an already-errored writer rejects with `TypeError`, and a throwing `finally` *replaces* the in-flight exception, destroying the real cause (`RECOV-12`) — which also declassifies the failure for `RETRY-2`'s cause-chain walk. Aborting rather than closing on failure additionally tells the transport the message is broken, where a clean close would signal a complete body that was never written | +| `media-type-safety.ts` — `Body.mediaType` validated as header-safe at construction | none — closes a `HTTP-51` header-injection hole this design did not anticipate | `mediaType` is interpolated verbatim into a multipart part header, so a CR/LF in it can append arbitrary headers, close the header block, and forge a closing boundary — while the shared framing routine keeps the declared length consistent with the corrupted bytes. Uses the same predicate as outbound header-value validation (`HTTP-26`). `headerSafeMediaType` is the inbound counterpart: `HTTP-19` lets a received `content-type` carry obs-text that `HTTP-18` forbids outbound, so `HttpStatusError.body()` drops it rather than raising from an accessor on an error object | +| `freeze-body.ts` — every `Body` variant frozen at construction | this design, which specified no freeze | `readonly` is erased at run time, so a caller could reassign `contentLength` after construction and desynchronize the value a transport stamps into `Content-Length` from the bytes `writeTo` emits. That is the same drift `HTTP-51` makes `MultipartBody` share one framing routine to prevent and `HTTP-1`/`XCUT-15` make it defensively copy its parts for — left open one level up. Matches the `Object.freeze(this)` step every `packages/core/src/http/` model already performs | +| `http/charset.ts` — `HTTP-42`'s charset resolution extracted and shared | this design, which put the charset logic inside `Response.text()` | `HttpStatusError.preview()` needs the identical resolve-then-fall-back-to-UTF-8 rule (`BODY-33`), and two copies of a fallback chain drift. Named `decodeBodyText`, **not** `decodeText`: `io/text-codec.ts`'s `decodeText` deliberately implements true ISO-8859-1 and sets `ignoreBOM` for per-fragment decoding (`IO-13`, `SSE-12`), while this one delegates to `TextDecoder`'s windows-1252 mapping and consumes a leading BOM, which is right for a whole message body. The two are not interchangeable and the names now say so | +| `FormBodyValidationError` public, and `formUrlEncodedBody` accepts `FormUrlEncodedInput`/`FormUrlEncodedValue` | this design's `formUrlEncodedBody(params: ReadonlyMap)` | A field value that is neither a primitive nor `null` cannot be rendered; dropping it silently puts an incomplete body on the wire, so it is raised naming the key. Widening the input to `QueryParams`, a map, a record, or entry pairs reuses Phase 1's `QueryParams` encoder rather than a second hand-rolled one, which is also what makes the `+`-for-space rule single-sourced (`HTTP-38`/`BODY-35`) | +| `StringBody.mediaType` defaults to `text/plain; charset=utf-8`; `StringBody.text` and `FormUrlEncodedBody.params` are public fields | this design, which defaulted `mediaType` to `undefined` and exposed neither field | The default states the encoding `writeTo` actually emits instead of leaving a text body with no declared type. The two readable fields are the non-destructive way to inspect a body a caller already holds — the alternative is draining it, which for a `Body` is the one thing an inspection must not do | +| `Response.close()` memoized on a promise rather than a boolean flag | this design's `#closed = false` sketch | A flag set before the `await` reports a FAILED release as success to every later caller, over a connection that was never released. Handing every caller the same promise propagates the failure on every path while still cancelling at most once — the shape `BufferedSink.close()` already settled on for `IO-5`/`IO-41` | +| `TypedResponse`'s raw fields are getters, not constructor-assigned `readonly` fields | this design's class sketch | Same observable surface; delegating to the wrapped `Response` keeps the two from being able to disagree. `value()` additionally wraps the parse in an `async` IIFE so a parser that throws *synchronously* is still memoized — a bare `??=` never completes the assignment in that case and re-runs the handler against a single-use body whose bytes are gone, which `HTTP-44` forbids | +| `assertCount` hoisted into `io/limits.ts`, and `TeeSink.write` gained it | Phase 3a's frozen surface | `IO-3`'s guard existed as three byte-for-byte copies (`byte-queue.ts`, `buffered-source.ts`, `buffered-sink.ts`) and `TeeSink` — the fourth size-taking surface — had none, so a negative count reached it and was rejected only indirectly, by whichever `ByteQueue` call happened to run first, and not at all on its `count === 0` and short-source early returns. Behavior-preserving for the three that had it | +| `BODY-25`'s zero-chunk check applied on the exceeds-cap tail path too, not only in `drainOnce` | the first implementation of this design's two-regime wrapper | A rule enforced in one regime and not the other makes the same violating upstream pass or fail depending only on how big the body happened to be | +| `MultipartBody.writeTo` verifies the bytes it writes against its own declared `contentLength` | this design, which treated the shared framing routine as sufficient | The shared 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-supplied implementation can report one length and write another. Measured drift on a one-part body: declared 59, written 63. A bounded writer now refuses a chunk that would carry the message past the declared total (early, for the reason `StreamBody.#writeExactly` checks early) and a short total raises inside the writer scope so the sink is aborted rather than cleanly closed | +| A caller-supplied multipart boundary is validated for grammar only, not for non-appearance in part content | `HTTP-51`'s "caller-boundary validation", read as covering RFC 2046's full sender obligation | RFC 2046 puts two duties on the sender: a `bchars`-valid delimiter, and one that appears in no part. Only the first is checkable here — a `StreamBody` part's bytes do not exist until the write, so a scan would be a *partial* check that reads as complete, which is worse than a stated limitation. Mitigated where it matters: the default boundary is 32 random characters from Web Crypto and is generated unless the caller opts out, and both entry points now document the obligation a caller-supplied delimiter carries. A caller who supplies a guessable boundary alongside attacker-influenced part content can have that content forge a closing delimiter | +| `Response.bytes()`/`text()` and `toHttpError` acquire the body reader *inside* the try | the first implementation, which acquired it above | `getReader()` itself throws a `TypeError` when an external consumer already holds the lock, and `BODY-15` forbids assuming the body was never touched — 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 | +| The request-logging tee closes the primary sink when a delegate resolves without closing the adapter | none — closes a hole in this design's own decorator | `Body.writeTo`'s contract is that the body closes the sink it was given, and this wrapper is the only place that takes a writer on behalf of someone else's `Body`. A delegate that just resolves would strand the caller's sink open and locked with nothing thrown to notice it by | +| A foreign primitive source that over-reports its transferred count raises `SourceContractViolationError`, not `EndOfStreamError` | Phase 3a's `factories.ts`, which left the over-report direction to `ByteQueue.takeBytes` | It surfaced as `end of stream: delivered 2 of 99 bytes` — reporting a foreign source's broken accounting as an exhausted stream, which is the exact confusion `IO-17` forbids. The under-report direction already had `assertDrained`; the file's own comment claimed both were covered | +| New blocking gate `verify:consumer-types` | none — this is the gate whose absence let the `Symbol.asyncDispose` defect ship | It compiles a throwaway consumer against the built `.d.ts` using the `lib`/`target` read from `tsconfig.base.json`, with `types: []`. `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 | ## Testing From c8ce34bf0d73c9c9e1762478ba32654dfdb64fc0 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 26 Aug 2026 00:41:10 +0300 Subject: [PATCH 5/8] chore: date-prefix changeset filenames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@changesets/write` names every changeset with a random `human-id` (`dry-candles-unite.md`), and there is no config knob for it — the ID comes from a hardcoded `humanId()` call, and `.changeset/config.json`'s schema has no filename field. The names were already being hand-corrected after the fact. Add `scripts/changeset.mjs`, wired as `bun run changeset`: it forwards every argument to the CLI, then renames whatever changeset the run produced to `YYYY-MM-DD-.md`, matching `docs/superpowers/{specs,plans}`. The slug is prompted for and defaults to the changeset's own first sentence; a non-TTY caller takes that default rather than hanging on a prompt nobody can answer. Subcommands that create nothing (`version`, `status`, `publish`, `tag`, `pre`, `init`) pass straight through. Renaming after the fact is safe because nothing reads the filename back: the CLI globs `.changeset/*.md` and takes every decision from the frontmatter. The seven existing changesets are backfilled with the date of the commit that added each one. No CI gate — a changeset written by hand or by another tool is not checked. --- ...d => 2026-08-10-core-http-domain-model.md} | 0 ...ions.md => 2026-08-11-seam-foundations.md} | 0 ...2026-08-25-body-lifecycle-review-fixes.md} | 0 ...ecycle.md => 2026-08-25-body-lifecycle.md} | 0 ...ontracts.md => 2026-08-25-io-contracts.md} | 0 ...=> 2026-08-26-phase3-conformance-fixes.md} | 0 ....md => 2026-08-26-phase3-review-pass-2.md} | 0 CLAUDE.md | 7 +- package.json | 1 + scripts/changeset.mjs | 122 ++++++++++++++++++ 10 files changed, 129 insertions(+), 1 deletion(-) rename .changeset/{core-http-domain-model.md => 2026-08-10-core-http-domain-model.md} (100%) rename .changeset/{seam-foundations.md => 2026-08-11-seam-foundations.md} (100%) rename .changeset/{body-lifecycle-review-fixes.md => 2026-08-25-body-lifecycle-review-fixes.md} (100%) rename .changeset/{body-lifecycle.md => 2026-08-25-body-lifecycle.md} (100%) rename .changeset/{io-contracts.md => 2026-08-25-io-contracts.md} (100%) rename .changeset/{phase3-conformance-fixes.md => 2026-08-26-phase3-conformance-fixes.md} (100%) rename .changeset/{phase3-review-pass-2.md => 2026-08-26-phase3-review-pass-2.md} (100%) create mode 100644 scripts/changeset.mjs diff --git a/.changeset/core-http-domain-model.md b/.changeset/2026-08-10-core-http-domain-model.md similarity index 100% rename from .changeset/core-http-domain-model.md rename to .changeset/2026-08-10-core-http-domain-model.md diff --git a/.changeset/seam-foundations.md b/.changeset/2026-08-11-seam-foundations.md similarity index 100% rename from .changeset/seam-foundations.md rename to .changeset/2026-08-11-seam-foundations.md diff --git a/.changeset/body-lifecycle-review-fixes.md b/.changeset/2026-08-25-body-lifecycle-review-fixes.md similarity index 100% rename from .changeset/body-lifecycle-review-fixes.md rename to .changeset/2026-08-25-body-lifecycle-review-fixes.md diff --git a/.changeset/body-lifecycle.md b/.changeset/2026-08-25-body-lifecycle.md similarity index 100% rename from .changeset/body-lifecycle.md rename to .changeset/2026-08-25-body-lifecycle.md diff --git a/.changeset/io-contracts.md b/.changeset/2026-08-25-io-contracts.md similarity index 100% rename from .changeset/io-contracts.md rename to .changeset/2026-08-25-io-contracts.md diff --git a/.changeset/phase3-conformance-fixes.md b/.changeset/2026-08-26-phase3-conformance-fixes.md similarity index 100% rename from .changeset/phase3-conformance-fixes.md rename to .changeset/2026-08-26-phase3-conformance-fixes.md diff --git a/.changeset/phase3-review-pass-2.md b/.changeset/2026-08-26-phase3-review-pass-2.md similarity index 100% rename from .changeset/phase3-review-pass-2.md rename to .changeset/2026-08-26-phase3-review-pass-2.md diff --git a/CLAUDE.md b/CLAUDE.md index d384494..8bbdf38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -152,7 +152,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-.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 diff --git a/package.json b/package.json index e9e093e..329892e 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "api": "cd packages/core && bun run api:ci", "lint:publish": "publint packages/core && attw --pack packages/core --ignore-rules cjs-resolves-to-esm", "audit": "bun audit --audit-level=high --prod", + "changeset": "node scripts/changeset.mjs", "verify:dual-consumption": "node scripts/verify-dual-consumption.mjs", "verify:consumer-types": "node scripts/verify-consumer-types.mjs", "verify:seam-1": "node scripts/verify-seam-1.mjs", diff --git a/scripts/changeset.mjs b/scripts/changeset.mjs new file mode 100644 index 0000000..e6a5e2f --- /dev/null +++ b/scripts/changeset.mjs @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: MIT +// scripts/changeset.mjs +// +// Wrapper around the changesets CLI that renames a newly created changeset +// from `@changesets/write`'s random `human-id` name (`silly-pandas-jump.md`) +// to this repo's convention: `YYYY-MM-DD-.md`, matching +// `docs/superpowers/{specs,plans}`. +// +// The name is not a config knob — the ID comes from a hardcoded `humanId()` +// call inside `@changesets/write`, and `.changeset/config.json`'s schema has +// no filename field. Renaming afterwards is safe because nothing reads the +// filename back: the CLI globs `.changeset/*.md` (skipping `README.md` and +// `config.json`) and takes every decision from the frontmatter. +// +// Every argument is forwarded to `changeset` verbatim. Only the invocations +// that can create a changeset (`add`, or no subcommand) are renamed; +// `version`, `status`, `publish`, `tag`, `pre` and `init` pass through +// untouched. +import {spawnSync} from 'node:child_process'; +import {existsSync, readFileSync, readdirSync, renameSync} from 'node:fs'; +import {join} from 'node:path'; +import {createInterface} from 'node:readline/promises'; +import {stdin, stdout} from 'node:process'; +import {fileURLToPath} from 'node:url'; + +const CHANGESET_DIR = fileURLToPath(new URL('../.changeset', import.meta.url)); +const NON_CHANGESET_FILES = new Set(['README.md']); +const PASSTHROUGH_SUBCOMMANDS = new Set([ + 'version', + 'status', + 'publish', + 'tag', + 'pre', + 'init', +]); +const MAX_SLUG_LENGTH = 48; + +function listChangesets() { + return readdirSync(CHANGESET_DIR).filter( + name => name.endsWith('.md') && !NON_CHANGESET_FILES.has(name), + ); +} + +function today() { + const now = new Date(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + return `${now.getFullYear()}-${month}-${day}`; +} + +function toSlug(text) { + const words = text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .split('-') + .filter(Boolean); + + const kept = []; + let length = 0; + for (const word of words) { + const next = length === 0 ? word.length : length + 1 + word.length; + if (kept.length > 0 && next > MAX_SLUG_LENGTH) break; + kept.push(word); + length = next; + } + return kept.join('-').slice(0, MAX_SLUG_LENGTH); +} + +// The summary a changeset was written with is the best default name for it. +// Frontmatter is delimited by the first two `---` lines; the summary is the +// first non-empty line after that, cut at its first sentence — these summaries +// open with a title-like clause and then keep going for paragraphs. +function summaryOf(fileName) { + const lines = readFileSync(join(CHANGESET_DIR, fileName), 'utf8').split('\n'); + const closing = lines.indexOf('---', lines.indexOf('---') + 1); + const summary = lines.slice(closing + 1).find(line => line.trim() !== ''); + return (summary ?? '').trim().split(/(?<=\.)\s/)[0]; +} + +function uniqueName(date, slug) { + const base = `${date}-${slug}`; + if (!existsSync(join(CHANGESET_DIR, `${base}.md`))) return `${base}.md`; + for (let suffix = 2; ; suffix++) { + const candidate = `${base}-${suffix}.md`; + if (!existsSync(join(CHANGESET_DIR, candidate))) return candidate; + } +} + +async function askSlug(fallback) { + // Non-interactive callers (CI, a scripted `--empty`) get the derived slug + // rather than a hang on a prompt nobody can answer. + if (!stdin.isTTY) return fallback; + const rl = createInterface({input: stdin, output: stdout}); + try { + const answer = await rl.question(`Changeset slug (${fallback}): `); + const slug = toSlug(answer); + return slug === '' ? fallback : slug; + } finally { + rl.close(); + } +} + +async function rename(fileName) { + const derived = toSlug(summaryOf(fileName)) || 'changeset'; + const target = uniqueName(today(), await askSlug(derived)); + renameSync(join(CHANGESET_DIR, fileName), join(CHANGESET_DIR, target)); + console.log(`Renamed ${fileName} -> ${target}`); +} + +const args = process.argv.slice(2); +const creates = !PASSTHROUGH_SUBCOMMANDS.has(args[0] ?? 'add'); +const before = creates ? new Set(listChangesets()) : new Set(); + +const result = spawnSync('bunx', ['changeset', ...args], {stdio: 'inherit'}); +if (result.status !== 0) process.exit(result.status ?? 1); + +if (creates) { + for (const fileName of listChangesets().filter(name => !before.has(name))) { + await rename(fileName); + } +} From 444d879b827e7997753f325e4ce2933b120c7714 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 26 Aug 2026 00:52:30 +0300 Subject: [PATCH 6/8] =?UTF-8?q?ci(test):=20add=20the=20Node-runtime=20conf?= =?UTF-8?q?ormance=20suite,=20close=20checkpoint=20=C2=A75.9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bun test` runs the whole unit suite on Bun's runtime and proves nothing about the runtime this SDK ships to. Audited before writing anything: 319 of the 516 unit tests, across 21 of 43 files, exercise a runtime-divergent surface — Web Streams, AbortSignal, async iteration, or ByteQueue's Uint8Array handling — against exactly two assertions of Node coverage in scripts/verify-node-floor.mjs, neither of which touched io/. The ci job pinned no Node at all, so its three node-executed gates ran on an undeclared runner default, and node-floor-conformance pinned 18.17.0 alone, leaving current LTS unexercised against sdk-design-nodejs/09:52-54's "in addition to current LTS". Implements §5.9's own prescription rather than a substitute. bun test stays the unit runner untouched — docs/knowledge/testing.md mandates bun:test symbol imports, setSystemTime and --concurrent, so migrating to node:test would be a styleguide deviation plus a whole-suite rewrite — and is now scoped to packages/ via bunfig's [test] root so the two layers cannot blur. Without that scoping bun test collected the new .mjs files too, which would have run the Node-only layer on Bun and erased the distinction it exists to draw. Adds test/node-conformance/: 30 `node --test` cases over the BUILT artifact, never src/. Public surface arrives through the @dexpace/core specifier, the path a real consumer takes; io/ is @internal with no public subpath in exports, so it is reached by direct dist/ file path. Seeded with composeSignal, Phase 3a's byte-stream surface (chunk-straddling CRLF, slice views not advancing the parent, reader-lock release on close, tee mirror-and-forward, writeAll), and Phase 3b's body surface (reader-lock discipline on bytes/text/close, pipeTo preventCancel ownership, multipart framing through Web Crypto, toHttpError buffering). scripts/verify-node-floor.mjs is retired and its two AbortSignal.any assertions folded in as the suite's first cases, per §5.9:375's "rather than keeping two parallel Node entry points". The CI job is renamed node-conformance and is now a fail-fast:false matrix over ['18.17.0', 'lts/*']; lts/* resolves at run time so the LTS half cannot go stale as LTS moves. The 3b plan's Task 13 Step 3 called `bun run test:node` when no such script existed, so that gate sequence could not be executed as written; it is corrected, along with the two blocking gates it had never listed. NOTE: the CI job name changed. Branch protection requiring `node-floor-conformance` must be updated to `node-conformance`. --- .github/workflows/ci.yml | 18 +- CLAUDE.md | 8 + bunfig.toml | 11 +- ...-07-25-phase3b-body-lifecycle-checklist.md | 3 +- .../2026-07-25-phase3b-body-lifecycle.md | 9 +- ...2026-07-23-nodejs-sdk-v1-roadmap-design.md | 8 +- eslint.config.js | 14 +- package.json | 4 +- scripts/verify-node-floor.mjs | 26 -- test/node-conformance/README.md | 43 ++++ test/node-conformance/body-lifecycle.test.mjs | 229 ++++++++++++++++++ test/node-conformance/io-byte-stream.test.mjs | 226 +++++++++++++++++ test/node-conformance/seams.test.mjs | 66 +++++ 13 files changed, 617 insertions(+), 48 deletions(-) delete mode 100644 scripts/verify-node-floor.mjs create mode 100644 test/node-conformance/README.md create mode 100644 test/node-conformance/body-lifecycle.test.mjs create mode 100644 test/node-conformance/io-byte-stream.test.mjs create mode 100644 test/node-conformance/seams.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f8eaa7..8e2635e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,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: ['18.17.0', 'lts/*'] + name: node-conformance (${{ matrix.node }}) steps: - uses: actions/checkout@v4 @@ -77,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 diff --git a/CLAUDE.md b/CLAUDE.md index 8bbdf38..53c39f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -45,6 +52,7 @@ Release-shape and invariant gates: 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 diff --git a/bunfig.toml b/bunfig.toml index 7731a75..dbf396f 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,8 +1,11 @@ [test] +# Scope discovery to the workspace packages. Without this, `bun test` also collects +# test/node-conformance/*.test.mjs -- the Node-only layer that exists precisely because it must NOT +# run on Bun (checkpoint 5.9). Running it under both runners would inflate the unit count and quietly +# erase the distinction the suite was added to draw. It also keeps `scripts/*.test.mjs` — repo +# tooling, run via `bun run test:knowledge` (`node --test`) — out of both the run and the 80% floor, +# which is a statement about `packages/core`. +root = "packages" coverage = true coverageThreshold = 0.8 coverageSkipTestFiles = true -# The 80% floor is a statement about `packages/core`. Scoping discovery to -# `packages` keeps `scripts/*.test.mjs` — repo tooling, run via -# `bun run test:knowledge` (`node --test`) — out of both the run and the floor. -root = "packages" diff --git a/docs/superpowers/plans/2026-07-25-phase3b-body-lifecycle-checklist.md b/docs/superpowers/plans/2026-07-25-phase3b-body-lifecycle-checklist.md index d237bbb..ad0eea9 100644 --- a/docs/superpowers/plans/2026-07-25-phase3b-body-lifecycle-checklist.md +++ b/docs/superpowers/plans/2026-07-25-phase3b-body-lifecycle-checklist.md @@ -84,7 +84,8 @@ phase consults it) — ⏳ Deferred (named target phase) — 🚫 Not built (per | Negative space and cleanup | styleguide 11.9, 13.9 | ✅ | Double-close on `Response` and the response wrapper; write-after-consumed; read-after-tail-consumed; a delegate failure aborting rather than closing the primary sink; a caller stream that must not be cancelled | | Every test file cites its requirement IDs | Phase 1 convention, for Phase 9 | ✅ | Top-of-file comment in all ten `body/` test files, both modified `http/` ones, and the `io/` files this phase touched | | SPDX header on line 1 | `NFR-13` | ✅ | Every file under `packages/core/src/`. `http/response.test.ts` lost it in Task 8's wholesale rewrite and has it back | -| 80% aggregate coverage floor | `NFR-5` | ✅ | Well above; `bun test` runs coverage by default | +| 80% aggregate coverage floor | `NFR-5` | ✅ | Well above; `bun test` runs coverage by default, and the threshold is enforced rather than merely reported — raising it to `0.999` makes the identical suite exit 1 | +| The body surface runs on Node, not only Bun | checkpoint §5.9; roadmap E5 | ✅ | `test/node-conformance/body-lifecycle.test.mjs` exercises `Response.bytes`/`text`/`close`'s reader-lock discipline, `StreamBody`'s `preventCancel` ownership, multipart framing including the Web Crypto boundary, and `toHttpError` buffering against the **built** artifact under `node --test`. Added when E5 was closed; this phase's surface is a founding member of that suite because §6 is where Web Streams semantics first reach a consumer | | Changeset committed | Consumer-facing change | ✅ | `RequestBuilder.body` and `ResponseBuilder.body` both narrow from `unknown`, which `api-design.md` classes as breaking. Released as **minor** under semver's 0.x initial-development carve-out, with the pointer recorded — this is D1's decision, taken | ## Deferred out of this phase diff --git a/docs/superpowers/plans/2026-07-25-phase3b-body-lifecycle.md b/docs/superpowers/plans/2026-07-25-phase3b-body-lifecycle.md index 7aa09ea..f3f82a6 100644 --- a/docs/superpowers/plans/2026-07-25-phase3b-body-lifecycle.md +++ b/docs/superpowers/plans/2026-07-25-phase3b-body-lifecycle.md @@ -2989,12 +2989,19 @@ bun test --coverage bun run api bun run lint:publish bun run verify:dual-consumption +bun run verify:consumer-types bun run verify:seam-1 -bun run verify:node-floor +bun run verify:runtime-floor bun run test:node bun run audit ``` +> **Corrected 2026-08-26.** This sequence originally called `bun run test:node`, which did not exist — the +> step could not be executed as written (roadmap finding E5). The script now exists, and +> `bun run verify:node-floor` has been removed from the list because checkpoint §5.9 folded that script's two +> `AbortSignal.any` assertions into the conformance suite rather than keeping two parallel Node entry points. +> `verify:consumer-types` and `verify:runtime-floor` are added because both are blocking CI steps. + Expected: all exit 0. Coverage at or above the 80% aggregate floor (`NFR-5`). - [ ] **Step 4: Verify no `node:` import crept in** diff --git a/docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md b/docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md index 434319b..9d70f4f 100644 --- a/docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md +++ b/docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md @@ -347,7 +347,7 @@ the 3b plan's prerequisite claim plausible to whoever wrote it. Measured status | 5.6 | `AbortSignal.any` composition | **No action needed** — confirmatory | | 5.7 | Flat hoisting lets a package resolve an undeclared dependency | **Open** — E4 below | | 5.8 | `NFR-14`'s stale "no direct Bun equivalent" reason | **Open** — E7 below | -| 5.9 | `bun test` proves nothing about the Node runtime | **Open** — E5 below, the largest | +| 5.9 | `bun test` proves nothing about the Node runtime | **Done 2026-08-26** — E5 below | | 5.10 | Per-class `#private` justification comments | **Open** — E6 below | | 5.11 | Phase 4 pre-commitment: `Stage` must not be an `enum` | Not yet due (Phase 4) | | 5.12 | Tooling conflicts already resolved by the plans | Recorded only | @@ -362,7 +362,7 @@ had. **Verify a prerequisite against the artifact it was supposed to produce, no | E2 | major — **OPEN, checkpoint §5.2** | 3b's Task 1 flattened `io/`'s four error leaves off `IoError` on the stated basis that checkpoint §5.2 had already flattened Phase 1's `DomainModelError` tier. It had not, so the taxonomy is now *mixed*: `DexpaceError → EndOfStreamError` is two levels while `DexpaceError → DomainModelError → RequiredFieldError` is still three | `packages/core/src/http/errors.ts`; 3b design §"Error Tree" | **Deliberately not fixed in 3b.** Removing `DomainModelError` deletes a class exported from the public barrel that consumers can `instanceof` — a breaking API change belonging to the checkpoint. The residual is strictly smaller than what preceded it (`io/` no longer adds a second independent violation) and is recorded in 3b's ledger and checklist. **Blast radius, measured:** ten leaves extend it — `RequiredFieldError`, `HeaderValidationError`, `MediaTypeParseError`, `ProtocolParseError`, `UrlConstructionError`, `RequestOptionsValidationError`, `EtagParseError`, `HttpRangeValidationError`, `RequestConditionsValidationError`, `RequestBodyNotAllowedError` — all in one file, and `DomainModelError` itself is a runtime value export, so `instanceof` narrowing on it is live public API. §5.2 pre-specifies the replacement (an exported `isDomainModelError` type-guard union, never a re-subclass), and 3b already proved that pattern twice in-tree with `isIoError` and `isBodyError`. **Sequencing:** §5.2's own note — "Phase 4's error families then land as leaves on `DexpaceError` too, which is what keeps the flattening from being undone one phase later". **Ten queued phases introduce new SDK error types** — 4a (`DuplicateContextKeyError`), 4c (five, including `PillarCollisionError`, `CrossStageEditError`, `ReservedStageError`), 5b (`NonReplayableBodyError`, `SchemeDowngradeError`), 5c (`AuthResolutionError`, `PlaintextCredentialError`, `DigestChallengeUnsupportedError`), 6a (`SerdeError`, `SerializationError`, `DeserializationError`), 6b (`SseStreamError`, `SseLineTooLongError`), 6c (`PaginationError`), 8a (`TransportFailureError`), and 5a/8b, which reuse rather than define. Counted from the phase design docs 2026-08-26; 4b and 7a/7b define none. Every one of those that ships before the flatten is another tier decision taken against the wrong parent. Owned by checkpoint §5.2 | | E3 | major — **OPEN, checkpoint §5.3** | §5.3 requires every error subclass to carry its identifying inputs as sanitized `readonly` fields, because `JSON.stringify(error)` and structured-log field enumeration bypass `.message` entirely. It was applied to **two** leaves and stopped: `RequiredFieldError` carries `fieldName`, `HeaderValidationError` carries `kind` + `escapedName`. The other **eight** carry nothing — their identifying data exists only interpolated into the message string, which is precisely the shape the rule forbids. Not raised by any of Phase 3b's three review passes either; found only when the checkpoint was audited item by item | `packages/core/src/http/errors.ts` | **Open.** Same file and same ten classes as E2, so doing §5.2 and §5.3 in one pass is strictly cheaper than two. §5.3 also specifies the sanitization shape per leaf: the offending *name* control-character-escaped, the offending *value* never stored raw (a `valueLength`, a masked minimum fragment, or no field at all), and for `MediaTypeParseError` the failing token/offset rather than the full input. It further asks for a file comment on `errors.ts` recording *why* fields are sanitized at construction — that comment is what stops a later contributor "restoring" the raw value | | E4 | major — **OPEN, checkpoint §5.7** | No isolated linker is configured. `bunfig.toml` carries only a `[test]` block and there is no `.npmrc` at all, so the install is flat-hoisted by default. Under flat hoisting `@dexpace/core` can import a package it never declared and still pass every gate — including `verify:seam-1`, which reads the `dependencies` map rather than what the code actually resolves. That is the one phantom-dependency failure mode `SEAM-1`'s gate structurally cannot see | `bunfig.toml` (no linker key); no `.npmrc`; `scripts/verify-seam-1.mjs` | **Open.** §5.7 requires confirming the exact linker option against the pinned Bun version before writing it. Low effort, and it strengthens a `SEAM-1` guarantee the project treats as foundational | -| E5 | **blocker — OPEN, checkpoint §5.9** | The largest gap, and it has already bitten: **no `test:node` script exists**, yet the 3b plan's Task 13 Step 3 gate sequence calls `bun run test:node` — so that plan cannot be executed as written. `node-floor-conformance` still pins `18.17.0` alone, so current LTS is never exercised, directly contradicting the "in addition to current LTS" half of the rule. All 516 tests run on Bun | `.github/workflows/ci.yml`; root `package.json` scripts; 3b plan Task 13 Step 3 | **Open, and decaying with every phase.** For this codebase specifically: Bun's Web Streams, `AbortSignal` and async-iteration are independent implementations of Node's, and `io/` — chunk boundaries, backpressure timing, microtask ordering — is exactly where they diverge. The `no node: imports` grep proves runtime-*agnostic imports*, a far weaker claim than runtime-*correct on Node*. §5.9 explicitly **rejects** the obvious fix of moving to `vitest`/`node:test`: `docs/knowledge/testing.md` mandates `bun:test` symbol imports, `setSystemTime`, and `--concurrent`, so swapping runners is a styleguide-chapter deviation plus a whole-suite rewrite that buys nothing for the pure-logic majority | +| E5 | **blocker — CLOSED 2026-08-26, checkpoint §5.9** | Was: no `test:node` script existed, yet the 3b plan's Task 13 Step 3 gate sequence called `bun run test:node`, so that plan could not be executed as written; `node-floor-conformance` pinned `18.17.0` alone, leaving current LTS unexercised against the "in addition to current LTS" half of the rule; and all 516 unit tests ran only on Bun. Audited 2026-08-26: **319 of those 516 tests, across 21 of 43 files, exercise a runtime-divergent surface** — Web Streams, `AbortSignal`, async iteration, or `ByteQueue`'s `Uint8Array` handling — against **two** assertions of Node coverage, neither of which touched `io/`. The `ci` job additionally pinned no Node at all, so `verify:dual-consumption`/`verify:consumer-types`/`verify:runtime-floor` ran on an undeclared runner default | `.github/workflows/ci.yml`; root `package.json` scripts; 3b plan Task 13 Step 3 | **Closed by implementing §5.9's own prescription, not a substitute.** `bun test` is unchanged as the unit runner and is now scoped to `packages/` so the two layers cannot blur. Added `test/node-conformance/` — 30 `node --test` cases over the **built** artifact, seeded with `composeSignal` plus Phase 3a's byte-stream surface and Phase 3b's public body surface — wired as `test:node`. `scripts/verify-node-floor.mjs` is **retired**, its two `AbortSignal.any` assertions folded in as the suite's first cases, per §5.9:375's "rather than keeping two parallel Node entry points". The CI job is renamed `node-conformance` and is now a `fail-fast: false` matrix over `['18.17.0', 'lts/*']`; `lts/*` resolves at run time so the LTS half cannot go stale. The membership rule §5.9:378 states — a phase touching a runtime-divergent surface adds a case here — is recorded in `test/node-conformance/README.md` and `CLAUDE.md`. **Note:** the CI job name changed, so any branch protection requiring `node-floor-conformance` needs updating to `node-conformance` | | E6 | minor — **OPEN, checkpoint §5.10** | §5.10 ratifies the `#private` *choice* for wire-model classes but calls the missing per-declaration justification "a real, uncorrected gap" — the corpus wants the reason where a reader meets the field, not in a plan document they will never open. **None** of the eleven `packages/core/src/http/` model files carries one. Measured 2026-08-26 by grepping for a comment naming runtime privacy or citing `HTTP-1`/`SEAM-29` near a `#private` declaration: four files matched and all four were false positives — unrelated `HTTP-10`/`HTTP-11`/`HTTP-13`/`HTTP-18` requirement citations in ordinary TSDoc | `packages/core/src/http/*.ts` | **Open.** One short comment per declaring class (not per field), naming the runtime-privacy requirement and citing `HTTP-1`/`SEAM-29`. §5.10 also asks that the `http-domain-model.md` conflict entry then be resolved as a carve-out **scoped to wire-model classes only**, so it cannot read as blanket permission for `#private` elsewhere | | E7 | minor — **OPEN, checkpoint §5.8** | The scaffold checklist defers `NFR-14` on the reasoning that pnpm's `catalog:` protocol "has no direct Bun equivalent". Bun has since added workspace catalogs. The *conclusion* (defer to Phase 8) is still right — with one package there is nothing to deduplicate — but the stated reason is wrong, and §5.8's point is that a wrong reason is worse than an open item: at Phase 8 someone reads "no Bun equivalent" and either hand-syncs versions or reopens the pnpm decision | `plans/2026-07-23-scaffold-milestone-checklist.md:45`; two `docs/knowledge` lines | **Open.** Correct the reason, keep the ⏳ status and the Phase 8 target. §5.8 requires confirming the catalog schema against the pinned Bun version before writing any of it | @@ -381,8 +381,8 @@ had. **Verify a prerequisite against the artifact it was supposed to produce, no input, not just its wording, and F1 already notes the resolution has to land in 5a, 6b and 6c at the same time. -**Not blocking Phase 4, ordered by how fast they decay:** E5 (grows with every phase that adds Node-divergent -surface — Phase 4c's pipelines and Phase 8's transports most of all), then E4, E6, E7. +**Not blocking Phase 4, ordered by how fast they decay:** E4, E6, E7. E5 is closed — it was the one that grew +with every phase, which is why it went first. ### Phase-3-owned residuals diff --git a/eslint.config.js b/eslint.config.js index b45f534..8fb6ccd 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -22,12 +22,14 @@ export default tseslint.config( rules: {'prettier/prettier': ['error', gtsPrettierOptions]}, }, { - // The root config and the `.mjs` verification scripts belong to no - // TypeScript project; they get the gts/format baseline only, never the - // type-aware tiers below. gts scopes its own Node globals to a fixed list - // of filenames that does not include `scripts/*.mjs`, so declare them here - // or `console`/`URL` trip `no-undef`. - files: ['eslint.config.js', 'scripts/*.mjs'], + // The root config, the `.mjs` verification scripts, and the Node-runtime + // conformance suite belong to no TypeScript project; they get the + // gts/format baseline only, never the type-aware tiers below. gts scopes + // its own Node globals to a fixed list of filenames that includes none of + // these, so declare them here or `console`/`URL` trip `no-undef` — and, in + // the conformance suite, so do the Web Streams and `AbortSignal` globals + // that are the whole point of running it on Node. + files: ['eslint.config.js', 'scripts/*.mjs', 'test/node-conformance/*.mjs'], languageOptions: {sourceType: 'module', globals: globals.node}, }, { diff --git a/package.json b/package.json index 329892e..303af46 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "test": "bun test", "knowledge": "node scripts/knowledge.mjs", "test:knowledge": "node --test 'scripts/*.test.mjs'", + "test:node": "node --test test/node-conformance/*.test.mjs", "bench": "bun run packages/core/src/io/byte-queue.bench.ts", "api": "cd packages/core && bun run api:ci", "lint:publish": "publint packages/core && attw --pack packages/core --ignore-rules cjs-resolves-to-esm", @@ -37,7 +38,6 @@ "verify:dual-consumption": "node scripts/verify-dual-consumption.mjs", "verify:consumer-types": "node scripts/verify-consumer-types.mjs", "verify:seam-1": "node scripts/verify-seam-1.mjs", - "verify:runtime-floor": "node scripts/verify-runtime-floor.mjs", - "verify:node-floor": "node scripts/verify-node-floor.mjs" + "verify:runtime-floor": "node scripts/verify-runtime-floor.mjs" } } diff --git a/scripts/verify-node-floor.mjs b/scripts/verify-node-floor.mjs deleted file mode 100644 index 1344a07..0000000 --- a/scripts/verify-node-floor.mjs +++ /dev/null @@ -1,26 +0,0 @@ -// SPDX-License-Identifier: MIT -// scripts/verify-node-floor.mjs -// -// NFR-10/NFR-17 residual pulled forward from Phase 3: CI must run the *built artifact* against the -// declared minimum Node version, not just the runner default. This forces the two-signal branch of -// composeSignal() — the one that calls AbortSignal.any(), the API that landed in exactly Node -// 18.17.0, the repo's declared floor (engines.node ">=18.17"). -import assert from 'node:assert/strict'; -import {composeSignal} from '@dexpace/core'; - -const controller = new AbortController(); -const combined = composeSignal(controller.signal, 50); - -assert.ok( - combined instanceof AbortSignal, - 'composeSignal() must return an AbortSignal when both a user signal and a timeout are supplied', -); -assert.notEqual( - combined, - controller.signal, - 'the combined signal must be a distinct AbortSignal.any() result, not the raw user signal', -); - -console.log( - `node-floor check passed: AbortSignal.any() resolved correctly on Node ${process.version}`, -); diff --git a/test/node-conformance/README.md b/test/node-conformance/README.md new file mode 100644 index 0000000..862224a --- /dev/null +++ b/test/node-conformance/README.md @@ -0,0 +1,43 @@ +# Node-runtime conformance suite + +Closes checkpoint §5.9 (`docs/superpowers/plans/2026-07-25-checkpoint-scaffold-through-phase3a.md:341`). + +`bun test` runs the whole unit suite on **Bun's** runtime and proves nothing about the runtime this SDK +actually ships to. Bun's Web Streams, `AbortSignal`, and `Uint8Array`/async-iteration behavior are independent +implementations of Node's, and `packages/core/src/io/` — chunk boundaries, backpressure timing, reader-lock +discipline, `queueMicrotask` ordering — is exactly the kind of code where they diverge. The `no node: imports` +grep proves the code is runtime-*agnostic in its imports*, which is a much weaker claim than runtime-*correct +on Node*. + +This layer is **thin and additive**, not a second unit suite. `bun test` stays the unit-test runner, unchanged +— `docs/knowledge/testing.md` mandates `bun:test` symbol imports, `setSystemTime`, and `--concurrent`, so +migrating the suite to `node:test` would be a styleguide deviation plus a whole-suite rewrite, and it buys +nothing for the pure-logic majority (`Headers`/`MediaType`/`QueryParams` parsing cannot behave differently on +Node). + +## Rules + +- **Import the built artifact, never `src/`.** Public surface comes in through the `@dexpace/core` specifier; + `io/` is `@internal` with no public subpath in `exports`, so it is reached by direct `dist/` file path. Run + `bun run build` first — `test:node` does not build for you, because the CI job builds once and then runs the + matrix. +- **Assert runtime-divergent behavior only.** Anything that is pure logic belongs in `bun test`, where it runs + faster and closer to the code. A case here should be one you could imagine failing on one runtime and passing + on the other. +- **Must pass on the declared floor.** `package.json` `engines.node` is the contract; CI runs this suite as a + matrix over that floor and current LTS. Do not reach for an API newer than the floor without moving the floor + in the same change. + +## Membership rule + +**A phase that touches a runtime-divergent surface adds a case here, not only to `bun test`** (§5.9:378). That +means Phase 4 (pipelines, where `NFR-11`'s async-framework-leak check lands) and Phase 8 (concrete +`fetch`/`undici` transports, where this stops being precautionary and becomes the point). + +## Files + +| File | Surface | +|---|---| +| `seams.test.mjs` | `AbortSignal.any()` composition — folded in from the retired `scripts/verify-node-floor.mjs`, whose two assertions were the only Node coverage that existed before this suite | +| `io-byte-stream.test.mjs` | Phase 3a's `ByteQueue`, `BufferedSource` + views, `BufferedSink`, `TeeSink`, `writeAll` | +| `body-lifecycle.test.mjs` | Phase 3b's public body surface over real Node Web Streams — reader-lock discipline, `pipeTo` ownership, multipart framing, error-body buffering | diff --git a/test/node-conformance/body-lifecycle.test.mjs b/test/node-conformance/body-lifecycle.test.mjs new file mode 100644 index 0000000..68614e5 --- /dev/null +++ b/test/node-conformance/body-lifecycle.test.mjs @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: MIT +// test/node-conformance/body-lifecycle.test.mjs +// +// Phase 3b's public body surface, driven through the `@dexpace/core` specifier — the path a real consumer +// takes — on Node's Web Streams rather than Bun's. +// +// The reader-lock cases are the reason this file exists. `ReadableStream.cancel()` rejects with a +// TypeError on a locked stream, that check runs BEFORE the state check, and reading to `{done: true}` +// does NOT release the lock. Every one of those is spec text that two independent implementations can +// get subtly different, and getting it wrong turns every successful read into a rejection or silently +// holds a connection open. +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; +import { + byteArrayBody, + Headers, + materialize, + multipartBody, + Protocol, + Request, + Response, + Status, + streamBody, + stringBody, + toHttpError, +} from '@dexpace/core'; + +function streamOf(bytes) { + return new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.from(bytes)); + controller.close(); + }, + }); +} + +function responseWith(code, body, headers = Headers.newBuilder().build()) { + return Response.newBuilder() + .request(Request.newBuilder().url('https://example.com').build()) + .protocol(Protocol.HTTP_1_1) + .status(Status.of(code)) + .headers(headers) + .body(body) + .build(); +} + +async function collect(body) { + const chunks = []; + await body.writeTo( + new WritableStream({ + write: chunk => void chunks.push(Uint8Array.from(chunk)), + }), + ); + const total = chunks.reduce((sum, c) => sum + c.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +} + +describe('Response reader-lock discipline on Node', () => { + it('bytes() succeeds and closes, rather than being replaced by a cancel-on-locked TypeError', async () => { + const response = responseWith( + 200, + streamOf([...new TextEncoder().encode('hello')]), + ); + assert.equal(new TextDecoder().decode(await response.bytes()), 'hello'); + // Idempotent, and already closed by bytes()' own finally. + await response.close(); + }); + + it('text() decodes with the declared charset and closes', async () => { + const headers = Headers.newBuilder() + .add('content-type', 'text/plain;charset=iso-8859-1') + .build(); + const response = responseWith(200, streamOf([0x68, 0xe9]), headers); + assert.equal(await response.text(), 'hé'); + }); + + it('close() releases the connection even when the body was never read', async () => { + let cancelled = false; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.from([1])); + }, + cancel() { + cancelled = true; + }, + }); + await responseWith(204, stream).close(); + assert.equal(cancelled, true); + }); + + it('close() tolerates a body an external consumer already locked', async () => { + const stream = streamOf([1, 2, 3]); + const response = responseWith(200, stream); + stream.getReader(); // an external consumer takes the lock; BODY-15 forbids assuming otherwise + // cancel() on a locked stream rejects with TypeError; close() must swallow exactly that one. + await response.close(); + }); + + it('cancels the body at most once however often close is called', async () => { + let cancels = 0; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.from([1])); + }, + }); + const delegate = stream.cancel.bind(stream); + stream.cancel = async reason => { + cancels += 1; + return delegate(reason); + }; + const response = responseWith(200, stream); + await response.close(); + await response.close(); + await response.close(); + assert.equal(cancels, 1); + }); +}); + +describe('Body.writeTo over Node Web Streams', () => { + it('writes a byte-array body repeatably, byte-for-byte', async () => { + const body = byteArrayBody(Uint8Array.from([9, 8, 7])); + assert.deepEqual([...(await collect(body))], [9, 8, 7]); + assert.deepEqual([...(await collect(body))], [9, 8, 7]); + }); + + it('does not cancel the caller stream when the sink fails', async () => { + // pipeTo's default preventCancel:false would cancel the SOURCE here. Whether a runtime honours + // preventCancel is exactly the kind of Streams-spec detail worth pinning on Node. + let cancelReason = 'NOT-CANCELLED'; + const source = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.from([1, 2, 3])); + }, + cancel(reason) { + cancelReason = reason; + }, + }); + const failing = new WritableStream({ + write() { + throw new Error('SOCKET GONE'); + }, + }); + + await assert.rejects(streamBody(source).writeTo(failing), /SOCKET GONE/); + assert.equal(cancelReason, 'NOT-CANCELLED'); + }); + + it('raises when a declared contentLength disagrees with the stream', async () => { + const body = streamBody(streamOf([1, 2]), undefined, 5); + await assert.rejects( + body.writeTo(new WritableStream({write: () => undefined})), + error => error.name === 'EndOfStreamError', + ); + }); + + it('refuses a second write of a single-use body', async () => { + const body = streamBody(streamOf([1])); + await body.writeTo(new WritableStream({write: () => undefined})); + await assert.rejects( + body.writeTo(new WritableStream({write: () => undefined})), + error => error.name === 'ConsumedBodyError', + ); + }); + + it('materializes a single-use stream body into a replayable one', async () => { + const replayed = await materialize(streamBody(streamOf([4, 5, 6]))); + assert.equal(replayed.replayable, true); + assert.deepEqual([...(await collect(replayed))], [4, 5, 6]); + assert.deepEqual([...(await collect(replayed))], [4, 5, 6]); + }); +}); + +describe('MultipartBody framing on Node', () => { + it('generates a boundary from Web Crypto and frames a part', async () => { + // crypto.getRandomValues is a global on the declared floor; if it were not, every multipart body + // this SDK produces would throw, and only running here would reveal it. + const generated = multipartBody([{name: 'a', body: stringBody('x')}]); + assert.match( + generated.mediaType, + /^multipart\/form-data; boundary=dexpace-[A-Za-z0-9]{32}$/, + ); + }); + + it('declares a length equal to the bytes it actually writes', async () => { + const body = multipartBody( + [{name: 'field', body: stringBody('value')}], + 'B', + ); + const written = await collect(body); + assert.equal(written.length, body.contentLength); + assert.equal( + new TextDecoder().decode(written), + '--B\r\n' + + 'Content-Disposition: form-data; name="field"\r\n' + + // stringBody declares text/plain; charset=utf-8 by default, so the part carries a Content-Type. + 'Content-Type: text/plain; charset=utf-8\r\n' + + '\r\n' + + 'value\r\n' + + '--B--\r\n', + ); + }); +}); + +describe('toHttpError buffering on Node', () => { + it('buffers a 4xx body and re-serves it replayably after the connection is released', async () => { + const payload = [...new TextEncoder().encode('not found')]; + const error = await toHttpError(responseWith(404, streamOf(payload))); + assert.ok(error); + assert.equal(error.status, 404); + assert.equal(error.preview(), 'not found'); + + const body = error.body(); + assert.equal(body.replayable, true); + assert.deepEqual([...(await collect(body))], payload); + assert.deepEqual([...(await collect(error.body()))], payload); + }); + + it('returns null for a non-error status and leaves the body intact', async () => { + const response = responseWith(200, streamOf([1, 2, 3])); + assert.equal(await toHttpError(response), null); + assert.deepEqual([...(await response.bytes())], [1, 2, 3]); + }); +}); diff --git a/test/node-conformance/io-byte-stream.test.mjs b/test/node-conformance/io-byte-stream.test.mjs new file mode 100644 index 0000000..542e609 --- /dev/null +++ b/test/node-conformance/io-byte-stream.test.mjs @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: MIT +// test/node-conformance/io-byte-stream.test.mjs +// +// Phase 3a's byte-stream surface, on Node. §5.9:358 names this layer specifically: "chunk boundaries, +// backpressure timing, queueMicrotask ordering" are where Bun's and Node's independent Web Streams +// implementations diverge, and every one of its ~300 unit tests runs only on Bun. +// +// Imported by direct `dist/` file path rather than through the `@dexpace/core` specifier: `io/` is +// `@internal` by design and `exports` maps only `"."`, so there is deliberately no public subpath. This +// is still the BUILT artifact, never `src/` (§5.9:372). +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; +import {ByteQueue} from '../../packages/core/dist/io/byte-queue.js'; +import {BufferedSource} from '../../packages/core/dist/io/buffered-source.js'; +import {BufferedSink} from '../../packages/core/dist/io/buffered-sink.js'; +import {TeeSink} from '../../packages/core/dist/io/tee-sink.js'; +import {writeAll} from '../../packages/core/dist/io/pump.js'; +import {END_OF_STREAM} from '../../packages/core/dist/io/limits.js'; + +/** A stream that hands out exactly the chunk boundaries the caller asks for. */ +function streamOfChunks(chunks) { + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(Uint8Array.from(chunk)); + controller.close(); + }, + }); +} + +function collectingStream() { + const chunks = []; + const stream = new WritableStream({ + write: chunk => void chunks.push(Uint8Array.from(chunk)), + }); + return { + stream, + written: () => { + const total = chunks.reduce((sum, c) => sum + c.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; + }, + }; +} + +async function drain(source) { + const staging = new ByteQueue(); + while ((await source.read(staging, 8)) !== END_OF_STREAM) { + /* pull to exhaustion */ + } + return staging.snapshot(); +} + +describe('ByteQueue Uint8Array semantics on Node', () => { + it('keeps a snapshot independent of later mutation, in both directions', () => { + const queue = new ByteQueue(); + const input = Uint8Array.from([1, 2, 3]); + queue.writeBytes(input); + + // The copy is what makes zero-copy subarray transfers between queues safe. A runtime whose + // TypedArray slice/subarray semantics differed here would corrupt every body the SDK sends. + input[0] = 99; + const snapshot = queue.snapshot(); + assert.deepEqual([...snapshot], [1, 2, 3]); + + snapshot[1] = 88; + assert.deepEqual([...queue.snapshot()], [1, 2, 3]); + }); + + it('preserves byte order across arbitrary chunk splits and read increments', () => { + const queue = new ByteQueue(); + for (const chunk of [[1], [2, 3, 4], [], [5, 6], [7, 8, 9, 10]]) { + queue.writeBytes(Uint8Array.from(chunk)); + } + const dest = new ByteQueue(); + for (const take of [3, 1, 4, 2]) queue.read(dest, take); + + assert.equal(queue.size, 0); + assert.deepEqual([...dest.snapshot()], [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + }); + + it('returns 0 for a zero-count read and END_OF_STREAM only when exhausted', () => { + const queue = new ByteQueue(); + const dest = new ByteQueue(); + assert.equal( + queue.read(dest, 0), + 0, + 'a zero-count read is 0, never end-of-stream', + ); + assert.equal(queue.read(dest, 4), END_OF_STREAM); + queue.writeBytes(Uint8Array.from([1, 2])); + assert.equal(queue.read(dest, 0), 0); + assert.equal(queue.read(dest, 4), 2); + }); +}); + +describe('BufferedSource over a Node ReadableStream', () => { + it('reads an exact count across chunk boundaries the stream chose, not the ones we asked for', async () => { + const source = BufferedSource.overStream( + streamOfChunks([[1, 2], [3], [4, 5, 6]]), + ); + assert.deepEqual([...(await source.readExactly(4))], [1, 2, 3, 4]); + assert.deepEqual([...(await source.readExactly(2))], [5, 6]); + assert.equal(await source.exhausted(), true); + await source.close(); + }); + + it('splits lines when the CRLF terminator straddles two stream chunks', async () => { + // The case hand-picked examples miss and the one most sensitive to how a runtime delivers chunks: + // "\r" ends one chunk and "\n" begins the next. + const source = BufferedSource.overStream( + streamOfChunks([ + [0x61, 0x0d], + [0x0a, 0x62, 0x0a], + ]), + ); + assert.equal(await source.readUtf8Line(), 'a'); + assert.equal(await source.readUtf8Line(), 'b'); + assert.equal(await source.readUtf8Line(), undefined); + await source.close(); + }); + + it('keeps a lone CR as line content rather than treating it as a terminator', async () => { + const source = BufferedSource.overStream( + streamOfChunks([[0x61, 0x0d, 0x62, 0x0a]]), + ); + assert.equal(await source.readUtf8Line(), 'a\rb'); + await source.close(); + }); + + it('serves a slice view without advancing the parent cursor', async () => { + const source = BufferedSource.overStream( + streamOfChunks([ + [0, 1, 2], + [3, 4], + [5, 6, 7, 8, 9], + ]), + ); + const view = source.slice(2, 5); + + assert.deepEqual([...(await drain(view))], [2, 3, 4, 5, 6]); + // Retention has to hold bytes the parent has not reached while the view races ahead — the + // RetentionWindow behavior that depends on when the underlying reader delivers. + assert.deepEqual( + [...(await drain(source))], + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + ); + await source.close(); + }); + + it('releases the caller stream lock on close', async () => { + const stream = streamOfChunks([[1, 2, 3]]); + const source = BufferedSource.overStream(stream); + assert.equal(stream.locked, true); + await source.close(); + // cancel() cancels the stream but never releases the reader's lock; only releaseLock() does, and a + // leaked lock on a connection-backed source is a held socket. + assert.equal(stream.locked, false); + }); +}); + +describe('BufferedSink and TeeSink over a Node WritableStream', () => { + it('writes exactly the requested count and drains the source only after the write resolves', async () => { + const {stream, written} = collectingStream(); + const sink = BufferedSink.overStream(stream); + const source = new ByteQueue(); + source.writeBytes(Uint8Array.from([1, 2, 3, 4, 5])); + + await sink.write(source, 3); + assert.deepEqual([...written()], [1, 2, 3]); + assert.equal(source.size, 2, 'only the written bytes leave the source'); + await sink.close(); + }); + + it('mirrors into the tap while forwarding the full untruncated payload', async () => { + const {stream, written} = collectingStream(); + const tee = new TeeSink(BufferedSink.overStream(stream), 2); + const source = new ByteQueue(); + source.writeBytes(Uint8Array.from([1, 2, 3, 4, 5])); + + await tee.write(source, 5); + // The invariant logging exists for: the wire body is never reduced by the tap. + assert.deepEqual([...written()], [1, 2, 3, 4, 5]); + assert.deepEqual([...tee.snapshot()], [1, 2]); + await tee.close(); + }); + + it('pumps a source to exhaustion through a tee', async () => { + const {stream, written} = collectingStream(); + const tee = new TeeSink(BufferedSink.overStream(stream), 4); + const payload = Uint8Array.from( + Array.from({length: 200}, (_, i) => i % 256), + ); + + const total = await writeAll(BufferedSource.overBytes(payload), tee); + await tee.close(); + + assert.equal(total, payload.length); + assert.deepEqual([...written()], [...payload]); + assert.equal(tee.snapshot().length, 4); + }); + + it('surfaces a failed downstream write through flush rather than reporting success', async () => { + const failing = new WritableStream({ + write() { + throw new Error('WIRE DIED'); + }, + }); + const sink = BufferedSink.overStream(failing); + const source = new ByteQueue(); + source.writeBytes(Uint8Array.from([1])); + + await assert.rejects(sink.write(source, 1), /WIRE DIED/); + // Backpressure and error propagation timing is exactly the queueMicrotask-ordering surface §5.9 names. + await assert.rejects(sink.flush(), /WIRE DIED/); + assert.equal( + source.size, + 1, + 'a failed write leaves the payload for the caller to retry', + ); + }); +}); diff --git a/test/node-conformance/seams.test.mjs b/test/node-conformance/seams.test.mjs new file mode 100644 index 0000000..2abaa84 --- /dev/null +++ b/test/node-conformance/seams.test.mjs @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +// test/node-conformance/seams.test.mjs +// +// Folded in from the retired `scripts/verify-node-floor.mjs`, whose two assertions were the entirety of +// this repo's Node coverage before this suite existed (checkpoint §5.9). Keeping a second parallel Node +// entry point alongside `test:node` is what §5.9:375 tells us not to do. +// +// `AbortSignal.any()` landed in exactly Node 18.17.0, which is why `engines.node` says `">=18.17"`. This +// file is the assertion that the floor is real rather than aspirational. +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; +import {composeSignal, isTimeoutSignal} from '@dexpace/core'; + +describe('composeSignal on the declared Node floor', () => { + it('returns a distinct AbortSignal.any() result when both a signal and a timeout are supplied', () => { + const controller = new AbortController(); + const combined = composeSignal(controller.signal, 50); + + assert.ok( + combined instanceof AbortSignal, + 'composeSignal() must return an AbortSignal when both a user signal and a timeout are supplied', + ); + assert.notEqual( + combined, + controller.signal, + 'the combined signal must be a distinct AbortSignal.any() result, not the raw user signal', + ); + }); + + it('propagates a user abort through the composed signal, and does not call it a timeout', () => { + const controller = new AbortController(); + const combined = composeSignal(controller.signal, 60_000); + assert.equal(combined.aborted, false); + + controller.abort(new Error('caller cancelled')); + // Abort propagation through AbortSignal.any() is synchronous per spec, but the two runtimes reach it + // by different implementations — asserting it here rather than only on Bun is the point. + assert.equal(combined.aborted, true); + assert.equal( + isTimeoutSignal(combined), + false, + 'a caller abort must not be misreported as a timeout', + ); + }); + + it('reports a fired timeout by its structured reason, not by instanceof', async () => { + const timeoutOnly = composeSignal(undefined, 5); + assert.ok(timeoutOnly instanceof AbortSignal); + // Not yet fired: `reason` is undefined, so the predicate is false until the timer runs. + assert.equal(isTimeoutSignal(timeoutOnly), false); + + await new Promise(resolve => { + timeoutOnly.addEventListener('abort', resolve, {once: true}); + }); + + // The shape of what AbortSignal.timeout() stores in `reason` is the runtime-divergent part: + // isTimeoutSignal reads `reason.name === 'TimeoutError'` precisely because `instanceof DOMException` + // is realm-bound and would fail across a worker or node:vm boundary. + assert.equal(timeoutOnly.aborted, true); + assert.equal(isTimeoutSignal(timeoutOnly), true); + }); + + it('returns undefined when neither a signal nor a timeout is supplied', () => { + assert.equal(composeSignal(undefined, undefined), undefined); + }); +}); From c7d19780e0cc27483f5da82b45d7ec35a1320bd5 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 26 Aug 2026 00:54:46 +0300 Subject: [PATCH 7/8] chore: add an empty changeset for the Node conformance suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `changeset --empty`, deliberately, rather than no changeset at all. Commit e3d0b18 touched zero files under packages/ — everything in it is repository infrastructure that ships to nobody — so there is nothing for @dexpace/core to bump, and a patch would put a changelog line in front of consumers that means nothing to them. The empty changeset is what distinguishes "this change needs no release" from "somebody forgot a changeset". `changeset status` is unchanged by it: still one minor for @dexpace/core, from the five existing non-empty changesets. Created through scripts/changeset.mjs so the filename follows the repo's YYYY-MM-DD- convention; --empty produces no summary to derive a slug from, so the wrapper fell back to a generic name and it was renamed using the wrapper's own toSlug logic once the summary was written. --- ...-add-the-node-runtime-conformance-suite.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .changeset/2026-08-26-add-the-node-runtime-conformance-suite.md diff --git a/.changeset/2026-08-26-add-the-node-runtime-conformance-suite.md b/.changeset/2026-08-26-add-the-node-runtime-conformance-suite.md new file mode 100644 index 0000000..aaefe70 --- /dev/null +++ b/.changeset/2026-08-26-add-the-node-runtime-conformance-suite.md @@ -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. From 5e2045d619798534b17fe37e43ae444992087cbd Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 26 Aug 2026 01:13:34 +0300 Subject: [PATCH 8/8] fix(core): raise the Node floor to 20.3, close two floor defects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR's node-conformance job failed on the pinned floor and passed on `lts/*`. Two unrelated defects, both invisible to `bun test` by construction. `MultipartBody` generates its boundary from `crypto.getRandomValues` — a bare global — while `engines.node` declared `">=18.17"`. Node exposes `globalThis.crypto` unflagged only from 19.0.0, and never to an ES module on any 18.x release: verified on 18.17.0 and 18.20.8, where `typeof globalThis.crypto` is `undefined` in `.mjs` and an object in CJS. So every `multipartBody(...)` call threw `ReferenceError: crypto is not defined` on the version the package promised, and a CommonJS probe would have reported that floor as satisfied. Bun supplies the global, which is why 516 unit tests never saw it and E5's suite caught it the first time it ran the built artifact on the pinned floor. The floor moves to `>=20.3`, chosen over the two options 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 RNG silently downgrades the unguessable-boundary mitigation HTTP-51 leans on against multipart injection, on exactly the runtime CI pins. Node 18 went EOL in April 2025, so no supported runtime is dropped. 20.3 and not 20.0: `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. `lib`/`target` move to ES2023 so `verify:runtime-floor` stays consistent; its `es2023` row is amended to `>=20.3` with the built-ins, not the syntax, named as the reason the floor sits above the language level's own minimum. The CI matrix pin moves 18.17.0 -> 20.3.0, and `seams.test.mjs` gains a case asserting `globalThis.crypto.getRandomValues` is a function *in ESM* — verified to fail on 18.17.0 and pass on 20.3.0 — so this cannot regress silently. Second defect: `seams.test.mjs` awaited an `AbortSignal.timeout()` abort with nothing else scheduled. That timer is unref'd on every Node version by design, so the loop drained before it fired and 18.17.0's runner cancelled the rest of the file (`Promise resolution is still pending but the event loop has already resolved`). Newer runners hold the loop open through handles of their own, which is the whole reason it passed on LTS. It now holds a ref'd deadline that both keeps the loop alive and fails the case if the abort never arrives. `sdk-design-nodejs/02`'s runtime line claimed Node >=18.17 supplies `globalThis.crypto.subtle`; corrected. Recorded as roadmap finding E8, which also renumbers E1: Symbol.dispose/asyncDispose reached the 20.x line in 20.4.0, so §5.4's bump now reads `>=20.3` -> `>=20.4`. The symbol is still declared nowhere. Gates: typecheck, lint, build, bun test (516), api, lint:publish, verify:dual-consumption, verify:consumer-types, verify:seam-1, verify:runtime-floor, audit, and test:node on both 20.3.0 and current Node. --- ...2026-08-26-raise-the-node-floor-to-20-3.md | 39 ++++++++++++++++ .github/workflows/ci.yml | 2 +- .../02-package-and-workspace-layout.md | 2 +- .../09-toolchain-and-quality-gates.md | 2 +- ...-deviations-from-the-reference-contract.md | 4 +- ...2026-07-23-nodejs-sdk-v1-roadmap-design.md | 5 ++- packages/core/package.json | 2 +- scripts/verify-runtime-floor.mjs | 12 +++-- test/node-conformance/README.md | 10 ++++- test/node-conformance/seams.test.mjs | 45 +++++++++++++++++-- tsconfig.base.json | 3 +- 11 files changed, 107 insertions(+), 19 deletions(-) create mode 100644 .changeset/2026-08-26-raise-the-node-floor-to-20-3.md diff --git a/.changeset/2026-08-26-raise-the-node-floor-to-20-3.md b/.changeset/2026-08-26-raise-the-node-floor-to-20-3.md new file mode 100644 index 0000000..bdba70f --- /dev/null +++ b/.changeset/2026-08-26-raise-the-node-floor-to-20-3.md @@ -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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e2635e..fba100e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,7 +70,7 @@ jobs: # 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: ['18.17.0', 'lts/*'] + node: ['20.3.0', 'lts/*'] name: node-conformance (${{ matrix.node }}) steps: - uses: actions/checkout@v4 diff --git a/docs/sdk-design-nodejs/02-package-and-workspace-layout.md b/docs/sdk-design-nodejs/02-package-and-workspace-layout.md index f3bc234..6b86915 100644 --- a/docs/sdk-design-nodejs/02-package-and-workspace-layout.md +++ b/docs/sdk-design-nodejs/02-package-and-workspace-layout.md @@ -7,7 +7,7 @@ of Gradle's multi-module build graph. | Package | Purpose | Runtime floor | Dependencies | |---|---|---|---| -| `@dexpace/core` | Domain model, I/O contracts (built directly on Web Streams, not pluggable — see §3.1), execution context, both pipeline layers, retry/redirect/auth, pagination, SSE parsing, the serde SPI + `Tristate`, the instrumentation SPI, configuration. | Any runtime with Web Streams, `fetch`-shaped `AbortSignal`, and `globalThis.crypto.subtle` (Node ≥18.17, current evergreen browsers, Deno, Bun, Cloudflare Workers). | none | +| `@dexpace/core` | Domain model, I/O contracts (built directly on Web Streams, not pluggable — see §3.1), execution context, both pipeline layers, retry/redirect/auth, pagination, SSE parsing, the serde SPI + `Tristate`, the instrumentation SPI, configuration. | Any runtime with Web Streams, `fetch`-shaped `AbortSignal`, and `globalThis.crypto` (Node ≥20.3, current evergreen browsers, Deno, Bun, Cloudflare Workers). **Node ≥18.17 was the claim until 2026-08-26 and it was wrong twice over:** Node exposes `globalThis.crypto` unflagged only from 19.0.0 and never to an ES module on any 18.x release, and `AbortSignal.any()` reached the 20.x line in 20.3.0. | none | | `@dexpace/codec-json` | Reference wire codec: `JSON.parse`/`JSON.stringify` plus `Tristate` wiring and Standard-Schema decode glue (§7.3). | same as core | none beyond a `@dexpace/core` peer | | `@dexpace/transport-fetch` | Minimal transport built on the global `fetch`. The zero-dependency, built-into-the-runtime option — the Node analog of `sdk-transport-jdkhttp`'s "no extra library, but less low-level control" trade-off. | same as core | none beyond a `@dexpace/core` peer | | `@dexpace/transport-undici` | Full-featured transport built on `undici`'s `Client`/`Pool`/`request()` API: connection-pool tuning, trailers, explicit socket-level cancellation. The Node analog of `sdk-transport-okhttp`'s "richer, but pulls in a real library" trade-off. | Node only | `undici` | diff --git a/docs/sdk-design-nodejs/09-toolchain-and-quality-gates.md b/docs/sdk-design-nodejs/09-toolchain-and-quality-gates.md index f18ba82..06a46fc 100644 --- a/docs/sdk-design-nodejs/09-toolchain-and-quality-gates.md +++ b/docs/sdk-design-nodejs/09-toolchain-and-quality-gates.md @@ -46,7 +46,7 @@ than the artifact's declared floor, producing a symbol reference that link-check fails at call time on an older runtime (`NoSuchMethodError` on the JVM; a plain `TypeError: X is not a function` in Node). The TypeScript-specific version of this trap is a `tsconfig.json` `lib` setting newer than the package's declared `engines.node` floor — for instance, `lib: ["ES2023"]` type-checks a call to -`Array.prototype.toSorted` cleanly while `engines.node: ">=18.17"` promises a runtime that does not have it, +`Array.prototype.toSorted` cleanly while `engines.node: ">=18.17"` promised a runtime that does not have it, producing exactly the same class of silent, deferred-to-call-time failure the JVM side already learned to guard against. Each package's `tsconfig` `lib`/`target` must be pinned to match its own declared `engines.node` floor, not inherited loosely from whatever the workspace root happens to use for editor tooling, and CI should run the built diff --git a/docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md b/docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md index cb4580f..5b4346e 100644 --- a/docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md +++ b/docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md @@ -87,8 +87,8 @@ Phase 10 (`docs/superpowers/specs/2026-07-28-phase10-deviation-reconciliation-de discovery surface to keep-configure at all, the same discovery machinery Item 2 above already retired. This closes the item permanently rather than leaving it re-flagged for a future phase. 11. **`Symbol.asyncDispose` is adopted opportunistically, not uniformly, and this is deliberate, not drift.** - Internal `io/` primitives ship `close()` only — the symbol postdates the package's declared `>=18.17` Node - floor, and these types are `@internal` and never surface to a consumer who'd use the ergonomic disposal syntax + Internal `io/` primitives ship `close()` only — the symbol postdates the package's declared Node floor + (`>=20.3` since 2026-08-26; on the 20.x line the symbol arrives in 20.4.0), and these types are `@internal` and never surface to a consumer who'd use the ergonomic disposal syntax (Phase 3a). Public, consumer-facing disposable resources added in later phases — `Body`/`Response` (Phase 3b), `SseStream` (Phase 6b), `Page` (Phase 6c) — each add `[Symbol.asyncDispose]` as optional and runtime-guarded rather than declaring `implements AsyncDisposable`, so the type works whether or not the running Node version diff --git a/docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md b/docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md index 9d70f4f..855ed04 100644 --- a/docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md +++ b/docs/superpowers/specs/2026-07-23-nodejs-sdk-v1-roadmap-design.md @@ -358,13 +358,14 @@ had. **Verify a prerequisite against the artifact it was supposed to produce, no | # | Sev | Finding | Where | Resolution | |---|---|---|---|---| -| E1 | **blocker — CLOSED in 3b, reopened against checkpoint §5.4** | 3b shipped `[Symbol.asyncDispose]` on `Response` and `LoggedResponseBody` on the strength of the design's claim that "the floor is bumped and `lib` extended before 3b starts". Neither happened: `engines.node` is still `">=18.17"` and `lib` is `["ES2022", "DOM", "DOM.AsyncIterable"]`. Two consequences, both real: below Node 18.18 the computed key evaluates to `undefined` and binds the method to the string `"undefined"`; and the symbol's *type* reaches the package only through a dev-only global, so a consumer compiling against the published `.d.ts` on this repo's own declared `lib` fails with `TS2550: Property 'asyncDispose' does not exist on type 'SymbolConstructor'`. No gate covered it — `verify:dual-consumption` runs `node`, not `tsc` | `packages/core/package.json`; `tsconfig.base.json`; 3b design §"Response Body" | **3b reverted to `close()`-only**, matching the decision Phase 3a shipped and every other resource owner still carries, with both classes now asserting the symbol's *absence* so it cannot be reintroduced ahead of the floor. Re-adding it is checkpoint §5.4's job and must land on all seven owners at once — `Transport`, `ByteQueue`, `BufferedSource`, `BufferedSink`, `RetentionWindow`, `Response`, `LoggedResponseBody`. **Version numbers now verified**, discharging §5.4's own "verify against the actual Node release notes before writing the number" instruction: `Symbol.dispose`/`Symbol.asyncDispose` first shipped in **Node 18.18.0**, backported to **20.4.0** — symbols only, not the `using` syntax. So §5.4's "believed 18.18.0" was right and the bump really is patch-level: `>=18.17` → `>=18.18.0`. **Note for 4b's F1:** that finding assumed the floor had already been "raised at most to `18.18.0` at the 2026-07-25 checkpoint" and that `esnext.disposable` was in `lib`. Neither premise holds — see F1's own amended row | +| E1 | **blocker — CLOSED in 3b, reopened against checkpoint §5.4** | 3b shipped `[Symbol.asyncDispose]` on `Response` and `LoggedResponseBody` on the strength of the design's claim that "the floor is bumped and `lib` extended before 3b starts". Neither happened: `engines.node` is still `">=18.17"` and `lib` is `["ES2022", "DOM", "DOM.AsyncIterable"]`. Two consequences, both real: below Node 18.18 the computed key evaluates to `undefined` and binds the method to the string `"undefined"`; and the symbol's *type* reaches the package only through a dev-only global, so a consumer compiling against the published `.d.ts` on this repo's own declared `lib` fails with `TS2550: Property 'asyncDispose' does not exist on type 'SymbolConstructor'`. No gate covered it — `verify:dual-consumption` runs `node`, not `tsc` | `packages/core/package.json`; `tsconfig.base.json`; 3b design §"Response Body" | **3b reverted to `close()`-only**, matching the decision Phase 3a shipped and every other resource owner still carries, with both classes now asserting the symbol's *absence* so it cannot be reintroduced ahead of the floor. Re-adding it is checkpoint §5.4's job and must land on all seven owners at once — `Transport`, `ByteQueue`, `BufferedSource`, `BufferedSink`, `RetentionWindow`, `Response`, `LoggedResponseBody`. **Version numbers now verified**, discharging §5.4's own "verify against the actual Node release notes before writing the number" instruction: `Symbol.dispose`/`Symbol.asyncDispose` first shipped in **Node 18.18.0**, backported to **20.4.0** — symbols only, not the `using` syntax. So §5.4's "believed 18.18.0" was right and the bump really is patch-level. **Renumbered 2026-08-26 by E8:** the floor is now `>=20.3`, and on the 20.x line the symbols arrive in 20.4.0, so §5.4's bump reads `>=20.3` → `>=20.4`. **Note for 4b's F1:** that finding assumed the floor had already been "raised at most to `18.18.0` at the 2026-07-25 checkpoint" and that `esnext.disposable` was in `lib`. Neither premise holds — see F1's own amended row | | E2 | major — **OPEN, checkpoint §5.2** | 3b's Task 1 flattened `io/`'s four error leaves off `IoError` on the stated basis that checkpoint §5.2 had already flattened Phase 1's `DomainModelError` tier. It had not, so the taxonomy is now *mixed*: `DexpaceError → EndOfStreamError` is two levels while `DexpaceError → DomainModelError → RequiredFieldError` is still three | `packages/core/src/http/errors.ts`; 3b design §"Error Tree" | **Deliberately not fixed in 3b.** Removing `DomainModelError` deletes a class exported from the public barrel that consumers can `instanceof` — a breaking API change belonging to the checkpoint. The residual is strictly smaller than what preceded it (`io/` no longer adds a second independent violation) and is recorded in 3b's ledger and checklist. **Blast radius, measured:** ten leaves extend it — `RequiredFieldError`, `HeaderValidationError`, `MediaTypeParseError`, `ProtocolParseError`, `UrlConstructionError`, `RequestOptionsValidationError`, `EtagParseError`, `HttpRangeValidationError`, `RequestConditionsValidationError`, `RequestBodyNotAllowedError` — all in one file, and `DomainModelError` itself is a runtime value export, so `instanceof` narrowing on it is live public API. §5.2 pre-specifies the replacement (an exported `isDomainModelError` type-guard union, never a re-subclass), and 3b already proved that pattern twice in-tree with `isIoError` and `isBodyError`. **Sequencing:** §5.2's own note — "Phase 4's error families then land as leaves on `DexpaceError` too, which is what keeps the flattening from being undone one phase later". **Ten queued phases introduce new SDK error types** — 4a (`DuplicateContextKeyError`), 4c (five, including `PillarCollisionError`, `CrossStageEditError`, `ReservedStageError`), 5b (`NonReplayableBodyError`, `SchemeDowngradeError`), 5c (`AuthResolutionError`, `PlaintextCredentialError`, `DigestChallengeUnsupportedError`), 6a (`SerdeError`, `SerializationError`, `DeserializationError`), 6b (`SseStreamError`, `SseLineTooLongError`), 6c (`PaginationError`), 8a (`TransportFailureError`), and 5a/8b, which reuse rather than define. Counted from the phase design docs 2026-08-26; 4b and 7a/7b define none. Every one of those that ships before the flatten is another tier decision taken against the wrong parent. Owned by checkpoint §5.2 | | E3 | major — **OPEN, checkpoint §5.3** | §5.3 requires every error subclass to carry its identifying inputs as sanitized `readonly` fields, because `JSON.stringify(error)` and structured-log field enumeration bypass `.message` entirely. It was applied to **two** leaves and stopped: `RequiredFieldError` carries `fieldName`, `HeaderValidationError` carries `kind` + `escapedName`. The other **eight** carry nothing — their identifying data exists only interpolated into the message string, which is precisely the shape the rule forbids. Not raised by any of Phase 3b's three review passes either; found only when the checkpoint was audited item by item | `packages/core/src/http/errors.ts` | **Open.** Same file and same ten classes as E2, so doing §5.2 and §5.3 in one pass is strictly cheaper than two. §5.3 also specifies the sanitization shape per leaf: the offending *name* control-character-escaped, the offending *value* never stored raw (a `valueLength`, a masked minimum fragment, or no field at all), and for `MediaTypeParseError` the failing token/offset rather than the full input. It further asks for a file comment on `errors.ts` recording *why* fields are sanitized at construction — that comment is what stops a later contributor "restoring" the raw value | | E4 | major — **OPEN, checkpoint §5.7** | No isolated linker is configured. `bunfig.toml` carries only a `[test]` block and there is no `.npmrc` at all, so the install is flat-hoisted by default. Under flat hoisting `@dexpace/core` can import a package it never declared and still pass every gate — including `verify:seam-1`, which reads the `dependencies` map rather than what the code actually resolves. That is the one phantom-dependency failure mode `SEAM-1`'s gate structurally cannot see | `bunfig.toml` (no linker key); no `.npmrc`; `scripts/verify-seam-1.mjs` | **Open.** §5.7 requires confirming the exact linker option against the pinned Bun version before writing it. Low effort, and it strengthens a `SEAM-1` guarantee the project treats as foundational | -| E5 | **blocker — CLOSED 2026-08-26, checkpoint §5.9** | Was: no `test:node` script existed, yet the 3b plan's Task 13 Step 3 gate sequence called `bun run test:node`, so that plan could not be executed as written; `node-floor-conformance` pinned `18.17.0` alone, leaving current LTS unexercised against the "in addition to current LTS" half of the rule; and all 516 unit tests ran only on Bun. Audited 2026-08-26: **319 of those 516 tests, across 21 of 43 files, exercise a runtime-divergent surface** — Web Streams, `AbortSignal`, async iteration, or `ByteQueue`'s `Uint8Array` handling — against **two** assertions of Node coverage, neither of which touched `io/`. The `ci` job additionally pinned no Node at all, so `verify:dual-consumption`/`verify:consumer-types`/`verify:runtime-floor` ran on an undeclared runner default | `.github/workflows/ci.yml`; root `package.json` scripts; 3b plan Task 13 Step 3 | **Closed by implementing §5.9's own prescription, not a substitute.** `bun test` is unchanged as the unit runner and is now scoped to `packages/` so the two layers cannot blur. Added `test/node-conformance/` — 30 `node --test` cases over the **built** artifact, seeded with `composeSignal` plus Phase 3a's byte-stream surface and Phase 3b's public body surface — wired as `test:node`. `scripts/verify-node-floor.mjs` is **retired**, its two `AbortSignal.any` assertions folded in as the suite's first cases, per §5.9:375's "rather than keeping two parallel Node entry points". The CI job is renamed `node-conformance` and is now a `fail-fast: false` matrix over `['18.17.0', 'lts/*']`; `lts/*` resolves at run time so the LTS half cannot go stale. The membership rule §5.9:378 states — a phase touching a runtime-divergent surface adds a case here — is recorded in `test/node-conformance/README.md` and `CLAUDE.md`. **Note:** the CI job name changed, so any branch protection requiring `node-floor-conformance` needs updating to `node-conformance` | +| E5 | **blocker — CLOSED 2026-08-26, checkpoint §5.9** | Was: no `test:node` script existed, yet the 3b plan's Task 13 Step 3 gate sequence called `bun run test:node`, so that plan could not be executed as written; `node-floor-conformance` pinned `18.17.0` alone, leaving current LTS unexercised against the "in addition to current LTS" half of the rule; and all 516 unit tests ran only on Bun. Audited 2026-08-26: **319 of those 516 tests, across 21 of 43 files, exercise a runtime-divergent surface** — Web Streams, `AbortSignal`, async iteration, or `ByteQueue`'s `Uint8Array` handling — against **two** assertions of Node coverage, neither of which touched `io/`. The `ci` job additionally pinned no Node at all, so `verify:dual-consumption`/`verify:consumer-types`/`verify:runtime-floor` ran on an undeclared runner default | `.github/workflows/ci.yml`; root `package.json` scripts; 3b plan Task 13 Step 3 | **Closed by implementing §5.9's own prescription, not a substitute.** `bun test` is unchanged as the unit runner and is now scoped to `packages/` so the two layers cannot blur. Added `test/node-conformance/` — 30 `node --test` cases over the **built** artifact, seeded with `composeSignal` plus Phase 3a's byte-stream surface and Phase 3b's public body surface — wired as `test:node`. `scripts/verify-node-floor.mjs` is **retired**, its two `AbortSignal.any` assertions folded in as the suite's first cases, per §5.9:375's "rather than keeping two parallel Node entry points". The CI job is renamed `node-conformance` and is now a `fail-fast: false` matrix over `['18.17.0', 'lts/*']` (floor pin moved to `20.3.0` by E8); `lts/*` resolves at run time so the LTS half cannot go stale. The membership rule §5.9:378 states — a phase touching a runtime-divergent surface adds a case here — is recorded in `test/node-conformance/README.md` and `CLAUDE.md`. **Note:** the CI job name changed, so any branch protection requiring `node-floor-conformance` needs updating to `node-conformance` | | E6 | minor — **OPEN, checkpoint §5.10** | §5.10 ratifies the `#private` *choice* for wire-model classes but calls the missing per-declaration justification "a real, uncorrected gap" — the corpus wants the reason where a reader meets the field, not in a plan document they will never open. **None** of the eleven `packages/core/src/http/` model files carries one. Measured 2026-08-26 by grepping for a comment naming runtime privacy or citing `HTTP-1`/`SEAM-29` near a `#private` declaration: four files matched and all four were false positives — unrelated `HTTP-10`/`HTTP-11`/`HTTP-13`/`HTTP-18` requirement citations in ordinary TSDoc | `packages/core/src/http/*.ts` | **Open.** One short comment per declaring class (not per field), naming the runtime-privacy requirement and citing `HTTP-1`/`SEAM-29`. §5.10 also asks that the `http-domain-model.md` conflict entry then be resolved as a carve-out **scoped to wire-model classes only**, so it cannot read as blanket permission for `#private` elsewhere | | E7 | minor — **OPEN, checkpoint §5.8** | The scaffold checklist defers `NFR-14` on the reasoning that pnpm's `catalog:` protocol "has no direct Bun equivalent". Bun has since added workspace catalogs. The *conclusion* (defer to Phase 8) is still right — with one package there is nothing to deduplicate — but the stated reason is wrong, and §5.8's point is that a wrong reason is worse than an open item: at Phase 8 someone reads "no Bun equivalent" and either hand-syncs versions or reopens the pnpm decision | `plans/2026-07-23-scaffold-milestone-checklist.md:45`; two `docs/knowledge` lines | **Open.** Correct the reason, keep the ⏳ status and the Phase 8 target. §5.8 requires confirming the catalog schema against the pinned Bun version before writing any of it | +| E8 | **blocker — CLOSED 2026-08-26** | `MultipartBody` generates its boundary from `crypto.getRandomValues`, a bare global reference, while `engines.node` declared `">=18.17"`. Node exposes `globalThis.crypto` unflagged only from **19.0.0**, and never to an ES module on any 18.x release — verified on 18.17.0 and 18.20.8, where `typeof globalThis.crypto` is `undefined` in `.mjs` and an object in CJS, so a CommonJS probe would have reported the floor as satisfied. Every `multipartBody(...)` call therefore threw `ReferenceError: crypto is not defined` on the declared floor. Uncaught until E5's conformance suite ran the built artifact on the pinned floor for the first time; `bun test` cannot see it, because Bun supplies the global. The same run exposed a second, unrelated defect: `seams.test.mjs` awaited an `AbortSignal.timeout()` abort with nothing else scheduled, and that timer is unref'd on every Node version, so on 18.17.0's test runner the loop drained first and the runner cancelled the rest of the file (`Promise resolution is still pending but the event loop has already resolved`). Newer runners hold the loop open through handles of their own, which is why it passed on `lts/*` | `packages/core/src/body/multipart-body.ts:36`; `packages/core/package.json`; `tsconfig.base.json`; `.github/workflows/ci.yml`; `sdk-design-nodejs/02:10` | **Floor raised to `>=20.3`**, the option taken in preference to a `node:crypto` fallback (which would put a Node-only specifier in a package documented as running on browsers, Deno, Bun and Workers, and cannot be reached synchronously from a constructor) or a non-crypto RNG (which silently downgrades the unguessable-boundary mitigation that `HTTP-51` leans on against multipart injection). **20.3 and not 20.0:** `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 on a pinned 20.0.0. `lib`/`target` move to `ES2023` with it, keeping `verify:runtime-floor`'s pairing table honest; its `es2023` row is amended to `>=20.3` with the built-ins, not the syntax, named as the reason. The CI matrix floor pin moves `18.17.0` → `20.3.0`, and `seams.test.mjs` gains a case asserting `globalThis.crypto.getRandomValues` is a function *in ESM*, so the floor cannot regress silently. Node 18 went EOL in April 2025, so no supported runtime is dropped. **Note for E1:** this discharges E1's floor half in the sense that only `Symbol.dispose`/`Symbol.asyncDispose` now stand between the declared floor and §5.4 — but not the number: the symbols reached the 20.x line in **20.4.0**, so §5.4's bump is now `>=20.3` → `>=20.4`, still patch-level, and still required before any owner declares the method | ### Suggested order diff --git a/packages/core/package.json b/packages/core/package.json index 835e89a..168dade 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -6,7 +6,7 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "engines": { - "node": ">=18.17" + "node": ">=20.3" }, "exports": { ".": { diff --git a/scripts/verify-runtime-floor.mjs b/scripts/verify-runtime-floor.mjs index 1fa9161..ec744cf 100644 --- a/scripts/verify-runtime-floor.mjs +++ b/scripts/verify-runtime-floor.mjs @@ -20,13 +20,17 @@ import {join} from 'node:path'; import {fileURLToPath} from 'node:url'; // The agreed pairings for this project. These are deliberate project decisions, -// not a general ES-to-Node compatibility matrix: ES2022 syntax runs on Node -// 16.11+, but the SDK declares a 18.17 floor. Adding a row here is a reviewed -// choice about what runtimes the SDK supports, never a mechanical bump. +// not a general ES-to-Node compatibility matrix, and the floor is set by the +// runtime built-ins the SDK calls rather than by the syntax it emits: ES2023 +// syntax runs on Node 20.0, but `globalThis.crypto` (which `MultipartBody` +// reads synchronously at construction) is exposed unflagged only from 19.0 and +// is absent from ESM on every Node 18 release, and `AbortSignal.any()` (which +// `composeSignal` calls) landed in 20.3.0. Adding or moving a row here is a +// reviewed choice about what runtimes the SDK supports, never a mechanical bump. const LANGUAGE_LEVEL_TO_NODE_FLOOR = { es2021: '>=16.11', es2022: '>=18.17', - es2023: '>=20.0', + es2023: '>=20.3', }; const repoRoot = fileURLToPath(new URL('..', import.meta.url)); diff --git a/test/node-conformance/README.md b/test/node-conformance/README.md index 862224a..35c9dc0 100644 --- a/test/node-conformance/README.md +++ b/test/node-conformance/README.md @@ -26,7 +26,13 @@ Node). on the other. - **Must pass on the declared floor.** `package.json` `engines.node` is the contract; CI runs this suite as a matrix over that floor and current LTS. Do not reach for an API newer than the floor without moving the floor - in the same change. + in the same change. The floor is set by the *built-ins the code calls*, not by the syntax it emits — it reads + `>=20.3` because `globalThis.crypto` is absent from ESM on every Node 18 release and `AbortSignal.any()` + reached the 20.x line in 20.3.0, not because of anything ES2023. +- **Do not await a timer the runtime does not ref.** `AbortSignal.timeout()`'s timer is unref'd everywhere by + design, so awaiting its `abort` event with nothing else scheduled lets the loop drain and the runner report + `Promise resolution is still pending but the event loop has already resolved`. Hold the loop open with a ref'd + deadline that also fails the case if the event never arrives. ## Membership rule @@ -38,6 +44,6 @@ means Phase 4 (pipelines, where `NFR-11`'s async-framework-leak check lands) and | File | Surface | |---|---| -| `seams.test.mjs` | `AbortSignal.any()` composition — folded in from the retired `scripts/verify-node-floor.mjs`, whose two assertions were the only Node coverage that existed before this suite | +| `seams.test.mjs` | `AbortSignal.any()` composition — folded in from the retired `scripts/verify-node-floor.mjs`, whose two assertions were the only Node coverage that existed before this suite — plus the `globalThis.crypto` floor assertion, made from ESM on purpose (Node 18 exposed `crypto` to CommonJS while leaving it undefined in ES modules) | | `io-byte-stream.test.mjs` | Phase 3a's `ByteQueue`, `BufferedSource` + views, `BufferedSink`, `TeeSink`, `writeAll` | | `body-lifecycle.test.mjs` | Phase 3b's public body surface over real Node Web Streams — reader-lock discipline, `pipeTo` ownership, multipart framing, error-body buffering | diff --git a/test/node-conformance/seams.test.mjs b/test/node-conformance/seams.test.mjs index 2abaa84..37cc03c 100644 --- a/test/node-conformance/seams.test.mjs +++ b/test/node-conformance/seams.test.mjs @@ -5,8 +5,11 @@ // this repo's Node coverage before this suite existed (checkpoint §5.9). Keeping a second parallel Node // entry point alongside `test:node` is what §5.9:375 tells us not to do. // -// `AbortSignal.any()` landed in exactly Node 18.17.0, which is why `engines.node` says `">=18.17"`. This -// file is the assertion that the floor is real rather than aspirational. +// This file is the assertion that the declared floor is real rather than aspirational. `engines.node` says +// `">=20.3"`, and two separate built-ins put it there: `globalThis.crypto` — which `MultipartBody` needs to +// generate a boundary — is exposed unflagged only from Node 19.0.0 and is absent from ESM on every Node 18 +// release including 18.20.x; and `AbortSignal.any()`, backported to 18.17.0, reached the 20.x line only in +// 20.3.0. 20.3.0 is the first release carrying both. import assert from 'node:assert/strict'; import {describe, it} from 'node:test'; import {composeSignal, isTimeoutSignal} from '@dexpace/core'; @@ -49,8 +52,27 @@ describe('composeSignal on the declared Node floor', () => { // Not yet fired: `reason` is undefined, so the predicate is false until the timer runs. assert.equal(isTimeoutSignal(timeoutOnly), false); - await new Promise(resolve => { - timeoutOnly.addEventListener('abort', resolve, {once: true}); + // `AbortSignal.timeout()`'s timer is unref'd on every Node version — deliberately, so a pending + // timeout never keeps a process alive on its own. Awaiting the `abort` event with nothing else + // scheduled therefore lets the loop drain before the 5ms timer runs, and Node 18.17's test runner + // reports that as `Promise resolution is still pending but the event loop has already resolved` + // and cancels the rest of the file. Newer runners hold the loop open through handles of their + // own, which is the whole reason this passed on current LTS and failed on the declared floor. + // Hold it open here rather than depending on the runner: the ref'd deadline keeps the loop alive + // and fails loudly if the timeout never arrives, instead of hanging until the job times out. + await new Promise((resolve, reject) => { + const deadline = setTimeout(() => { + reject(new Error('AbortSignal.timeout(5) did not fire within 5s')); + }, 5_000); + + timeoutOnly.addEventListener( + 'abort', + () => { + clearTimeout(deadline); + resolve(undefined); + }, + {once: true}, + ); }); // The shape of what AbortSignal.timeout() stores in `reason` is the runtime-divergent part: @@ -64,3 +86,18 @@ describe('composeSignal on the declared Node floor', () => { assert.equal(composeSignal(undefined, undefined), undefined); }); }); + +describe('Web Crypto on the declared Node floor', () => { + it('exposes globalThis.crypto.getRandomValues to an ES module', () => { + // The floor-defining global. `MultipartBody` reads it synchronously at construction, so there is no + // asynchronous fallback available to it, and no `node:crypto` import either — the package is documented + // as runnable on browsers, Deno, Bun and Workers, all of which supply the global. Asserted here in an + // `.mjs` file on purpose: Node 18 exposes `crypto` to CommonJS while leaving it undefined in ESM, so a + // CJS probe would have reported this floor as satisfied when it was not. + assert.equal( + typeof globalThis.crypto?.getRandomValues, + 'function', + 'the declared engines.node floor must expose globalThis.crypto.getRandomValues to ES modules', + ); + }); +}); diff --git a/tsconfig.base.json b/tsconfig.base.json index 7c876c0..e715f95 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -2,10 +2,11 @@ "extends": "./node_modules/gts/tsconfig-google.json", "compilerOptions": { "composite": true, + "target": "ES2023", "module": "nodenext", "moduleResolution": "nodenext", "lib": [ - "ES2022", + "ES2023", "DOM", "DOM.AsyncIterable" ],