Skip to content

Commit bde3873

Browse files
committed
fix(runtime): standalone mounts hand dispatch() the body unread, so a domain reads it after its anonymous decision
WIP: helper, mount sites, domain reads, pins. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
1 parent cbfa5e4 commit bde3873

13 files changed

Lines changed: 504 additions & 25 deletions
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
5+
import { deferRequestBody, readDispatchedBody, UNRESOLVED_REQUEST_BODY_READ } from './deferred-request-body.js';
6+
7+
/**
8+
* The two halves of a body handed to `dispatch()` unread (#24151): the mount
9+
* defers, the domain reads once it has admitted the caller. The socket-level
10+
* pins are `dispatcher-body-after-anonymous-floor.hono.integration.test.ts`;
11+
* these hold the value's own contract.
12+
*/
13+
14+
/** A request whose `body` getter counts its reads and answers `value`, or throws `refusal`. */
15+
function requestWith(value: unknown, refusal?: Error): { req: { readonly body: unknown }; reads: () => number } {
16+
let reads = 0;
17+
const req = {
18+
get body(): unknown {
19+
reads += 1;
20+
if (refusal) throw refusal;
21+
return value;
22+
},
23+
};
24+
return { req, reads: () => reads };
25+
}
26+
27+
describe('deferRequestBody / readDispatchedBody', () => {
28+
it('defers: building, holding and passing the value along reads nothing', () => {
29+
const { req, reads } = requestWith({ name: 'probe' });
30+
const deferred = deferRequestBody(req);
31+
32+
expect(typeof deferred).toBe('object');
33+
expect(deferred ?? 'nullish').toBe(deferred);
34+
const carried = [deferred][0];
35+
expect(carried).toBe(deferred);
36+
expect(reads()).toBe(0);
37+
});
38+
39+
it('reads the request body when the domain resolves it, returning that exact value', () => {
40+
for (const value of [{ name: 'probe' }, [{ jsonrpc: '2.0' }], null, 'text', 0, false]) {
41+
const { req, reads } = requestWith(value);
42+
const deferred = deferRequestBody(req);
43+
44+
expect(readDispatchedBody(deferred)).toBe(value);
45+
expect(reads()).toBe(1);
46+
}
47+
});
48+
49+
it("lets the transport's refusal out of the resolving read, unchanged", () => {
50+
const refusal = Object.assign(new Error('The request body could not be parsed as JSON'), { status: 400, code: 'INVALID_REQUEST' });
51+
const { req, reads } = requestWith(undefined, refusal);
52+
const deferred = deferRequestBody(req);
53+
54+
expect(reads()).toBe(0);
55+
let thrown: unknown;
56+
try {
57+
readDispatchedBody(deferred);
58+
} catch (err) {
59+
thrown = err;
60+
}
61+
// The very object the transport threw: its `status` and `code` are what it answers.
62+
expect(thrown).toBe(refusal);
63+
expect(reads()).toBe(1);
64+
});
65+
66+
it('hands every value it did not defer back as it is, reading nothing from it', () => {
67+
const refusedOnRead = new Proxy(Object.create(null) as object, {
68+
get: () => { throw new Error('read'); },
69+
has: () => { throw new Error('read'); },
70+
ownKeys: () => { throw new Error('read'); },
71+
getOwnPropertyDescriptor: () => { throw new Error('read'); },
72+
getPrototypeOf: () => { throw new Error('read'); },
73+
});
74+
const parsed = { name: 'probe' };
75+
for (const value of [parsed, refusedOnRead, undefined, null, 'text', 0, [1]]) {
76+
expect(readDispatchedBody(value)).toBe(value);
77+
}
78+
});
79+
80+
it('refuses loudly when a deferred body is read without being resolved, and never reads the request', () => {
81+
const { req, reads } = requestWith({ name: 'probe' });
82+
const deferred = deferRequestBody(req) as Record<string, unknown>;
83+
84+
/** The name of what `read` threw, or `'nothing'` when it returned. */
85+
const thrownName = (read: () => unknown): string => {
86+
try {
87+
read();
88+
} catch (err) {
89+
return (err as Error).name;
90+
}
91+
return 'nothing';
92+
};
93+
for (const read of [
94+
() => deferred.name,
95+
() => 'name' in deferred,
96+
() => Object.keys(deferred),
97+
() => ({ ...deferred }),
98+
() => JSON.stringify(deferred),
99+
]) {
100+
expect(thrownName(read)).toBe(UNRESOLVED_REQUEST_BODY_READ);
101+
}
102+
expect(reads()).toBe(0);
103+
});
104+
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
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

Comments
 (0)