Skip to content

Commit 6f3052e

Browse files
committed
fix(hono): createHonoApp reads a dispatched body by plugin-hono-server's rule, media type by media type
A JSON body sent as text/plain, and any other body that declares itself without declaring JSON or a form, is refused with 400 INVALID_REQUEST when the dispatched domain reads it, through the refuse-on-read value. A form is parsed from a clone, so the forwarded request stays unread. No body is {}. Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude <noreply@anthropic.com>
1 parent b1d5569 commit 6f3052e

1 file changed

Lines changed: 132 additions & 73 deletions

File tree

‎packages/adapters/hono/src/index.ts‎

Lines changed: 132 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { Hono } from 'hono';
44
import type { Context } from 'hono';
55
import { cors } from 'hono/cors';
6+
import { parseBody } from 'hono/utils/body';
67
import {
78
type ObjectKernel,
89
HttpDispatcher,
@@ -282,8 +283,9 @@ export function objectStackMiddleware(kernel: ObjectKernel) {
282283
}
283284

284285
/**
285-
* The JSON body this adapter hands the dispatcher, read WITHOUT consuming the
286-
* request (#22576).
286+
* The body this adapter hands the dispatcher: read by its declared media type,
287+
* by `@objectstack/plugin-hono-server`'s rule, or REFUSED when read (#24042);
288+
* never invented, and never read off the request it forwards (#22576).
287289
*
288290
* ## Why the raw request must stay readable
289291
*
@@ -299,86 +301,108 @@ export function objectStackMiddleware(kernel: ObjectKernel) {
299301
* verify an HMAC over the sender's exact JSON bytes — and a spaced JSON body
300302
* re-serialised from the parsed value is not those bytes.
301303
*
302-
* So the parse reads a CLONE: `clone()` tees the body, `c.req.raw` keeps the
303-
* other branch unread, and a domain that forwards the request gets every byte
304-
* the client sent, for every `Content-Type`, JSON included.
304+
* So whatever is parsed is parsed from a CLONE: `clone()` tees the body,
305+
* `c.req.raw` keeps the other branch unread, and a domain that forwards the
306+
* request gets every byte the client sent, for every `Content-Type`. One case
307+
* cannot be read from a clone: a body something upstream already consumed
308+
* through `c.req` — Hono's body cache then holds the only copy, and it is read
309+
* from that cache (`c.req.text()`, `c.req.parseBody()`), as
310+
* `plugin-hono-server` reads it.
305311
*
306-
* ## What the domains see is unchanged, deliberately
312+
* ⚠️ The cost is the tee: until the request is released, the unread branch
313+
* holds the body's bytes, so a JSON or form body is buffered twice instead of
314+
* once for the life of the request. These are API bodies the old path already
315+
* buffered whole; nothing here streams. A body of any other media type is not
316+
* read here at all.
307317
*
308-
* Hono's `c.req.json()` is `text()` then `JSON.parse`, with any failure mapped
309-
* to `{}` by the `.catch` this adapter put on it — so an empty body, a form
310-
* body and invalid JSON all became `{}`, and any JSON value (an array, `null`,
311-
* a number) came through as parsed. The clone is parsed by the same two steps
312-
* and every failure is the same `{}`, so the `body` argument is the value it
313-
* was for every input. One case cannot be read from a clone: a body something
314-
* upstream already consumed through `c.req` — Hono's body cache then holds the
315-
* only copy, and `c.req.json()` is asked exactly as before.
318+
* ## What a reading answers: `plugin-hono-server`'s rule (#24042)
316319
*
317-
* ⚠️ The cost is the tee: until the request is released, the unread branch
318-
* holds the body's bytes, so a body is buffered twice instead of once for the
319-
* life of the request. These are API bodies the old path already buffered
320-
* whole; nothing here streams.
321-
*
322-
* ## A declared JSON body that does not parse is refused BY ITS READER (#23914)
323-
*
324-
* …and is no longer `{}`. That `{}` reached the data domain as an empty query,
325-
* so a truncated `POST /data/:object/query` answered `200` with every row the
326-
* caller could read, and a truncated `PATCH` answered `200` for an update that
327-
* changed nothing.
328-
*
329-
* One rule on both Hono transports: the code that READS the parsed body
330-
* refuses it, nothing earlier. `@objectstack/plugin-hono-server` does that
331-
* with its `req.body` accessor; here the body is a `dispatch()` argument, so
332-
* for a body whose `Content-Type` declares `application/json` (matched
333-
* case-insensitively), is not blank, and does not parse, the value handed on
334-
* is {@link refusedOnRead}'s: any read of it — a property, `in`, its keys, a
335-
* spread, `JSON.stringify` — throws {@link RequestBodyRefusal} (a declared
336-
* `400` with the registered code `INVALID_REQUEST`) and records that it was
337-
* read. Both arms that call this then answer that refusal, through
338-
* `thrownJson` as the ADR-0112 envelope, whatever the domain made of the throw
339-
* — the same override `plugin-hono-server` applies — and only then.
320+
* One rule on both Hono transports. It is `readRequestBody` in
321+
* `@objectstack/plugin-hono-server` (`src/request-body.ts`), module-private
322+
* there and mirrored here, media type by media type:
323+
*
324+
* - **Declared JSON** (`Content-Type` includes `application/json`, matched
325+
* case-insensitively): read as text and parsed. Empty or whitespace-only is
326+
* `{}` (the REST data doors document an empty body as an empty query). A
327+
* body that does not parse, or cannot be read, is refused (#23914).
328+
* - **A form** (`multipart/form-data`, `application/x-www-form-urlencoded`):
329+
* parsed by Hono's own `parseBody`, the function `c.req.parseBody()` runs on
330+
* the other door. A form that does not parse is refused.
331+
* - **Any other media type, or none**: nothing is read. When the request
332+
* declares a body by its framing headers ({@link declaresBody}), it is
333+
* refused; when it declares none, it is `{}`.
334+
*
335+
* The content-type-blind parse this replaces read a JSON body sent as
336+
* `text/plain` as JSON, and handed `{}` for every other body that did not
337+
* declare JSON, a form included. `{}` reached the data domain as an empty
338+
* query, so a garbled body that did not declare JSON read the whole table on
339+
* this door, while `plugin-hono-server` refused it. The wire contract names
340+
* `Content-Type: application/json` as required
341+
* (`content/docs/api/wire-format.mdx`); parsing `text/plain` as JSON is a
342+
* second dialect, so it is refused, not parsed.
343+
*
344+
* ## A body that cannot be read is refused BY ITS READER (#23914)
345+
*
346+
* …and is never `{}`. The code that READS the parsed body refuses it, nothing
347+
* earlier. `@objectstack/plugin-hono-server` does that with its `req.body`
348+
* accessor; here the body is a `dispatch()` argument, so the value handed on
349+
* for every refusal above is {@link refusedOnRead}'s: any read of it — a
350+
* property, `in`, its keys, a spread, `JSON.stringify` — throws
351+
* {@link RequestBodyRefusal} (a declared `400` with the registered code
352+
* `INVALID_REQUEST`) and records that it was read. Both arms that call this
353+
* then answer that refusal, through `thrownJson` as the ADR-0112 envelope,
354+
* whatever the domain made of the throw — the same override
355+
* `plugin-hono-server` applies — and only then.
340356
*
341357
* Why on read and not before `dispatch()`. A domain that forwards the request
342358
* reads its own bytes from it and never reads `body`: `/webhooks/redeliver`
343359
* and the inbound automation hooks answer a body that is not JSON with their
344360
* own refusal, after their own sign-in or signature check, and the
345-
* `/approvals/act` form `POST` reads a form. Refusing before `dispatch()`
346-
* pre-empted them at this door only (measured: `/webhooks/redeliver` then
347-
* answered this adapter's message where the self-hosted mount answers the
348-
* member's, and an anonymous caller got `400` where the mount says `401`).
349-
* `dispatch()` itself never reads `body` (its gates read headers and the
350-
* path), so the gates still run first, as they do ahead of a route handler on
351-
* `plugin-hono-server`. No list of routes is kept here: whether a domain reads
352-
* the body is answered by the domain reading it.
353-
*
354-
* `@objectstack/plugin-hono-server` answers the same body with the same `code`
355-
* and `message` (`hono-body-refusal.test.ts` drives both doors).
356-
*
357-
* Every other input is read exactly as before, deliberately:
358-
* - an empty or whitespace-only body is `{}` (an empty query);
359-
* - a body that does not declare JSON keeps the content-type-blind parse and
360-
* its `{}` on failure;
361-
* - the request handed on beside it is unread in every case, so a domain that
362-
* forwards it reads every byte the client sent, a malformed body included.
361+
* `/approvals/act` form `POST` reads its form from the request. Refusing
362+
* before `dispatch()` pre-empted them at this door only (measured:
363+
* `/webhooks/redeliver` then answered this adapter's message where the
364+
* self-hosted mount answers the member's, and an anonymous caller got `400`
365+
* where the mount says `401`). `dispatch()` itself never reads `body` (its
366+
* gates read headers and the path), so the gates still run first, as they do
367+
* ahead of a route handler on `plugin-hono-server`. No list of routes is kept
368+
* here: whether a domain reads the body is answered by the domain reading it.
369+
*
370+
* `@objectstack/plugin-hono-server` answers the same body with the same
371+
* `status`, `code` and `message` (`hono-body-refusal.test.ts` drives both
372+
* doors over one table).
363373
*/
364374
async function readDispatchBody(c: Context): Promise<DispatchBody> {
365-
const declaresJson = (c.req.header('content-type') ?? '').toLowerCase().includes('application/json');
375+
const lowered = (c.req.header('content-type') ?? '').toLowerCase();
366376
const raw = c.req.raw;
367-
let text: string;
368-
try {
369-
// A body something upstream already read through `c.req` is in Hono's
370-
// body cache, which is the only copy left; `text()` reads that cache.
371-
text = raw.bodyUsed ? await c.req.text() : await raw.clone().text();
372-
} catch {
373-
return declaresJson ? refusedOnRead(new RequestBodyRefusal(UNREADABLE_BODY_MESSAGE)) : readable({});
377+
378+
if (lowered.includes('application/json')) {
379+
let text: string;
380+
try {
381+
text = raw.bodyUsed ? await c.req.text() : await raw.clone().text();
382+
} catch {
383+
return refusedOnRead(new RequestBodyRefusal(UNREADABLE_BODY_MESSAGE));
384+
}
385+
if (text.trim() === '') return readable({});
386+
try {
387+
return readable(JSON.parse(text));
388+
} catch {
389+
return refusedOnRead(new RequestBodyRefusal(MALFORMED_JSON_BODY_MESSAGE));
390+
}
374391
}
375-
try {
376-
return readable(JSON.parse(text));
377-
} catch {
378-
return declaresJson && text.trim() !== ''
379-
? refusedOnRead(new RequestBodyRefusal(MALFORMED_JSON_BODY_MESSAGE))
380-
: readable({});
392+
393+
const mediaType = lowered.split(';')[0]!.trim();
394+
if (mediaType === 'multipart/form-data' || mediaType === 'application/x-www-form-urlencoded') {
395+
try {
396+
return readable(raw.bodyUsed ? await c.req.parseBody() : await parseBody(raw.clone()));
397+
} catch {
398+
return refusedOnRead(new RequestBodyRefusal(malformedFormMessage(mediaType)));
399+
}
381400
}
401+
402+
// Not read: a domain that wants these bytes reads them off the request.
403+
return declaresBody(c)
404+
? refusedOnRead(new RequestBodyRefusal(unreadMediaTypeMessage(mediaType)))
405+
: readable({});
382406
}
383407

384408
/** What {@link readDispatchBody} hands an arm: the `body` argument, and whether a refused body was read. */
@@ -422,7 +446,8 @@ function refusedOnRead(refusal: RequestBodyRefusal): DispatchBody {
422446
* throw (`status` and the registered `INVALID_REQUEST`), so `thrownJson`
423447
* renders it through `declaredEnvelopeForThrow`. Module-private, like its twin
424448
* in `@objectstack/plugin-hono-server` (`src/request-body.ts`); the messages
425-
* are the twin's, word for word, and a test holds the two doors' answers equal.
449+
* below and {@link declaresBody} are the twin's, word for word, and a test
450+
* holds the two doors' answers equal (`hono-body-refusal.test.ts`).
426451
*/
427452
class RequestBodyRefusal extends Error {
428453
readonly status = 400;
@@ -434,11 +459,45 @@ class RequestBodyRefusal extends Error {
434459
}
435460
}
436461

462+
/** The refusal tail every message ends with: what the transport did NOT do. */
463+
const NOT_EMPTY = 'It was refused rather than read as an empty body';
464+
437465
const MALFORMED_JSON_BODY_MESSAGE =
438-
'The request body could not be parsed as JSON (it was sent as application/json). It was refused rather than '
439-
+ 'read as an empty body: send a well-formed JSON document, or no body at all.';
466+
`The request body could not be parsed as JSON (it was sent as application/json). ${NOT_EMPTY}: `
467+
+ 'send a well-formed JSON document, or no body at all.';
440468

441-
const UNREADABLE_BODY_MESSAGE = 'The request body could not be read. It was refused rather than read as an empty body.';
469+
const UNREADABLE_BODY_MESSAGE = `The request body could not be read. ${NOT_EMPTY}.`;
470+
471+
/** [#24042] A media type as the client spelled it, echoed only when it is a plain token pair. */
472+
function namedMediaType(mediaType: string): string {
473+
if (mediaType === '') return 'with no Content-Type';
474+
return /^[a-z0-9][a-z0-9!#$&^_.+-]{0,63}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,63}$/.test(mediaType)
475+
? `as ${mediaType}`
476+
: 'with an unrecognised Content-Type';
477+
}
478+
479+
function malformedFormMessage(mediaType: string): string {
480+
return `The request body could not be parsed as form data (it was sent ${namedMediaType(mediaType)}). ${NOT_EMPTY}.`;
481+
}
482+
483+
function unreadMediaTypeMessage(mediaType: string): string {
484+
return `The request body was sent ${namedMediaType(mediaType)}, which this server does not read into a `
485+
+ `request body. ${NOT_EMPTY}: send it as application/json.`;
486+
}
487+
488+
/**
489+
* [#24042] Whether the request carries a body, by RFC 9112 §6.3: a `Content-Length`
490+
* that is not `0`, or a `Transfer-Encoding`. Reading the bytes to find out would
491+
* buffer a body this adapter never reads, and `Request.body` cannot answer:
492+
* `@hono/node-server` hands every non-GET/HEAD request a stream, an empty one
493+
* included. An in-process `Request` built without those headers is therefore
494+
* read as having no body — on both doors.
495+
*/
496+
function declaresBody(c: Context): boolean {
497+
const length = c.req.header('content-length');
498+
if (length !== undefined && length.trim() !== '') return Number(length) !== 0;
499+
return c.req.header('transfer-encoding') !== undefined;
500+
}
442501

443502
/**
444503
* The query the catch-all hands `dispatch()`: a REPEATED name as an array, a

0 commit comments

Comments
 (0)