33import { Hono } from 'hono' ;
44import type { Context } from 'hono' ;
55import { cors } from 'hono/cors' ;
6+ import { parseBody } from 'hono/utils/body' ;
67import {
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 */
364374async 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 */
427452class 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+
437465const 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 - z 0 - 9 ] [ a - z 0 - 9 ! # $ & ^ _ . + - ] { 0 , 63 } \/ [ a - z 0 - 9 ] [ a - z 0 - 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