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

Filter by extension

Filter by extension

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

Body lifecycle review fixes.

Security:

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

Correctness:

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

Public API:

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

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

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

Expand Down
223 changes: 218 additions & 5 deletions packages/core/etc/core.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8Array>): Promise<void>;
}
export { Body_2 as Body }

// @public
export interface Builder<T> {
build(): T;
Expand All @@ -12,6 +26,24 @@ export interface Builder<T> {
// @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<Uint8Array>): Promise<void>;
}

// @public
export function byteArrayBody(bytes: Uint8Array, mediaType?: string): ByteArrayBody;

// @public
export class CancellationError extends DexpaceError {
constructor(message: string, options?: ErrorOptions);
Expand All @@ -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);
Expand All @@ -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<Uint8Array>): Promise<void>;
}

// @public
export function formUrlEncodedBody(input: FormUrlEncodedInput): FormUrlEncodedBody;

// @public
export type FormUrlEncodedInput = QueryParams | ReadonlyMap<string, FormUrlEncodedValue | readonly FormUrlEncodedValue[]> | Record<string, FormUrlEncodedValue | readonly FormUrlEncodedValue[]> | readonly (readonly [string, FormUrlEncodedValue])[];

// @public
export type FormUrlEncodedValue = string | number | boolean | bigint | null;

// @public
export class HeaderName {
equals(other: HeaderName): boolean;
Expand Down Expand Up @@ -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<Body_2>;

// @public
export class MediaType {
get charset(): string | undefined;
Expand All @@ -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<Uint8Array>): Promise<void>;
}

// @public
export function multipartBody(parts: readonly MultipartPart[], boundary?: string): MultipartBody;

// @public
export class MultipartBodyBuilder implements Builder<MultipartBody> {
// (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);
Expand All @@ -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<Record<string, string>> | undefined;
Expand Down Expand Up @@ -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;
Expand All @@ -189,7 +326,7 @@ export class RequestBodyNotAllowedError extends DomainModelError {

// @public
export class RequestBuilder implements Builder<Request_2> {
body(body: unknown): this;
body(body: Body_2 | undefined): this;
build(): Request_2;
headers(headers: Headers_2): this;
method(method: Method): this;
Expand Down Expand Up @@ -246,25 +383,45 @@ export class RequiredFieldError extends DomainModelError {

// @public
class Response_2 {
get body(): unknown;
// (undocumented)
[Symbol.asyncDispose](): Promise<void>;
constructor(request: Request_2, protocol: Protocol, status: Status, reasonPhrase: string | undefined, headers: Headers_2, body: ReadableStream<Uint8Array> | null);
get body(): ReadableStream<Uint8Array> | null;
bytes(): Promise<Uint8Array>;
close(): Promise<void>;
// (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<string>;
}
export { Response_2 as Response }

// @public
export class ResponseBuilder implements Builder<Response_2> {
body(body: unknown): this;
// (undocumented)
body(body: ReadableStream<Uint8Array> | 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;
}

Expand All @@ -284,12 +441,68 @@ export class Status {
static recognized(code: number): Status | undefined;
}

// @public
export class StreamBody implements Body_2 {
constructor(stream: ReadableStream<Uint8Array>, 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<Uint8Array>): Promise<void>;
}

// @public
export function streamBody(stream: ReadableStream<Uint8Array>, 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<Uint8Array>): Promise<void>;
}

// @public
export function stringBody(text: string, mediaType?: string): StringBody;

// @public
export function toHttpError(response: Response_2): Promise<HttpStatusError | null>;

// @public
export interface Transport {
close(): Promise<void>;
send(request: Request_2, options?: RequestOptions, signal?: AbortSignal): Promise<Response_2>;
}

// @public
export class TypedResponse<T> {
constructor(response: Response_2, parse: (response: Response_2) => Promise<T>);
// (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<T>;
}

// @public
export class UrlConstructionError extends DomainModelError {
}
Expand Down
Loading
Loading