Skip to content

Commit acacab0

Browse files
fix(qa): the reference IHttpServer adapter refuses, parses or reads req.body as the IHttpRequest.body contract says (#24242)
Fixes #24083 Clause-②: no ## What changed `packages/qa/http-conformance/src/adapter.ts`: the reference `NodeHttpServer` now decides `req.body` the way the standard transport decides it (`readRequestBody` in `plugin-hono-server`'s `src/request-body.ts`). Per the seat's ruling (MATCH) the decision is mirrored inside `adapter.ts` and not imported. The package keeps `@objectstack/core` as its only runtime dependency, and no export was added anywhere. | Body sent | Reference before (`origin/main` `2bcd406a2`) | Both adapters after | |---|---|---| | declared JSON that does not parse (`{ not json`) | 200, `req.body` is `{}` | 400 `INVALID_REQUEST`, thrown from the read | | non-empty `text/plain` | 200, `req.body` is the string | 400 `INVALID_REQUEST`, thrown from the read | | `Text/Plain; charset=utf-8` (case) | 200, `{}` | 400 `INVALID_REQUEST` | | empty `text/plain` | 200, `""` | 200, `{}` | | multipart with fields | 200, `{}` | 200, the fields: last value wins, a `[]` key collects an array, a file part is a `File` | | malformed or empty multipart | 200, `{}` | 400 `INVALID_REQUEST` | | urlencoded `a=1&a=2&b[]=3&b[]=4` | `{"a":"2","b[]":"4"}` | `{"a":"2","b[]":["3","4"]}` | | handler catches the refusal, answers 500 | (no refusal) | 400 `INVALID_REQUEST`: the refusal replaces the handler's answer | | a fallback (`setFallbackHandler`) reads a refused body, then throws, answers its own, or writes nothing | 500 `Fallback handler failed`, its own answer, or the 404/405 | 400 `INVALID_REQUEST` on all three (patch round 1) | | `rawBody()` on a body `req.body` refuses | the bytes | the bytes (unchanged) | I measured all 17 request shapes with one probe that drives both adapters over a real socket, and, in patch round 1, 12 fallback shapes with a second probe. After the change, every shape answers the same status and the same body bytes on both. The one difference is the `charset=utf-8` parameter on the reference's JSON `Content-Type`. Every reference JSON answer already carried it before this change. How it works: - **The decision.** One module-private `readRequestBody` copies the standard transport's arms and its four refusal messages word for word. - Declared JSON (matched case-insensitively): empty or whitespace is `{}`, anything else that does not parse is refused. - A form, `multipart/form-data` or `application/x-www-form-urlencoded`, is parsed by Node's own Fetch `Response.formData()`. This is the parser hono's `bufferToFormData` calls. The field shape follows hono 4.13.12's `convertFormDataToBodyData` with `all: false, dot: false`, and its null prototype too. - For any other media type nothing is read. The body is refused on read when the framing headers declare one (RFC 9112 §6.3), and is `{}` otherwise. - GET and HEAD read no bytes from the socket, as `@hono/node-server` hands them a request with no body. - **`req.body` is an accessor** with a setter, as on the standard transport. A route that reads only `rawBody()` never meets the refusal. `rawBody()` now caches the read as one promise, so a body the decision already consumed hands back the same bytes, or the same read failure. - **The answer side** (mechanism assumption 1, measured below). On a matched route and in the fallback alike, once a read of `req.body` meets the refusal, anything the handler writes after it is dropped. `writeBodyRefusal` then answers `400 {"success":false,"error":{"code":"INVALID_REQUEST","message":...}}`, the two keys the standard transport's `declaredEnvelopeForThrow` renders for this throw. In the fallback branch (patch round 1, seat ruling `6244250024`) it answers on every arm: the fallback threw, caught and answered, or caught and wrote nothing. This is what the standard transport's fallback answers, because it runs through the same `runHandler` as its routes. Untouched: the generic catch arm, which reads `statusCode` only and answers an `{error}` body (`adapter.test.ts`'s `maps thrown errors with statusCode` pins it), and the fallback's `500 Fallback handler failed` for a throw that read no refused body (`fallback-seam.conformance.test.ts` G5-a pins it). Guarantee 4's docblock now names the one exception: a fallback that read a refused body. New `packages/qa/http-conformance/src/request-body.conformance.test.ts`. Its route handlers read `req.body` and run on BOTH adapters over a real socket. Per adapter, it covers: - malformed declared JSON; - non-empty `text/plain`; - multipart fields: single, repeated, `[]`, a file part; - a handler that catches the refusal and writes its own answer; - `rawBody()` of a refused body; - the controls: no body, an empty JSON body and an empty `text/plain` body are each `{}`, and valid JSON is the document. - the fallback (patch round 1): a fallback that reads a refused body and then throws, catches and answers, or catches and writes nothing is answered with the refusal. Its controls: a fallback that never reads the body and writes nothing leaves the 404, and the 405 + `Allow`; one that reads a valid JSON body gets the document. A parity block then sends each of 16 rows to both adapters and asserts the same status and the same bytes, refusal messages included. A change to either side's wording therefore reds here. `packages/qa/http-conformance/README.md` (patch round 1): the one line that listed multipart parsing as deliberately absent now says what the adapter does. JSON and forms (multipart included) are parsed into `req.body`, and every other media type stays raw behind the lazy `req.rawBody()`. ## PM mechanism assumptions, measured 1. **Partly falsified.** - Held: the standard transport refuses from the `body` read, not before the handler, and a route that reads only `rawBody()` is unaffected. - Did not hold: its answer does not take "the same declared-error path every thrown declared error takes". `runHandler` has a dedicated `refusedBodyRead` arm that answers the refusal whatever the handler made of the throw, a caught throw included (probe: a handler that caught it and wrote 500 still answered 400). Only the renderer, `declaredEnvelopeForThrow`, is shared. - The reference did not render a declared throw at all. A route throwing `{ status: 400, code: 'INVALID_REQUEST' }` answered `500 {"error":"declared refusal"}`. So the answer-side change above was required. It is the smallest change that reproduces the replacement, and it does not touch the generic catch arm. 2. **Held.** The standard transport parses both form types through `parseBody()`. Node's built-in `Response.formData()` matches every field shape the case reaches without a dependency: a single value, a repeated key (last wins), a `[]` key (array) and a file part (`File`). 3. **Held.** `rawBody()` reads the bytes of every body, refused ones included. The suite pins it for malformed JSON and `text/plain`. Multipart `rawBody()` is the held-off hono cache question and is not asserted. 4. **Held.** No existing assert pinned an old answer, so `adapter.test.ts` is unchanged. `fallback-seam.conformance.test.ts` is unchanged too: its only 500 assert, G5-a, is a fallback that throws its own error on a body-less GET and never reads a refused body. These suites drive the reference and read `body`, and all pass on `6544c545a`: - `adapter.test.ts` (16) - `fallback-seam.conformance.test.ts` (18) - `conformance.integration.test.ts` (30) - `query-multiplicity.conformance.test.ts` (8) - `response-observation.conformance.test.ts` (14) ## Ablation (once, the card's acceptance 3) `node scripts/ablation-replace.mjs` (WRAP mode, with an `EXIT INT TERM` trap) restored the old arm `let body: any = {}; try { body = text ? JSON.parse(text) : {}; } catch { body = {}; }` in place of the malformed-JSON refusal. No build is involved: the suite imports `./adapter.js` from source. - The mutation landed on disk: anchor 1 to 0, old arm 0 to 1, blob `8204c4fa1a35` to `9b952dea3e0e`. - **Red:** `pnpm --filter @objectstack/http-conformance exec vitest run --maxWorkers=2 src/request-body.conformance.test.ts`, verify-lock `VERDICT command-exit 1`, `Tests 2 failed | 22 passed (24)`. The two failures: - `... on the 'node' adapter > a malformed declared-JSON body is refused 400 INVALID_REQUEST at the read, never read as {}` - the parity row `/echo · malformedJson` - Every `hono` leg stayed green. The parity row is cross-adapter by construction, so it reds whenever the reference drifts. - **Restore** proved: blob equals HEAD (`8204c4fa1a35`), and `git diff HEAD` is empty. - **Green:** same command on `9e5b8b1e2`, `VERDICT command-exit 0`, `Tests 24 passed (24)`. Patch round 1, once: `node scripts/ablation-replace.mjs --delete` (WRAP mode, with an `EXIT INT TERM` trap) removed the fallback branch's one refusal-answer line, `if (refusedBodyRead) return this.writeBodyRefusal(nodeRes, refusedBodyRead);`. - The mutation landed on disk: line count 1 to 0, blob `d3cdbc3cfd7a` to `54509553a9f2`. - **Red:** the same vitest command, `VERDICT command-exit 1`, `Tests 6 failed | 34 passed (40)`. The six failures: - the three `node` fallback legs: the refused-read-then-throw leg answered `500 Fallback handler failed`, and the caught-and-answered and caught-and-silent legs answered `404 ENDPOINT_NOT_FOUND`; - their three parity rows: `/fallback/throw`, `/fallback/caught-answers`, `/fallback/caught-silent`. - Every `hono` leg stayed green, and so did both fallback controls. - **Restore** proved: blob equals HEAD (`d3cdbc3cfd7a`), and `git diff HEAD` is empty. - **Green:** the whole suite on `6544c545a`, `Tests 190 passed (190)`, with `request-body.conformance.test.ts` at 40 of 40. ## Tests (final head `6544c545a`, after merging `origin/main` `7be3cc728`) - First rebuilt the closure under the verify lock: `pnpm turbo run build --filter='@objectstack/http-conformance^...' --concurrency=2`, `VERDICT command-exit 0`, 36/36 tasks. - `pnpm --filter @objectstack/http-conformance exec vitest run --reporter=verbose`: exit 0, `Test Files 15 passed (15)`, `Tests 190 passed (190)`. - `pnpm --filter @objectstack/http-conformance typecheck`: exit 0. This and the line above ran joined by `&&` under one `VERDICT command-exit 0`. - `tsc --noEmit` passes, and so does `check:test-typecheck`: 3 files / 27 errors / 10 signatures held, unchanged. - The new test file carries no ledger entry, so it compiles with zero errors. - `test:repo`, each exit captured on its own, all 0 (re-run in patch round 1, spec included, since its repo tests walk `packages/**`): | Package | Files | Tests | |---|---|---| | `@objectstack/core` | 6 | 86 | | `@objectstack/objectql` | 1 | 5 | | `@objectstack/rest` | 5 | 262 passed, 14 skipped | | `@objectstack/runtime` | 3 | 808 | | `@objectstack/types` | 1 | 11 | | `@objectstack/spec` | 67 | 1115 | ## Gates (final head `6544c545a`) - `pnpm exec eslint --no-inline-config` on both changed `.ts` files: exit 0, no output. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived the same 52 commands as round 0. 51 exited 0. - `pnpm check:dual-build-cjs-loads` exited 3 (`PREREQUISITE NOT MET`): it reads every package's `dist/`, and only this package's closure is built locally. NOT MEASURED locally; CI's `Lint & Repo Gates` runs it and is green on `6544c545a`. - `dispatch-gates --ran`: `52 derived famil(ies) accounted for — 51 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)`. - Clause-② judged from this diff: - `scripts/pm/clause2-line.mjs` has no CLI. Run bare it prints nothing and exits 0. Its `readClause2Line` export reads this body's line as `{"kind":"declared","value":"no","arm":null}`. - `node scripts/check-changeset-no-major.mjs --base origin/main --event` (this body) on the final diff: exit 0, its `LEVEL AXIS` line for a declared clause-② `no`, with no direction arm. No narrowing face was read. ## Changeset None. `@objectstack/http-conformance` is `"private": true` and publishes nothing, which is the repo's private-package fast lane for `skip-changeset`. The package's `CHANGELOG.md` has only ever carried dependency bumps, and the precedent test-only change `6a53564b9` shipped without one. The `skip-changeset` label is applied after this PR is created. ## Acceptance notes - **README line (fixed in patch round 1).** The README line that listed multipart parsing as deliberately absent is corrected in this PR, per seat ruling `6244250024`. - **Fallback seam (fixed in patch round 1).** A fallback that reads a refused body is now answered with the refusal on the reference too, on every arm, as on the standard transport. Seat ruling `6244250024` lifted the hold-off for the answer path only. - **Fallback contract wording (spec, held off).** In `packages/spec/src/contracts/http-server.ts`, the `setFallbackHandler` sentence "A handler that writes no response leaves the adapter's standard unmatched-request answer in place" names no exception. Both adapters now answer a fallback that read a refused body and wrote nothing with `400 INVALID_REQUEST`, as the `IHttpRequest.body` paragraph says the standard transport does. The wording is the spec lane's; nothing here edits it. - **Handler writes before it reads.** A handler that WROTE before it read a refused body cannot be replaced on `node:http`: its bytes are already on the wire. The standard transport buffers the answer and still replaces it. The code documents this; no conformance case reaches it. - **Middleware reads `req.body`.** `use()` middleware on the reference receives the real request, so a middleware that reads `req.body` meets the refusal. The standard transport hands middleware an empty request. The middleware contract does not populate `body`, and no case reaches it. - **Content-Type charset.** The reference's JSON answers carry `Content-Type: application/json; charset=utf-8`, and the standard transport's carry `application/json`. This predates the change and covers every reference JSON answer. - **Hono multipart `rawBody()` cache.** The hold-off named by the card is not touched or asserted here. --- _Generated by [Claude Code](https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 646303e commit acacab0

3 files changed

Lines changed: 574 additions & 32 deletions

File tree

‎packages/qa/http-conformance/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ suite is what breaks.
4040

4141
Deliberately absent (each a known escape hatch whose consumers
4242
feature-detect and degrade): `getRawApp()` (Hono-specific — metadata HMR,
43-
cloud-connection routes, static/SPA + CORS + Server-Timing), `mount()`,
44-
multipart parsing (binary bodies stay raw behind the lazy `req.rawBody()`).
43+
cloud-connection routes, static/SPA + CORS + Server-Timing) and `mount()`.
44+
Bodies: JSON and forms (multipart included) are parsed into `req.body`, and every other media type stays raw behind the lazy `req.rawBody()`.
4545

