Skip to content

Phase 8 — the transport adapters, the file-backed body, and the RxJS async-runtime bridge - #53

Merged
Wahbeh-Mohammad merged 2 commits into
mvpfrom
21-phase-8
Aug 29, 2026
Merged

Phase 8 — the transport adapters, the file-backed body, and the RxJS async-runtime bridge#53
Wahbeh-Mohammad merged 2 commits into
mvpfrom
21-phase-8

Conversation

@Wahbeh-Mohammad

Copy link
Copy Markdown
Contributor

What changed

New packages

  • @dexpace/transport-fetch — a Transport over the runtime's global fetch. It has no
    dependencies. It offers no proxy option, because Node's bare fetch exposes no proxy hook.
  • @dexpace/transport-undici — the full-featured transport. It closes only the dispatchers it
    built, honors NO_PROXY over a direct Agent, and dispatches file bodies with start/count.
  • @dexpace/body-file — the fileBody() factory. It validates the file at construction and opens
    a fresh handle per write. Core cannot hold it, because core imports no node: module.
  • @dexpace/transport-shared — the parts both transports share: header drop and degrade rules,
    drop-log dedup, abort mapping, the request-body pump, and the delivery-detached signal fork.
  • @dexpace/transport-conformance — private. One TRANSPORT-N suite and its node:http fixture
    server. Each transport runs the same suite, so no clause is proven for one adapter only.
  • @dexpace/rx — exposes SseStream and Paginator as RxJS observables: sseEvents$,
    typedSse$, pageItems$, pages$. rxjs is a required peer. The package ships its own
    fromAsyncIterable, because RxJS from() does not cancel a suspended pull.

Core

  • Adds TransportFailureError and the type-only FileBodyDescriptor.
  • Adds 'file' to Body['kind'].
  • Promotes IoError to @public as the base class, so retry classification needs no edit.

Gates

  • build:deps replaces build:core as the prefix of typecheck, lint, fix, and build. It
    builds core and transport-shared.
  • typecheck, build, api, and lint:publish now cover every new package.
  • verify:seam-1 becomes a per-package allow-list for NFR-2. It again fails a package that omits
    dependencies instead of committing an empty object.
  • verify:dual-consumption and verify:consumer-types exercise the new packages.

New CI preflight check

  • Adds the ci-preflight skill. node .claude/skills/ci-preflight/run-ci.mjs runs all 14 blocking
    CI steps locally, in CI's order, and reports every failure at once.
  • Full output goes to node_modules/.cache/ci-preflight/<step>.log. Only a summary reaches stdout.
  • --clean deletes each dist/ and *.tsbuildinfo first, so the run starts from the tree CI
    checks out. Use it before a push.
  • The runner pins Bun to .bun-version by default, because transport behavior differs between Bun
    releases.

Tests

  • 72 colocated cases across the transport packages, and 26 conformance rows run once per transport.
  • 25 cases in @dexpace/rx, plus its conformance suite for the four cancellation shapes.
  • New Node-runtime cases for both adapters, for file bodies through a real transport, and for the
    rx bridge.

Docs

  • Two changesets, the Phase 8a and 8b checklists and plan, open items §M, and Deviation Ledger
    rows 13 and 17. CLAUDE.md records the build:depsrule and preflight runner.

