|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * A request body handed to `dispatch()` UNREAD, so the domain reads it only |
| 5 | + * after its anonymous decision (ADR-0138 D2) — never the mount ahead of it. |
| 6 | + * |
| 7 | + * ## Why the mount cannot read it |
| 8 | + * |
| 9 | + * `IHttpRequest.body` may REFUSE when it is read: the standard transport |
| 10 | + * (`@objectstack/plugin-hono-server`) throws its `400 INVALID_REQUEST` from the |
| 11 | + * read of a body it could not parse, and answers the request with that refusal |
| 12 | + * whatever the handler then does. The standalone mounts in |
| 13 | + * `dispatcher-plugin.ts` used to read `req.body` to build `dispatch()`'s |
| 14 | + * arguments, so the refusal fired before `dispatch()` ran — ahead of the |
| 15 | + * domain's anonymous floor, which ADR-0138 D2 makes the domain's FIRST |
| 16 | + * statement. An anonymous caller sending an unparseable JSON body was answered |
| 17 | + * `400` where the same request with a readable body is answered `401`: the |
| 18 | + * anonymous answer depended on whether the body parsed. `createHonoApp` |
| 19 | + * (`@objectstack/hono`) never had this order, because it hands `dispatch()` a |
| 20 | + * body whose refusal is raised only when a domain reads it. |
| 21 | + * |
| 22 | + * ## The two halves |
| 23 | + * |
| 24 | + * - {@link deferRequestBody} (the mount): the `body` argument for `dispatch()`, |
| 25 | + * holding the request and reading nothing. |
| 26 | + * - {@link readDispatchedBody} (the domain): called once the domain's anonymous |
| 27 | + * decision has admitted the caller. A deferred body is read there, from the |
| 28 | + * request, so a refusal is the transport's, unchanged; any other value (what |
| 29 | + * `createHonoApp`, a test, or an internal caller hands `dispatch()`) is |
| 30 | + * returned as it is. |
| 31 | + * |
| 32 | + * Reading the body THEN — rather than handing the domain no body at all, the |
| 33 | + * shape the bodiless doors use — is what keeps the admitted caller's answer: a |
| 34 | + * signed-in request with an unparseable body still gets the transport's |
| 35 | + * refusal, and a readable one the domain's answer to what it says. |
| 36 | + * |
| 37 | + * ## A deferred body read without being resolved is a programming error |
| 38 | + * |
| 39 | + * The value {@link deferRequestBody} returns throws on every read — a |
| 40 | + * property, `in`, its keys, a spread, `JSON.stringify` — with a message naming |
| 41 | + * the missing {@link readDispatchedBody} call. Holding it is not a read: |
| 42 | + * `typeof` is `'object'`, and passing it along or testing it with `??` touch |
| 43 | + * nothing, so `dispatch()` and a domain's own gates carry it untouched. A |
| 44 | + * domain that forgot to resolve it fails loudly on its first read instead of |
| 45 | + * acting on an invented empty body. |
| 46 | + */ |
| 47 | + |
| 48 | +/** The request a deferred body is read from: the transport's `IHttpRequest` (`body` may throw on read). */ |
| 49 | +interface BodyBearingRequest { |
| 50 | + readonly body?: unknown; |
| 51 | +} |
| 52 | + |
| 53 | +/** Deferred bodies → their request. Keyed by identity, so recognising one touches none of its traps. */ |
| 54 | +const DEFERRED_BODIES = new WeakMap<object, BodyBearingRequest>(); |
| 55 | + |
| 56 | +/** The `name` of the error a read of an unresolved deferred body throws: a programming error, never a request's. */ |
| 57 | +export const UNRESOLVED_REQUEST_BODY_READ = 'UnresolvedRequestBodyRead'; |
| 58 | + |
| 59 | +const UNRESOLVED_READ_MESSAGE = |
| 60 | + 'A request body handed to dispatch() unread was read before the domain resolved it. ' |
| 61 | + + 'A domain resolves the body with readDispatchedBody(body) once its anonymous decision has admitted the caller, ' |
| 62 | + + 'and reads only the value that call returns.'; |
| 63 | + |
| 64 | +/** |
| 65 | + * The `body` argument a mount hands `dispatch()`: `req.body`, NOT yet read. |
| 66 | + * The domain reads it with {@link readDispatchedBody} after its anonymous |
| 67 | + * decision; until then nothing reads it, so a body the transport refuses is |
| 68 | + * refused only for a caller the domain admitted. |
| 69 | + */ |
| 70 | +export function deferRequestBody(req: BodyBearingRequest): unknown { |
| 71 | + const unresolved = (): never => { |
| 72 | + const error = new Error(UNRESOLVED_READ_MESSAGE); |
| 73 | + error.name = UNRESOLVED_REQUEST_BODY_READ; |
| 74 | + throw error; |
| 75 | + }; |
| 76 | + const deferred = new Proxy(Object.create(null) as object, { |
| 77 | + get: unresolved, |
| 78 | + has: unresolved, |
| 79 | + ownKeys: unresolved, |
| 80 | + getOwnPropertyDescriptor: unresolved, |
| 81 | + getPrototypeOf: unresolved, |
| 82 | + set: unresolved, |
| 83 | + defineProperty: unresolved, |
| 84 | + deleteProperty: unresolved, |
| 85 | + }); |
| 86 | + DEFERRED_BODIES.set(deferred, req); |
| 87 | + return deferred; |
| 88 | +} |
| 89 | + |
| 90 | +/** |
| 91 | + * The body a domain acts on, read once its anonymous decision has admitted the |
| 92 | + * caller. A body {@link deferRequestBody} deferred is read now, from its |
| 93 | + * request — which may throw the transport's refusal, and must be left to |
| 94 | + * propagate (`IHttpRequest.body`: a handler never carries on past it). Any |
| 95 | + * other value is returned unchanged. |
| 96 | + */ |
| 97 | +export function readDispatchedBody(body: unknown): unknown { |
| 98 | + if (typeof body !== 'object' || body === null) return body; |
| 99 | + const req = DEFERRED_BODIES.get(body); |
| 100 | + return req === undefined ? body : req.body; |
| 101 | +} |
0 commit comments