4646
## Not a product server
4747

‎packages/qa/http-conformance/src/adapter.ts‎

Lines changed: 241 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -98,13 +98,162 @@ export function compileRoute(pattern: string): { regex: RegExp; keys: string[] }
9898
return { regex: new RegExp(`^${body}$`), keys };
9999
}
100100

101-
/** Body-parsing content types we consume eagerly; everything else stays raw. */
102-
function isEagerlyParsed(contentType: string): boolean {
103-
return (
104-
contentType.includes('application/json') ||
105-
contentType.includes('application/x-www-form-urlencoded') ||
106-
contentType.startsWith('text/')
107-
);
101+
/*
102+
* ── The request body: parsed by its declared content type, or REFUSED ────────
103+
*
104+
* [#24083] `IHttpRequest.body` (`@objectstack/spec/contracts`) is the parsed
105+
* JSON document for `application/json`, the parsed fields of a form
106+
* (`multipart/form-data`, `application/x-www-form-urlencoded`), `{}` when the
107+
* request carries no body, and otherwise a REFUSAL thrown from the read, never
108+
* an invented value. This adapter used to read malformed declared JSON as `{}`,
109+
* a multipart body as `{}`, and a `text/*` body as a string.
110+
*
111+
* What follows MIRRORS the standard transport's decision
112+
* (`readRequestBody` in plugin-hono-server's `src/request-body.ts`, whose
113+
* module note says why each arm answers what it does), and does not import
114+
* it: this adapter is the instrument that measures that transport, and a
115+
* reference that ran the subject's code could not see the subject drift. The
116+
* two are held equal from outside instead, byte for byte, by
117+
* `request-body.conformance.test.ts`. So a change to either side's wording or
118+
* arms is a change to both.
119+
*
120+
* - Declared JSON (`application/json`, matched case-insensitively): read as
121+
* text and parsed. Empty or whitespace-only is `{}`; anything else that does
122+
* not parse is a refusal.
123+
* - A form: parsed into fields by the Fetch `Response.formData()` that Node
124+
* ships (the very parser the standard transport's framework calls), in its
125+
* field shape: one value per key, the last one winning, except a key that
126+
* ends in `[]`, which collects every value into an array; a file part is a
127+
* `File`. A parse failure is a refusal.
128+
* - Any other media type, or none: nothing is read, so the bytes stay on the
129+
* socket for `rawBody()`. A request that carries a body by its framing
130+
* headers (RFC 9112 §6.3) is a refusal on read; one that carries none is
131+
* `{}`.
132+
*
133+
* GET and HEAD carry no body here, as on the standard transport, whose
134+
* framework hands those methods a request with no body: nothing is read from
135+
* the socket for them, and the decision above runs on empty bytes.
136+
*/
137+
138+
/**
139+
* The refusal, as a DECLARED ADR-0112 throw: `status` and a registered `code`
140+
* (`INVALID_REQUEST` is on the error-code ledger). Module-private.
141+
*/
142+
class RequestBodyRefusal extends Error {
143+
readonly status = 400;
144+
readonly code = 'INVALID_REQUEST';
145+
146+
constructor(message: string) {
147+
super(message);
148+
this.name = 'RequestBodyRefusal';
149+
}
150+
}
151+
152+
type RequestBodyReading =
153+
| { readonly body: unknown; readonly refusal?: undefined }
154+
| { readonly refusal: RequestBodyRefusal };
155+
156+
/** The refusal tail every message ends with: what the transport did NOT do. */
157+
const NOT_EMPTY = 'It was refused rather than read as an empty body';
158+
159+
const MALFORMED_JSON_BODY_MESSAGE =
160+
`The request body could not be parsed as JSON (it was sent as application/json). ${NOT_EMPTY}: `
161+
+ 'send a well-formed JSON document, or no body at all.';
162+
163+
const UNREADABLE_BODY_MESSAGE = `The request body could not be read. ${NOT_EMPTY}.`;
164+
165+
/** A media type as the client spelled it, echoed only when it is a plain token pair. */
166+
function namedMediaType(mediaType: string): string {
167+
if (mediaType === '') return 'with no Content-Type';
168+
return /^[a-z0-9][a-z0-9!#$&^_.+-]{0,63}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,63}$/.test(mediaType)
169+
? `as ${mediaType}`
170+
: 'with an unrecognised Content-Type';
171+
}
172+
173+
function malformedFormMessage(mediaType: string): string {
174+
return `The request body could not be parsed as form data (it was sent ${namedMediaType(mediaType)}). ${NOT_EMPTY}.`;
175+
}
176+
177+
function unreadMediaTypeMessage(mediaType: string): string {
178+
return `The request body was sent ${namedMediaType(mediaType)}, which this server does not read into a `
179+
+ `request body. ${NOT_EMPTY}: send it as application/json.`;
180+
}
181+
182+
/** RFC 9112 §6.3: a `Content-Length` that is not `0`, or any `Transfer-Encoding`, is a body. */
183+
function declaresBody(headers: IncomingMessage['headers']): boolean {
184+
const length = headers['content-length'];
185+
if (length !== undefined && length.trim() !== '') return Number(length) !== 0;
186+
return headers['transfer-encoding'] !== undefined;
187+
}
188+
189+
/**
190+
* A form's fields in the standard transport's shape (see the block above).
191+
* Built through `Object.fromEntries`, so a field named `__proto__` is an own
192+
* key and never reaches the prototype chain, and then left with no prototype,
193+
* as the standard transport's object has none.
194+
*/
195+
function formFields(form: FormData): Record<string, unknown> {
196+
const fields = new Map<string, unknown>();
197+
form.forEach((value, key) => {
198+
if (!key.endsWith('[]')) {
199+
fields.set(key, value);
200+
return;
201+
}
202+
const values = fields.get(key);
203+
if (Array.isArray(values)) values.push(value);
204+
else fields.set(key, [value]);
205+
});
206+
return Object.setPrototypeOf(Object.fromEntries(fields), null) as Record<string, unknown>;
207+
}
208+
209+
/**
210+
* Decide the body (see the block above). `bytes` is the read of the request
211+
* stream when this decision consumed it, so `rawBody()` hands back those same
212+
* bytes, or that same failure, instead of reading a drained socket.
213+
*/
214+
async function readRequestBody(
215+
nodeReq: IncomingMessage,
216+
method: string,
217+
): Promise<{ reading: RequestBodyReading; bytes?: Promise<Buffer> }> {
218+
const contentType = String(nodeReq.headers['content-type'] ?? '');
219+
const lowered = contentType.toLowerCase();
220+
const readStream = (): Promise<Buffer> =>
221+
method === 'GET' || method === 'HEAD' ? Promise.resolve(Buffer.alloc(0)) : readBody(nodeReq);
222+
223+
if (lowered.includes('application/json')) {
224+
const bytes = readStream();
225+
let text: string;
226+
try {
227+
text = (await bytes).toString('utf8');
228+
} catch {
229+
return { reading: { refusal: new RequestBodyRefusal(UNREADABLE_BODY_MESSAGE) }, bytes };
230+
}
231+
if (text.trim() === '') return { reading: { body: {} }, bytes };
232+
try {
233+
return { reading: { body: JSON.parse(text) }, bytes };
234+
} catch {
235+
return { reading: { refusal: new RequestBodyRefusal(MALFORMED_JSON_BODY_MESSAGE) }, bytes };
236+
}
237+
}
238+
239+
const mediaType = lowered.split(';')[0]!.trim();
240+
if (mediaType === 'multipart/form-data' || mediaType === 'application/x-www-form-urlencoded') {
241+
const bytes = readStream();
242+
try {
243+
const form = await new Response(await bytes, {
244+
headers: { 'Content-Type': contentType.replace(/^[^;]+/, (type) => type.toLowerCase()) },
245+
}).formData();
246+
return { reading: { body: formFields(form) }, bytes };
247+
} catch {
248+
return { reading: { refusal: new RequestBodyRefusal(malformedFormMessage(mediaType)) }, bytes };
249+
}
250+
}
251+
252+
return {
253+
reading: declaresBody(nodeReq.headers)
254+
? { refusal: new RequestBodyRefusal(unreadMediaTypeMessage(mediaType)) }
255+
: { body: {} },
256+
};
108257
}
109258

110259
function readBody(req: IncomingMessage): Promise<Buffer> {
@@ -211,7 +360,9 @@ export class NodeHttpServer implements IHttpServer {
211360
* 4. **A handler that writes nothing leaves the standard answer.** The
212361
* branch falls through to {@link writeUnmatchedResponse} — the 404, or
213362
* the 405 + `Allow`, unchanged. Writing nothing is the documented way
214-
* to say "not mine".
363+
* to say "not mine". The one exception is a fallback that read a body
364+
* this adapter refused: as on the primary adapter, the refusal is then
365+
* the answer, whether the fallback threw, answered or wrote nothing.
215366
*
216367
* Locked cross-adapter by this package's own
217368
* `fallback-seam.conformance.test.ts`, which runs the same cases against
@@ -285,6 +436,31 @@ export class NodeHttpServer implements IHttpServer {
285436
}));
286437
}
287438

439+
/**
440+
* [#24083] The answer to a request whose handler (a matched route's, or
441+
* the fallback's) read a body this adapter refused: `400 INVALID_REQUEST`
442+
* in the ADR-0112 envelope, the same two keys the primary adapter renders
443+
* for this throw. The request's body was never readable, so no other
444+
* answer describes it.
445+
*
446+
* One shape cannot be matched on `node:http`: a handler that WROTE before
447+
* it read has put bytes on the wire, which the primary adapter (it holds
448+
* the handler's answer until the handler returns) still replaces. Here
449+
* what was sent stands, and the response is closed.
450+
*/
451+
private writeBodyRefusal(nodeRes: ServerResponse, refusal: RequestBodyRefusal) {
452+
if (nodeRes.headersSent) {
453+
if (!nodeRes.writableEnded) nodeRes.end();
454+
return;
455+
}
456+
nodeRes.statusCode = refusal.status;
457+
nodeRes.setHeader('Content-Type', 'application/json; charset=utf-8');
458+
nodeRes.end(JSON.stringify({
459+
success: false,
460+
error: { code: refusal.code, message: refusal.message },
461+
}));
462+
}
463+
288464
private match(method: string, path: string): { route: CompiledRoute; params: Record<string, string> } | undefined {
289465
const normalized = normalize(path);
290466
// HEAD is answered by GET handlers (body suppressed by node core for
@@ -373,24 +549,15 @@ export class NodeHttpServer implements IHttpServer {
373549
const query = Object.fromEntries(queryEntries) as Record<string, string | string[]>;
374550

375551
// ── Body ────────────────────────────────────────────────────────────
376-
// JSON / urlencoded / text bodies are consumed eagerly (like the
377-
// primary adapter); anything else (octet-stream, multipart) stays on
378-
// the socket for the lazy `rawBody()` accessor so binary uploads pay
379-
// no parsing cost.
380-
const contentType = String(nodeReq.headers['content-type'] || '');
381-
let body: any = {};
382-
let bufferedBody: Buffer | undefined;
383-
if (method !== 'GET' && method !== 'HEAD' && isEagerlyParsed(contentType)) {
384-
bufferedBody = await readBody(nodeReq);
385-
const text = bufferedBody.toString('utf8');
386-
if (contentType.includes('application/json')) {
387-
try { body = text ? JSON.parse(text) : {}; } catch { body = {}; }
388-
} else if (contentType.includes('application/x-www-form-urlencoded')) {
389-
body = Object.fromEntries(new URLSearchParams(text));
390-
} else {
391-
body = text;
392-
}
393-
}
552+
// [#24083] Declared JSON and forms are read eagerly, as on the primary
553+
// adapter; every other media type stays on the socket for the lazy
554+
// `rawBody()` accessor, so a binary upload pays no parsing cost. What
555+
// each media type answers is `readRequestBody`'s (above).
556+
const { reading, bytes } = await readRequestBody(nodeReq, method);
557+
let bodyReading: RequestBodyReading = reading;
558+
let bufferedBody: Promise<Buffer> | undefined = bytes;
559+
/** Set once a READ of `req.body` met the refusal; see the route branch below. */
560+
let refusedBodyRead: RequestBodyRefusal | undefined;
394561

395562
// node:http exposes headers as a lowercased record already, Host
396563
// included — no backfill needed (the Fetch-API Host backfill in the
@@ -400,26 +567,51 @@ export class NodeHttpServer implements IHttpServer {
400567
// primary adapter hands its fallback (its `readRouteParams` guard).
401568
params: matched?.params ?? {},
402569
query,
403-
body,
570+
/**
571+
* [#24083] An accessor rather than a value, as on the primary
572+
* adapter: reading a body this adapter refused throws the refusal,
573+
* which stops the handler at the read. A route that reads
574+
* `rawBody()` instead never reaches it. Writable as before.
575+
*/
576+
get body(): any {
577+
if (bodyReading.refusal) {
578+
refusedBodyRead = bodyReading.refusal;
579+
throw bodyReading.refusal;
580+
}
581+
return bodyReading.body;
582+
},
583+
set body(value: any) {
584+
bodyReading = { body: value };
585+
},
404586
headers: nodeReq.headers as Record<string, string | string[]>,
405587
method,
406588
path,
407-
rawBody: async () => bufferedBody ?? (bufferedBody = await readBody(nodeReq)),
589+
// The bytes of every body, a refused one included.
590+
rawBody: () => (bufferedBody ??= readBody(nodeReq)),
408591
};
409592

593+
/**
594+
* [#24083] A read of a refused body makes the refusal THE answer,
595+
* whatever the handler made of the throw, on a matched route and in
596+
* the fallback alike, as on the primary adapter: anything the handler
597+
* writes after the read is dropped, and {@link writeBodyRefusal}
598+
* answers once it returns.
599+
*/
600+
const refusalStands = (): boolean => refusedBodyRead !== undefined;
601+
410602
// ── Response wrapper (contract + streaming extension) ───────────────
411603
const isHead = method === 'HEAD';
412604
let streaming = false;
413605
const res: IHttpResponse & { write: (chunk: string | Uint8Array) => void; end: () => void } = {
414606
status: (code: number) => { nodeRes.statusCode = code; return res; },
415607
header: (name: string, value: string | string[]) => { nodeRes.setHeader(name, value); return res; },
416608
json: (data: any) => {
417-
if (nodeRes.writableEnded) return;
609+
if (nodeRes.writableEnded || refusalStands()) return;
418610
if (!nodeRes.headersSent) nodeRes.setHeader('Content-Type', 'application/json; charset=utf-8');
419611
nodeRes.end(isHead ? undefined : JSON.stringify(data));
420612
},
421613
send: (data: string | Uint8Array | ArrayBuffer) => {
422-
if (nodeRes.writableEnded) return;
614+
if (nodeRes.writableEnded || refusalStands()) return;
423615
if (typeof data === 'string') {
424616
if (!nodeRes.headersSent) nodeRes.setHeader('Content-Type', 'text/html; charset=utf-8');
425617
nodeRes.end(isHead ? undefined : data);
@@ -432,10 +624,12 @@ export class NodeHttpServer implements IHttpServer {
432624
},
433625
// Streaming extension (SSE): map straight onto the native stream.
434626
write: (chunk: string | Uint8Array) => {
627+
if (refusalStands()) return;
435628
streaming = true;
436629
if (!nodeRes.writableEnded) nodeRes.write(chunk);
437630
},
438631
end: () => {
632+
if (refusalStands()) return;
439633
if (!nodeRes.writableEnded) nodeRes.end();
440634
},
441635
};
@@ -447,9 +641,18 @@ export class NodeHttpServer implements IHttpServer {
447641
// an unmatched request on this adapter today either, and changing
448642
// that is a separate decision from installing this seam.
449643
const handler = fallback as RouteHandler;
644+
let threw = false;
450645
try {
451646
await handler(req, res as IHttpResponse);
452647
} catch {
648+
threw = true;
649+
}
650+
// [#24083] The fallback read a body this adapter refused: the
651+
// refusal is the answer on every arm (thrown, caught and answered,
652+
// caught and silent), as on the primary adapter, whose fallback
653+
// runs through the same `runHandler` as its routes.
654+
if (refusedBodyRead) return this.writeBodyRefusal(nodeRes, refusedBodyRead);
655+
if (threw) {
453656
// Prefer failing to falling back: a fallback that THREW is a
454657
// broken consumer, and reporting its failure as this adapter's
455658
// ordinary 404 would hide it behind the most unremarkable
@@ -481,6 +684,10 @@ export class NodeHttpServer implements IHttpServer {
481684
void advanced; // chain always continues — parity with the primary adapter
482685
}
483686
await matched.route.handler(req, res as IHttpResponse);
687+
if (refusedBodyRead) {
688+
this.writeBodyRefusal(nodeRes, refusedBodyRead);
689+
return;
690+
}
484691
// Handler resolved without producing a response and without
485692
// starting a stream (SSE handlers legitimately resolve while the
486693
// stream is still open — dispatcher drains it in the background).
@@ -493,6 +700,10 @@ export class NodeHttpServer implements IHttpServer {
493700
}));
494701
}
495702
} catch (err: any) {
703+
if (refusedBodyRead) {
704+
this.writeBodyRefusal(nodeRes, refusedBodyRead);
705+
return;
706+
}
496707
if (!nodeRes.writableEnded) {
497708
if (!nodeRes.headersSent) {
498709
nodeRes.statusCode = typeof err?.statusCode === 'number' ? err.statusCode : 500;

0 commit comments

Comments
 (0)