…ed body (#52)

* feat(transport): phase 8a — the fetch and undici adapters, and the file-backed body.

Ships the two transport adapters, the file-backed request body, and the one
conformance suite both adapters are proven against — the first code in this
SDK that puts bytes on the wire, per
product-spec/17-transport-adapter-conformance-contract.md (TRANSPORT-1..30),
appendix C's SEAM-12/14/15/16/30, NFR-2/15 and BODY-11/12/13, and
docs/superpowers/plans/2026-07-28-phase8a-transport-checklist.md.

Four published packages and one private:

- `@dexpace/transport-fetch` — a `Transport` over the runtime's global
  `fetch`, zero dependencies beyond its `@dexpace/core` peer. There is no
  `proxy` option at all: an absent option, not a silently ignored one, because
  Node's bare `fetch` exposes no proxy hook that does not route through
  `undici` internals (TRANSPORT-30, scoped out). `close()` is a sanctioned
  no-op over a runtime global it does not own, so `send()` keeps working after
  it — this adapter's documented SEAM-15 mode.
- `@dexpace/transport-undici` — the full-featured one, taking exactly one
  external dependency. Ownership-aware `close()` over the dispatchers it
  constructed and never a bring-your-own one (SEAM-14), `NO_PROXY` bypass
  routed over a separate direct `Agent`, direct file-body dispatch honoring
  `start`/`count` (TRANSPORT-28), and a native-internal cancel told apart from
  a timeout by undici's own codes (TRANSPORT-8). An `Agent`, not a `Pool`: a
  `Pool` binds to one origin at construction, and a general-purpose transport
  must reach whatever origin each `Request` names. `undici` is loaded through
  `createRequire` by path, because Bun resolves the bare specifier to its own
  shim, whose `Agent` constructs but has no `request`.
- `@dexpace/body-file` — the concrete `fileBody()` factory, fail-fast `node:fs`
  construction validation, a fresh handle per write, short-write detection.
  Cannot live in core, which imports no `node:` module. Neither transport
  depends on it: they recognize it structurally through `body.kind === 'file'`
  and core's type-only `FileBodyDescriptor`, never a cross-package
  `instanceof`.
- `@dexpace/transport-shared` — the header drop/degrade pass, drop-log dedup
  policy, abort-to-SDK-error mapping, request-body pump, and delivery-detached
  signal fork. `@internal` exports only; published because a transport's
  `dependencies` must resolve for consumers. Exists so neither transport has to
  depend on its sibling.
- `@dexpace/transport-conformance` — unpublished. The single `TRANSPORT-N`
  suite plus its `node:http` fixture server, run once per transport through
  each package's own `*.conformance.test.ts`, so no requirement is proven for
  one adapter and assumed for the other.

Core gains `TransportFailureError` (TRANSPORT-20's canonical retryable
no-response failure) and the type-only `FileBodyDescriptor`, plus a `'file'`
member on `Body['kind']`. `IoError` is promoted from `@internal` to `@public`
as its base class. The subtyping is the requirement, not modelling
convenience: `classify.ts`'s cause-walk already returns true for every
`IoError`, so a no-response failure is retryable with no edit to the retry
layer. It costs a third hierarchy level against the styleguide's two-level
cap, recorded as Deviation Ledger row 17 rather than left silent.

SEAM-16 drove `signal-fork.ts`. Both native clients tie the response body's
lifetime to whatever signal they were handed, so passing the caller's straight
through would let a later `abort()` truncate a body the caller is still
reading. Each transport dispatches over a fork it detaches at delivery:
cancellation stays live for the whole in-flight window and goes inert
afterwards.

Five defects found reviewing the staged phase against the plan. One would have
taken a consumer's process down.

`transport-undici` never kept a handler on a streaming request body's
producer. When a server answers before the body finishes — an early 413, a
redirect — `send()` has already resolved, and a producer that then fails
reaches Node's default `unhandledRejection` policy and terminates the process.
`transport-fetch` was immune only incidentally, through the `Promise.race` it
uses to surface producer failures. That race is now `producerFailure` in
`transport-shared`, used by both, and the guarantee is a conformance row
driven by a new `/early-response` fixture — verified to fail on the
reintroduced defect and pass once reverted. Its absence is why the suite
missed this: TRANSPORT-19 had no undici row at all, against the suite's own
rule that no requirement is proven for one adapter and assumed for the other.

Every non-abort dispatch failure was classified `TransportFailureError`, and
`classify.ts` is an allow-list that returns true for every `IoError` — so
undici's argument-validation codes, which are permanent and perfectly
reproducible, were reported as always-retryable and would have spent a
caller's whole retry budget re-proving the same rejection. `UND_ERR_INVALID_ARG`
and `UND_ERR_NOT_SUPPORTED` now leave the `IoError` tree as a `TypeError`,
matching `selectDispatchers`, which already reports caller misconfiguration
that way. Reachable through a bring-your-own `ProxyAgent`, whose per-request
`Proxy-Authorization` the owned-proxy drop set does not cover.

The adaptation-throw path released the response body but not the request
producer, leaving it parked on backpressure — both adapters, and the one
non-delivering exit the TRANSPORT-19 audit trail claimed was covered.

`verify:seam-1` had quietly weakened. Generalizing it to an NFR-2 allow-list
replaced `deepEqual(dependencies, {})` with a key scan, so a package that
omits `dependencies` entirely passed; an omitted field is not a hard-committed
empty one. Every package outside the allow-list is held to the original
assertion again, with the banner comment rewritten to describe the allow-list
model it now implements.

BODY-11 and TRANSPORT-28 were each tested in isolation and never together: no
test sent a real `fileBody()` through a real transport, which matters most for
undici, whose file path bypasses `writeTo` entirely for its own
`createReadStream`. Covered now in `test/node-conformance/`, the only layer
where a Node-only package and a transport can meet, whole and ranged, for both
adapters.

Gates: `verify:seam-1` becomes a per-package allow-list, because NFR-2 grants
each optional capability core plus at most one external library —
`transport-undici` takes `undici`, the rest take none. `verify:dual-consumption`
exercises all five new packages under plain `node`; `verify:consumer-types`
references every symbol the three consumer-facing ones promote and asserts only
that `transport-shared`'s artifact exists, since no consumer is meant to import
its `@internal` surface. `lint:publish` and `api` extend to all four published
packages.

Tests: 72 colocated cases across the four packages, 26 conformance rows run
once per transport (capability-gated where §17 scopes a clause to one
reference implementation), and eleven Node-runtime cases per adapter under
`node --test`, because Bun's `fetch`, `AbortSignal`, and Web Streams are an
independent implementation of the surfaces a transport is made of. Each header
cites the IDs it exercises.

Deliberate gaps, recorded rather than silent. TRANSPORT-18's re-subscribable
producer is unbuildable here — neither client drives writes through one, so
there is no native internal resend to make idempotent, and 5a's replayability
gate covers the SDK's own retries. TRANSPORT-28's literal zero-copy path has
no `sendfile`-shaped API in Node's HTTP client stack. TRANSPORT-27's
Content-Length half is N/A: `Response.body` is a raw `ReadableStream`, with no
declared-length field for a -1 sentinel to live in. TRANSPORT-14's degrade path
is tested at its source, not end to end, because both native parsers reject a
control byte in a header value at the wire first. TRANSPORT-30's custom
`challengeHandler` cannot be dispatched on undici at all — `ProxyAgent` takes
its credential solely from its constructor, which runs before any challenge is
seen — so it warns at construction and again on the first real 407, and proxy
auth falls back to Basic. That last one is a deviation from the phase plan,
which had specified a retry-with-stamped-credential flow; Deviation Ledger
row 13.

* chore: resolve failing ci checks, add a new skill to run CI checks locally.

* chore: resolve failing ci checks, fixes on the ci skill.

* chore: resolve failing ci checks, fixes on the ci skill.
Ships `@dexpace/rx`, exposing Phase 6b's `SseStream`/`typedSseStream` and
Phase 6c's `Paginator` as RxJS `Observable`s, per
docs/product-spec/18-asynchronous-runtime-adapter-contract.md
(ASYNC-1..ASYNC-22), docs/product-spec/13-server-sent-events-and-streaming.md
(SSE-41), and
docs/superpowers/specs/2026-07-28-phase8b-async-runtime-design.md.

New `packages/rx/src/`, the workspace's fifth package:

- `sse.ts` — `sseEvents$`, `typedSse$` (SSE-41, SSE-33..SSE-36, ASYNC-21).
  Single-subscription, and deliberately so: `SseStream` wraps an already-open,
  single-use response body (BODY-14) and is itself single-pass (SSE-26), so a
  second `subscribe()` reaches the facade's own guard and surfaces
  `SseStreamError` through the error channel. The restriction is inherited and
  documented, not reimplemented — there is no honest way to make
  re-subscription mean anything without a second HTTP call this package does
  not make.
- `pagination.ts` — `pageItems$`, `pages$` (PAGE-8). Cold and repeatable, the
  opposite asymmetry: `.items()`/`.pages()` build a fresh generator per call,
  and the wrapper defers that call to `[Symbol.asyncIterator]()` so each
  subscription drives an independent fetch sequence rather than reusing the
  first walk's exhausted generator.
- `from-async-iterable.ts` — `fromAsyncIterable`, `@internal` (ASYNC-6,
  ASYNC-13, ASYNC-21). See below; this file is a deviation from the plan, not
  a component it called for.
- `index.ts` — the four wrappers, nothing else.

**The bridge is hand-written, against this phase's own instruction.** The
design (§1) and plan (Global Constraints) both said: do not write an
`AsyncIterable`→`Observable` pull loop, use RxJS's `from()`, and *prove* it
satisfies each clause instead of assuming it. The proof failed on one.
`rxjs@7.8.2`'s async-iterable path (`internal/observable/innerFrom.js`) is a
bare `for await` that tests `subscriber.closed` only *after* a pull resolves,
so unsubscribing while a pull is suspended reaches the source only if and when
the server sends again. For pagination that is invisible; for SSE it is the
common case — an idle event stream is permanently suspended, so
`unsubscribe()` would leave the response body unreleased and the connection
open indefinitely. That is ASYNC-6's bidirectional-cancellation clause
unsatisfied, and SSE-30's release obligation with it. `fromAsyncIterable` is
the same loop plus a teardown that releases the caller-supplied source and
drives `iterator.return()` — release first, because closing the source is what
settles the suspended pull an async generator's queued `return()` would
otherwise sit behind. Scope is exactly the failing clause: no scheduler, no
error re-wrapping, no retry, no buffering. The conformance suite's last case
asserts the *defect* in RxJS's own `from()` alongside this module's fix, so it
fails the day RxJS closes the gap and the module should be deleted.

No RxJS scheduler appears in this package's production code, which is what
keeps ASYNC-8..ASYNC-11 free: every pull runs inside the continuation chain
that called `subscribe()`, exactly what `AsyncLocalStorage` tracks. A caller
who adds `observeOn`/`subscribeOn` downstream reintroduces the boundary 7b's
snapshot helper exists for, and the TSDoc says so rather than leaving them to
find out from a missing trace ID.

`rxjs` is a **required** peer (`optional: false`), unlike `pino`/`debug` in
the logging adapters. Those peer on a library they never import — they are
structural over `PinoLike`/`DebugLike` and work with anything of that shape.
This package imports `Observable` unconditionally at module load, so an
optional marking would suppress the one warning that catches a missing install
and hand the consumer `ERR_MODULE_NOT_FOUND` instead. Peer rather than
dependency for the usual reason: a duplicate copy breaks
`Observable`/`Subscription` identity the way two classloaders break
`instanceof`, the same hazard core's own peer-dedup guard exists to prevent.

Tests: 25 across three colocated files, plus 8 Node-runtime cases in
`test/node-conformance/rx-bridge.test.mjs`. The Node layer is not
precautionary here — whether the release lands depends on Node's
`ReadableStream.cancel()` settling a suspended read and on Node's
async-generator `return()` queueing behind an in-flight `next()`, both
independent implementations of Bun's. The conformance suite covers all four
cancellation shapes (unsubscribe from inside `next()`, while a pull is
suspended, before the first emission, and with a rejected release); ASYNC-21's
poll-once-per-demand case uses a source that outlives demand — ten available,
two taken, two pulled — since a generator yielding exactly what the subscriber
consumes cannot tell one-pull-per-emission from a bridge that prefetches.

Gate wiring: `typecheck`, `build`, `api`, and `lint:publish` extended to the
new package; `verify-dual-consumption.mjs` drives a real `SseStream` through
`sseEvents$` under plain `node`; `verify-consumer-types.mjs` compiles all four
signatures against the built `.d.ts`. `verify:seam-1` and
`verify:runtime-floor` pick the package up on their own.

Open items registered in docs/open-items.md §M rather than left silent: M1
records the hand-written bridge and names its removal trigger; M2 flags that
the checklist's eight 🚫 `ASYNC-*` rows collapse onto `TRANSPORT-*`
requirements **no shipped package implements yet**, so an appendix-B sweep run
between 8b and 8a does not count them as covered; M3 confirms at
implementation time what the design predicted — ASYNC-18's scheduled-delay
primitive is a full-port collapse, not an 8b scope boundary, since the
as-built package contains no timer, scheduler, or backoff at all.
@Wahbeh-Mohammad Wahbeh-Mohammad self-assigned this Aug 29, 2026
@Wahbeh-Mohammad
Wahbeh-Mohammad merged commit a0d734d into mvp Aug 29, 2026
3 checks passed
@Wahbeh-Mohammad
Wahbeh-Mohammad deleted the 21-phase-8 branch August 29, 2026 08:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant