From 498ad7152911f34e9db176c5e14ee602d0fb30fb Mon Sep 17 00:00:00 2001 From: Quim T <26749475+espetro@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:54:10 +0000 Subject: [PATCH 1/7] feat(router): Web Standards GenFetch dispatch arm (contract v4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit contracts/route-contract-fetch.md: a parallel contract to route-contract.md for the fetch surface. GenFetch.dispatchFetch takes the same normalized primitives _dispatchPrim consumes (method/url/headers/webReq/body/bodyU8/ FetchResponseSink) and returns Promise; app.fetch and app.fetchShim try the emitted dispatch first and decline to _dispatchPrim on false. ROUTE_CONTRACT_VERSION bumps to v4 across both arms. FetchResponseSink gains streaming members: writeHead arms ReadableStream mode, write() enqueues through the stream controller (buffered until toResponse() hands it over), endStream() closes it — SSE handlers produce a chunked ReadableStream body, not a buffered string. Also fixes two latent fetch-path divergences: runChainPromise did not catch a synchronous chain throw (unhandled rejection instead of 500), and allow405Promise never expanded * or GET->HEAD in the Allow header. --- contracts/route-contract-fetch.md | 136 ++++++++++++ contracts/route-contract.md | 8 +- packages/router/package.json | 2 +- packages/router/src/gen.ts | 57 ++++- packages/router/src/index.ts | 185 ++++++++++++++-- packages/router/src/sink.ts | 107 ++++++++- packages/router/test/genfetch.test.js | 301 ++++++++++++++++++++++++++ 7 files changed, 764 insertions(+), 32 deletions(-) create mode 100644 contracts/route-contract-fetch.md create mode 100644 packages/router/test/genfetch.test.js diff --git a/contracts/route-contract-fetch.md b/contracts/route-contract-fetch.md new file mode 100644 index 0000000..775e861 --- /dev/null +++ b/contracts/route-contract-fetch.md @@ -0,0 +1,136 @@ +# route-contract-fetch.md — Web Standards generated dispatch + +Status: **frozen** — the Web Standards twin of `contracts/route-contract.md`. +This is the seam between `packages/router/src/gen.ts` (`GenFetch`) and the +fetch surface (`app.fetch`, `app.fetchShim`) on one side, and build-time +code generators on the other (the `@chttp/unplugin` fetch emitter — +`src/emitfetch.ts` — emits modules satisfying this contract). v0.5.0, +2026-10-18. + +## Shape + +An emitter produces a module exporting: + +```ts +export class CompiledFetchRoutes extends GenFetch { + constructor(a: App) + dispatchFetch( + method: string, + url: string, + headers: Record | null, + webReq: WebReqView | null, + body: string, + bodyU8: Uint8Array | null, + sink: FetchResponseSink, + ): Promise +} +``` + +registered in the app-owning entry module: + +```ts +app.withFetchDispatch(new CompiledFetchRoutes(app)); +``` + +`dispatchFetch` takes the **same normalized primitives** `App._dispatchPrim` +consumes — the contract deliberately mirrors that signature so `app.fetch` +(a real `Request` captured through the `WebReqView` view) and +`app.fetchShim` (plain primitives, `webReq === null`, `body` seeded) drive +the emitted matcher identically. No `IncomingMessage`/`ServerResponse` and +no synthesized response object appear anywhere in the module. + +- `method` — the HTTP method, pre-uppercased by neither side (matchers + normalize via `String(method)`). +- `url` — the path + query string (`"/a/b?x=1"`), exactly what + `app.fetch` derives from `req.url`. The emitted matcher splits `?` + itself, matching the Node-arm emit line for line. +- `headers` — a lower-cased `Record` materialized from + the Request's `Headers` (`headersFromRaw`), or `null` on fetchShim. + Passed to the `ReqView` constructor as + `headers === null ? undefined : headers` (the ReqView ctor's optional + param). +- `webReq` — the original Web Request through the `WebReqView` interface + view (host objects can't be class field types, so the interface is the + sanctioned carrier), or `null` under fetchShim. +- `body` — the seeded body string: `""` under `app.fetch` (lazy reads are + the route's business — see `_bufferFetch`), the utf8 body string under + `fetchShim` for non-GET/HEAD methods. +- `bodyU8` — pre-read bytes for multipart-declared routes. Emitted + matchers **decline multipart routes** (same rule as the Node arm), so + the parameter is carried for signature parity and never read. +- `sink` — the `FetchResponseSink` the request is answered into. All + writes go through the `ResponseSink` surface (`setStatus`, `setHeader`, + `appendHeader`, `writeBody`, `bodyBytes`, `write`, `writeHead`, + `endStream`, `redirect`). + +**Return:** `Promise` — always a Promise, never a union: a +single async return shape lowers cleanly where `Promise | +boolean` would fence (SC2009). `true` means the request was answered +through `sink` (or will be — an awaited ctx runner resolves after the +chain drains). `false` declines to `_dispatchPrim`, which owns +405/notFound/not-registered decline paths on both surfaces. Emitters +**must** return `false` — never answer a route the matcher cannot fully +cover (`bail, never guess`, same as the Node arm). + +## Contract members the emitted module may touch + +Same stable surface as the Node arm, with the fetch-arm additions marked: + +| Member | Signature | +|---|---| +| `GenFetch` | `class` — emitted class extends it; `ROUTE_CONTRACT_VERSION` instance field stamps the contract version | +| `ReqView` | `new ReqView(raw: IncomingMessage \| null, qs: string, path: string, url?: string, method?: string, headers?: Record, headersDistinct?: Record)` — fetch arm passes `null` for `raw` | +| `ReqView._seedPm` | `(pm: string[]) => void` — flat decoded `[name, value, ...]` pairs incl. `"*"` for splats | +| `ReqView._setWeb` | `(r: WebReqView \| null) => void` — captures the Web Request for `c.req.raw` | +| `ReqView._setBody` | `(s: string) => void` — installs the seeded/`text()` body | +| `ReqView.url` | `string` field — overwrite with the Request's **absolute** URL when `webReq !== null` (Hono parity: `c.req.url` is absolute on the fetch surface) | +| `Ctx` | `new Ctx(rv, sink)` — the sink is the `FetchResponseSink` directly | +| `FetchResponseSink` | `ResponseSink` subclass — capture surface; type-only in emitted code (never constructed there) | +| `WebReqView` | interface view of the Request (method/url/headers/text/arrayBuffer); type-only | +| `_guardFetch` | `(c: CtxCore, sink: ResponseSink, k: () => unknown) => Promise` — drains the chain via `runChainPromise`; thrown, rejected, or silent chains answer 500 | +| `_bufferFetch` | `(c: CtxCore, sink: ResponseSink, webReq: WebReqView \| null, seeded: string, k: () => unknown) => Promise` — declared-body routes: seeded string wins, else `webReq.text()` is buffered, then the guarded chain runs | +| `dec` | `(s: string) => string` — URI-decode for param/wildcard captures | +| `App._chains` | `Handler[]` — composed middleware+handler per registration index | +| `App.withFetchDispatch` | `(d: GenFetch) => App` — registration seam; enforces the version stamp | + +## Body rule + +Same declared-body rule as the Node arm: a route only buffers when it +registered `{ body: true }` AND the method carries a body. Concrete write +methods emit `_bufferFetch` unconditionally; `*` (all) routes keep a +runtime method check (`m === "POST" || m === "PUT" || m === "PATCH" || +m === "DELETE"`). Non-body runners still install the seeded string via +`rv._setBody(body)` before `_guardFetch` — `_dispatchPrim` installs it +unconditionally, and fetchShim's seeded body must be visible through +`c.req.body()` identically on both dispatchers. + +## Multipart rule + +Emitters **must decline** any route whose registration opts include +`multipart` — identical to the Node arm. `_dispatchPrim` owns the parse; +an emitted `_bufferFetch` entry would serve the route unparsed. + +## Streaming rule + +Streaming middleware (`@chttp/stream`: `streamSSE`, `streamText`) works on +the fetch surface through the sink's streaming members — `writeHead` +arms ReadableStream mode, `write()` enqueues chunks (buffered until +`toResponse()` hands the controller over), `endStream()` closes it. The +emitted dispatcher changes nothing for these routes: they run through +`_guardFetch` like any other ctx route and produce a chunked +`ReadableStream` body, not a buffered single string. + +## Contract versioning + +Same scheme as the Node arm — both arms share the one +`ROUTE_CONTRACT_VERSION` number (`packages/router/src/gen.ts`, mirrored in +`packages/unplugin/src/contract.ts`). + +- **Stamped into gen files** as a literal: `export const + ROUTE_CONTRACT_VERSION = 4;` in the module header and + `ROUTE_CONTRACT_VERSION = 4` as an instance field on the emitted class. +- **Validated at `withFetchDispatch`**: a numeric stamp that differs from + the runtime's throws immediately, naming both versions; a missing stamp + is accepted. +- Current version: **4** (v4 adds the GenFetch arm; Node-arm modules + generated at v3 must regenerate alongside their fetch siblings). diff --git a/contracts/route-contract.md b/contracts/route-contract.md index b0970f8..6f0fd7b 100644 --- a/contracts/route-contract.md +++ b/contracts/route-contract.md @@ -129,10 +129,10 @@ The contract majors with the package: any change to a member listed in the stabl ## Contract versioning -`ROUTE_CONTRACT_VERSION` (currently `3`) is a plain number exported from `chttp` (defined in `src/gen.ts`, re-exported by `src/index.ts`). It exists so a stale generated module can never silently serve requests against a runtime that expects a different emit shape: +`ROUTE_CONTRACT_VERSION` (currently `4`) is a plain number exported from `chttp` (defined in `src/gen.ts`, re-exported by `src/index.ts`). It exists so a stale generated module can never silently serve requests against a runtime that expects a different emit shape: - **Lives in chttp.** The runtime is the source of truth; the constant is a plain `const` number, subset-clean. -- **Stamped into gen files.** Emitters write the same number as a literal — `export const ROUTE_CONTRACT_VERSION = 3;` in the module header next to the Source comment, and `ROUTE_CONTRACT_VERSION = 3` as an instance field on the emitted `CompiledRoutes` class (assigned after `super()`, shadowing the `GenDispatch` default). The gen file stays standalone: no import of the version, just the literal. +- **Stamped into gen files.** Emitters write the same number as a literal — `export const ROUTE_CONTRACT_VERSION = 4;` in the module header next to the Source comment, and `ROUTE_CONTRACT_VERSION = 4` as an instance field on the emitted `CompiledRoutes` class (assigned after `super()`, shadowing the `GenDispatch` default). The gen file stays standalone: no import of the version, just the literal. - **Validated at `withDispatch`.** If the dispatch object exposes a numeric `ROUTE_CONTRACT_VERSION` that differs from the runtime's, `app.withDispatch(d)` throws immediately, naming both versions. A missing stamp is accepted (dispatches that predate versioning). - **Bump semantics.** Any breaking change to this contract — member shapes, dispatch obligations, the emitted-module grammar — bumps the number. Bump `chttp`'s export and `@chttp/unplugin`'s mirrored constant (`packages/unplugin/src/contract.ts`, asserted equal by test) in the same commit; a regenerated module then carries the new stamp. @@ -142,9 +142,9 @@ import { GenDispatch, ROUTE_CONTRACT_VERSION } from '@chttp/router' app.withDispatch(new CompiledRoutes(app)) // throws on stamp mismatch // emitted header (.chttp/routes.gen.ts) -export const ROUTE_CONTRACT_VERSION = 3 +export const ROUTE_CONTRACT_VERSION = 4 export class CompiledRoutes extends GenDispatch { - ROUTE_CONTRACT_VERSION = 3 + ROUTE_CONTRACT_VERSION = 4 // ... } ``` diff --git a/packages/router/package.json b/packages/router/package.json index d9e0b53..5087596 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -43,7 +43,7 @@ "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "npm run build && node --test test/router.test.js test/server.test.js test/async.test.js test/upgrade.test.js test/fetch-passthrough.test.js test/client.test.js && bash test/elf.test.sh" + "test": "npm run build && node --test test/router.test.js test/server.test.js test/async.test.js test/upgrade.test.js test/fetch-passthrough.test.js test/client.test.js test/genfetch.test.js && bash test/elf.test.sh" }, "devDependencies": { "@types/node": "^24.0.0", diff --git a/packages/router/src/gen.ts b/packages/router/src/gen.ts index 5e6c0df..a210833 100644 --- a/packages/router/src/gen.ts +++ b/packages/router/src/gen.ts @@ -1,18 +1,40 @@ // Generated-dispatch contract — the stable seam between chttp and // build-time code generators (the Vite/unplugin transform). Spec: -// contracts/route-contract.md. +// contracts/route-contract.md (Node arm) and +// contracts/route-contract-fetch.md (Web Standards arm). // // Emitters generate `class X extends GenDispatch { dispatch(req,res){…} }` // and register `app.withDispatch(new X())`. Subclass method override is // verified to dispatch correctly under both node and scriptc ELF builds. +// +// The Web Standards twin emits `class X extends GenFetch` registered via +// `app.withFetchDispatch(new X(app))`: same normalized primitives +// _dispatchPrim takes (method, url, headers, the optional Web Request, +// seeded body, sink) so app.fetch and app.fetchShim share the one entry. import type { IncomingMessage, ServerResponse } from "node:http"; +import type { FetchResponseSink } from "./sink.ts"; +import type { WebReqView } from "./index.ts"; export type DispatchFn = (req: IncomingMessage, res: ServerResponse) => boolean; +// Fetch-arm dispatch closure: the same normalized primitives +// _dispatchPrim consumes. Always Promise — a single async return shape +// lowers cleanly where a Promise|boolean union would fence (SC2009). +export type DispatchFetchFn = ( + method: string, + url: string, + headers: Record | null, + webReq: WebReqView | null, + body: string, + bodyU8: Uint8Array | null, + sink: FetchResponseSink, +) => Promise; + // Contract version (contracts/route-contract.md, Contract versioning). Bumped // when the emit contract changes shape. Emitters stamp the same number -// into generated modules and App.withDispatch refuses a mismatch. -export const ROUTE_CONTRACT_VERSION = 3; +// into generated modules and App.withDispatch refuses a mismatch. v4 adds +// the GenFetch arm — Node-shaped modules generated at v3 must regenerate. +export const ROUTE_CONTRACT_VERSION = 4; export class GenDispatch { // Stamp carried by every dispatch instance. GenDispatch subclasses that @@ -29,3 +51,32 @@ export class GenDispatch { return false; } } + +// Web Standards-shaped generated dispatch (contracts/route-contract-fetch.md). +// The emitter lowers routes to an async matcher over the same primitives +// _dispatchPrim consumes — no IncomingMessage/ServerResponse anywhere, so +// the module runs identically under app.fetch (a real Request through the +// WebReqView view) and app.fetchShim (plain primitives, webReq === null). +// `true` means the request was answered through `sink`; `false` declines +// to the runtime fallback (_dispatchPrim handles 405/404/notFound). +export class GenFetch { + ROUTE_CONTRACT_VERSION: number; + + constructor() { + this.ROUTE_CONTRACT_VERSION = ROUTE_CONTRACT_VERSION; + } + + dispatchFetch( + _method: string, + _url: string, + _headers: Record | null, + _webReq: WebReqView | null, + _body: string, + _bodyU8: Uint8Array | null, + _sink: FetchResponseSink, + ): Promise { + return new Promise(function (resolve): void { + resolve(false); + }); + } +} diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index eacb15b..2e0a52b 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -49,8 +49,8 @@ export interface ResView { body: ReadableStream | null; headers: Headers; } -import { GenDispatch, ROUTE_CONTRACT_VERSION } from "./gen.ts"; -import type { DispatchFn } from "./gen.ts"; +import { GenDispatch, GenFetch, ROUTE_CONTRACT_VERSION } from "./gen.ts"; +import type { DispatchFn, DispatchFetchFn } from "./gen.ts"; import { UpgradeDispatch } from "./upgrade.ts"; import type { Socket } from "node:net"; import { ResponseSink, NodeResponseSink, FetchResponseSink } from "./sink.ts"; @@ -64,12 +64,12 @@ import { } from "./multipart.ts"; import type { MultipartFileSink, UploadedFile, DispositionParams } from "./multipart.ts"; -export { GenDispatch }; +export { GenDispatch, GenFetch }; export { ResponseSink, NodeResponseSink, FetchResponseSink }; export { MultipartParser, MultipartStorage, MemoryStorage, DiskStorage, multipartBoundary }; export { memoryStorage, diskStorage } from "./multipart.ts"; export type { MultipartFileSink, UploadedFile, DispositionParams }; -export type { DispatchFn }; +export type { DispatchFn, DispatchFetchFn }; export { ROUTE_CONTRACT_VERSION }; export { UpgradeDispatch }; @@ -1188,6 +1188,53 @@ export function _buffer( }); } +// Fetch-arm contract helpers (contracts/route-contract-fetch.md). +// _guardFetch is _guard's Promise-returning twin: it drains the chain +// through runChainPromise so the emitted dispatch's Promise resolves only +// after the response settles. _bufferFetch mirrors _buffer's declared-body +// rule for fetch: a fetchShim-seeded string wins; when it is empty the +// captured Web Request's text() is buffered before the chain runs (the +// fetch arm's analogue of the Node path's data/end buffering). +export function _guardFetch(c: CtxCore, sink: ResponseSink, k: () => unknown): Promise { + return new Promise(function (resolve): void { + // No onError handler on this path — a no-op is passed rather than a + // literal null so runChainPromise's runOnError specialization never + // instantiates with a null-typed oef (SC2001). A thrown chain calls + // the no-op, leaves c.sent false, and the standard send500 runs — + // the same outcome the `oef === null` arm produces. + runChainPromise( + function (): unknown { + return k(); + }, + c, + sink, + function (_e: unknown, _c: CtxCore): void {}, + resolve, + ); + }); +} + +export async function _bufferFetch( + c: CtxCore, + sink: ResponseSink, + webReq: WebReqView | null, + seeded: string, + k: () => unknown, +): Promise { + const rv = c.req; + if (webReq !== null && seeded === "") { + try { + rv._setBody(await webReq.text()); + } catch (e) { + send500(sink); + return; + } + } else { + rv._setBody(seeded); + } + await _guardFetch(c, sink, k); +} + // 405 Method Not Allowed with an Allow header built from the matched // node's registered methods. `*` (all) expands to every standard verb so // the header stays spec-true; GET implies HEAD. @@ -1481,6 +1528,12 @@ export class App { // would fence eager program-code instantiation the same way a function // inside a record does. Default returns false (never registered). _gen: DispatchFn; + // Web Standards generated dispatch (contracts/route-contract-fetch.md), + // same closure-field shape as _gen. Default resolves false — the app + // falls back to _dispatchPrim when no emitted fetch module is + // registered, so the fetch path works identically with or without the + // unplugin emit. + _genFetch: DispatchFetchFn; // Upgrade seam (contracts/upgrade-contract.md), same closure-field shape as // _gen. serve() never attaches an "upgrade" listener: any attach built // inside package dist JS defers 2 SC1090 sites for EVERY npm app @@ -1510,6 +1563,19 @@ export class App { this._gen = function (_r: IncomingMessage, _s: ServerResponse): boolean { return false; }; + this._genFetch = function ( + _m: string, + _u: string, + _h: Record | null, + _w: WebReqView | null, + _b: string, + _b8: Uint8Array | null, + _s: FetchResponseSink, + ): Promise { + return new Promise(function (resolve): void { + resolve(false); + }); + }; this._up = function (_r: IncomingMessage, _s: Socket, _h: Buffer): boolean { return false; }; @@ -1643,6 +1709,39 @@ export class App { return this; } + // Registers a generated Web Standards dispatch module + // (contracts/route-contract-fetch.md) — the fetch-arm twin of + // withDispatch, tried FIRST by app.fetch and app.fetchShim. A `false` + // resolution declines to _dispatchPrim (405/404/notFound and bailed + // routes). + withFetchDispatch(d: GenFetch): App { + // Same contract-version drift guard as withDispatch — the stamp check + // runs before any other member read. + const stamped = d.ROUTE_CONTRACT_VERSION; + if (typeof stamped === "number" && stamped !== ROUTE_CONTRACT_VERSION) { + throw new Error( + "chttp: route contract version mismatch — fetch dispatch was generated against contract v" + + String(stamped) + + ", runtime implements v" + + String(ROUTE_CONTRACT_VERSION) + + ". Regenerate .chttp/*.gen.fetch.ts (upgrade @chttp/unplugin to match chttp) or align chttp.", + ); + } + const g = d; + this._genFetch = function ( + m: string, + u: string, + h: Record | null, + w: WebReqView | null, + b: string, + b8: Uint8Array | null, + s: FetchResponseSink, + ): Promise { + return g.dispatchFetch(m, u, h, w, b, b8, s); + }; + return this; + } + // Registers an upgrade dispatch (contracts/upgrade-contract.md). Last call // wins. The listener is only attached by serve()/upgradeHandler() users // when one is registered, so apps without it keep node's default. @@ -1729,12 +1828,12 @@ export class App { // handler may either write through `c.*` or return a Response (Hono // accepts both); see adoptResponse in the chain runner. // - // Generated dispatch (contracts/route-contract.md) is still - // Node-shaped: a synthesized ServerResponse is constructed from the - // FetchResponseSink and passed to the dispatcher. When the dispatcher - // writes to it, the writes are mirrored into the FetchResponseSink via - // the wrapper. See follow-up A in the plan for a real Web-Standards- - // shaped GenDispatch. + // Generated dispatch (contracts/route-contract-fetch.md) is Web + // Standards-shaped: the emitted GenFetch matcher takes the same + // normalized primitives _dispatchPrim consumes and writes straight into + // the FetchResponseSink — no synthesized ServerResponse anywhere. A + // `false` resolution declines to _dispatchPrim for 405/404/notFound and + // routes the emitter bailed on. fetch(req: Request, _env?: unknown, _ctx?: unknown): Promise { const app = this; // The router matches on the path part of the URL (Node's IncomingMessage @@ -1757,12 +1856,14 @@ export class App { if (isMp && method !== "GET" && method !== "HEAD") { bodyU8 = new Uint8Array(await req.arrayBuffer()); } - // Generated dispatch is Node-shaped (contracts/route-contract.md) — - // the contract has no Web Standards variant yet (follow-up A in the - // plan). For the fetch path we skip generated dispatch entirely; - // the unplugin-emitted routes would need a parallel contract. Today - // no shipped example uses generated dispatch through app.fetch. - await app._dispatchPrim(method, url, headersFromRaw(req), req, body, bodyU8, sink); + // Generated Web Standards dispatch first (contracts/ + // route-contract-fetch.md); a `false` resolution declines to the + // runtime path — same decline shape as the Node arm's _gen check. + const hs = headersFromRaw(req); + const handled = await app._genFetch(method, url, hs, req, body, bodyU8, sink); + if (handled === false) { + await app._dispatchPrim(method, url, hs, req, body, bodyU8, sink); + } return sink.toResponse(); })(); } @@ -1799,8 +1900,27 @@ export class App { const sink = new FetchResponseSink(); const m = String(method).toUpperCase(); const seeded = m === "GET" || m === "HEAD" ? "" : String(body); - const p = app._dispatchPrim(String(method), String(url), headers, null, seeded, null, sink); - p.then(function (): void { + // Generated Web Standards dispatch first — the emitted GenFetch runs + // identically on plain primitives (webReq === null), so an ELF bench + // exercises the same lowered matcher as app.fetch. Decline falls + // through to _dispatchPrim. + const p = app._genFetch(String(method), String(url), headers, null, seeded, null, sink); + p.then(function (handled: boolean): void { + if (handled === false) { + const p2 = app._dispatchPrim( + String(method), + String(url), + headers, + null, + seeded, + null, + sink, + ); + p2.then(function (): void { + respond(sink.status, sink.hdrList, sink.chunks.join("")); + }); + return; + } respond(sink.status, sink.hdrList, sink.chunks.join("")); }); } @@ -2173,7 +2293,21 @@ function runChainPromise( // because app.fetch already crosses at least one microtask (await // req.text()). Use the async settle path so the resolve() fires // after the chain fully drains. - const r = chain(c); + // A synchronous throw must become a 500, not a rejected promise — + // runChain (the Node twin) catches it inline; here the guard keeps + // app.fetch and fetchShim from surfacing an unhandled rejection. + let r: unknown; + try { + r = chain(c); + } catch (e) { + if (oef !== null) { + runOnError(oef, e, sink, c); + } else { + send500(sink); + } + resolve(); + return; + } if (isSettled(r, c)) { if (c.sent === false && sink.isHeadersSent() === false) { send500(sink); @@ -2254,7 +2388,18 @@ function allow405Promise( const seen: { [k: string]: number } = {}; const out: string[] = []; for (let i = 0; i < hm.length; i++) { - const m = String(hm[i]); + let m = String(hm[i]); + // Same expansion as the Node allow405: `*` covers every standard verb + // and GET implies HEAD so the header stays spec-true. + if (m === "*") { + m = "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS"; + } else if (m === "GET" && seen["HEAD"] === undefined) { + if (seen["GET"] === undefined) { + seen["GET"] = 1; + out.push("GET"); + } + m = "HEAD"; + } if (seen[m] !== 1) { seen[m] = 1; out.push(m); diff --git a/packages/router/src/sink.ts b/packages/router/src/sink.ts index 32d6bc1..45ac2cd 100644 --- a/packages/router/src/sink.ts +++ b/packages/router/src/sink.ts @@ -18,6 +18,15 @@ import type { ServerResponse } from "node:http"; import type { ResView } from "./index.ts"; +// View of the ReadableStream controller FetchResponseSink feeds when a +// handler opened streaming mode via writeHead(). Host objects can't be +// class field types (SC1090), so the controller is held through this +// interface view — same trick as WebReqView/ResView in index.ts. +export interface StreamCtl { + enqueue(b: Uint8Array): void; + close(): void; +} + // ResponseSink is a base class — not declared `abstract` (scriptc doesn't // lower abstract methods). Each method has a no-op default; concrete // sinks override the ones they need. Same pattern as Ctx/ReqView/App. @@ -29,6 +38,17 @@ export class ResponseSink { bodyBytes(_b: Uint8Array, _ct: string): void {} redirect(_url: string, _code: number): void {} write(_chunk: string): void {} + // Streaming commit seam (@chttp/stream): write status + a flat + // [k1, v1, k2, v2, ...] header list and flush headers so subsequent + // write() chunks reach the client immediately. On the Node sink this is + // res.writeHead + flushHeaders; on the fetch sink it arms ReadableStream + // mode (toResponse() then wraps a live stream instead of the chunk + // buffer). No-op on the base. + writeHead(_status: number, _flatPairs: string[]): void {} + // End-of-stream seam for a writeHead()-opened stream. Node ends the + // ServerResponse; fetch closes the ReadableStream controller. No-op on + // the base — a sink that never armed streaming ignores it. + endStream(): void {} // Response adoption (0.4.0): a fetch-path handler returned a Response. // No-op on the base (and on NodeResponseSink — there is no Web Response // to mirror into a ServerResponse); FetchResponseSink overrides it. @@ -152,6 +172,23 @@ export class NodeResponseSink extends ResponseSink { this.res.write(String(chunk)); } + writeHead(status: number, flatPairs: string[]): void { + const res = this.res; + const obj: { [k: string]: string } = {}; + this._status = Number(status); + for (let i = 0; i + 1 < flatPairs.length; i += 2) { + const kk = String(flatPairs[i]); + obj[kk] = String(flatPairs[i + 1]); + this._set[kk.toLowerCase()] = String(flatPairs[i + 1]); + } + res.writeHead(this._status, obj); + res.flushHeaders(); + } + + endStream(): void { + this.res.end(); + } + isSent(): boolean { return this.res.headersSent; } @@ -217,6 +254,14 @@ export class FetchResponseSink extends ResponseSink { // we read lowers at 100% static). null until adopt() runs; toResponse() // wraps the adopted body stream instead of the chunk buffer when set. adopted: ResView | null; + // Streaming mode (0.5.0): armed by writeHead() — the response body is a + // live ReadableStream. _ctl holds the controller once toResponse() hands + // it over (interface view — see StreamCtl above); writes before that + // point buffer in chunks and replay at stream start. _endSeen records + // an endStream() that arrived before the controller did. + _streaming: boolean; + _ctl: StreamCtl | null; + _endSeen: boolean; constructor() { super(); @@ -228,6 +273,9 @@ export class FetchResponseSink extends ResponseSink { this.sent = false; this.headersSent = false; this.adopted = null; + this._streaming = false; + this._ctl = null; + this._endSeen = false; } setStatus(n: number): void { @@ -322,9 +370,39 @@ export class FetchResponseSink extends ResponseSink { } write(chunk: string): void { + // Streaming arm: once toResponse() has handed the ReadableStream + // controller over, chunks enqueue directly instead of buffering — + // this is the path @chttp/stream's fetch writers drive. + const ctl = this._ctl; + if (ctl !== null) { + ctl.enqueue(new TextEncoder().encode(String(chunk))); + return; + } this.chunks.push(String(chunk)); } + writeHead(status: number, flatPairs: string[]): void { + this.status = Number(status); + for (let i = 0; i + 1 < flatPairs.length; i += 2) { + this.setHeader(String(flatPairs[i]), String(flatPairs[i + 1])); + } + // Headers committed + streaming armed: isHeadersSent() must report + // true so a later send500 destroys rather than double-writing, and + // toResponse() must build the live-stream Response. + this.headersSent = true; + this.sent = true; + this._streaming = true; + } + + endStream(): void { + this._endSeen = true; + this.sent = true; + const ctl = this._ctl; + if (ctl !== null) { + ctl.close(); + } + } + // Response adoption (0.4.0): a handler returned a full Response. Its // status + Headers overwrite the captured state, and its body stream // (the handler's own — never buffered here) is kept on the view so @@ -392,6 +470,28 @@ export class FetchResponseSink extends ResponseSink { for (let i = 0; i + 1 < this.hdrList.length; i += 2) { init.headers.push([String(this.hdrList[i]), String(this.hdrList[i + 1])]); } + const self = this; + if (self._streaming) { + // Streaming response (opened via writeHead): wrap a live + // ReadableStream. Chunks written before toResponse() replay into + // the stream at start; chunks written after go straight to the + // controller through write(). endStream() may already have run — + // close right after the replay then. + const rs = new ReadableStream({ + start: function (ctl: ReadableStreamDefaultController): void { + self._ctl = ctl; + const pending = self.chunks; + for (let i = 0; i < pending.length; i++) { + ctl.enqueue(new TextEncoder().encode(pending[i])); + } + pending.length = 0; + if (self._endSeen) { + ctl.close(); + } + }, + }); + return new Response(rs, init); + } const a = this.adopted; if (a !== null) { const b = a.body; @@ -400,10 +500,9 @@ export class FetchResponseSink extends ResponseSink { } return new Response(null, init); } - // Join captured chunks. A future streaming adapter (follow-up) will - // return a ReadableStream Response instead of concatenating here. - // Empty joins pass null, not "": the Response constructor stamps its - // own content-type on a string body even when init.headers has none. + // Join captured chunks. Empty joins pass null, not "": the Response + // constructor stamps its own content-type on a string body even when + // init.headers has none. const body = this.chunks.join(""); if (body.length > 0) { return new Response(body, init); diff --git a/packages/router/test/genfetch.test.js b/packages/router/test/genfetch.test.js new file mode 100644 index 0000000..84e5158 --- /dev/null +++ b/packages/router/test/genfetch.test.js @@ -0,0 +1,301 @@ +// Web Standards generated dispatch parity (contracts/route-contract-fetch.md): +// the emitted GenFetch arm must produce the same response shape through +// app.fetch as the Node-shaped GenDispatch arm produces through +// app.handler — status, headers, body. The module below is written in the +// exact shape the unplugin fetch emitter emits (emitfetch.ts); the +// emitter's own e2e covers generation, this suite covers the runtime +// contract the emitted module depends on. +import test from "node:test"; +import assert from "node:assert/strict"; +import { + createRouter, + GenDispatch, + GenFetch, + ReqView, + Ctx, + NodeResponseSink, + FetchResponseSink, + _guard, + _buffer, + _guardFetch, + _bufferFetch, + ROUTE_CONTRACT_VERSION, +} from "../dist/esm/index.js"; + +function dec(s) { + try { + return decodeURIComponent(String(s)); + } catch { + return String(s); + } +} + +const ROUTES = [ + { method: "GET", path: "/json", segs: ["json"], idx: 0 }, + { method: "GET", path: "/user/:id", segs: ["user", ":id"], idx: 1 }, + { method: "GET", path: "/files/*", segs: ["files", "*"], idx: 2 }, + { method: "POST", path: "/echo", segs: ["echo"], idx: 3, needsBody: true }, +]; + +// Node-arm dispatch in the emitted shape (contracts/route-contract.md). +class TestNodeDispatch extends GenDispatch { + constructor(a) { + super(); + this._a = a; + this.ROUTE_CONTRACT_VERSION = ROUTE_CONTRACT_VERSION; + } + dispatch(req, res) { + const m = String(req.method || ""); + const url = String(req.url || "/"); + let path = url; + let qs = ""; + const qi = url.indexOf("?"); + if (qi >= 0) { + path = String(url.slice(0, qi)); + qs = String(url.slice(qi + 1)); + } + const segs = path.split("/"); + if (m === "GET") { + if (segs.length === 2 && segs[1] === "json") { + const rv = new ReqView(req, qs, path); + const c = new Ctx(rv, new NodeResponseSink(res)); + _guard(res, c, () => this._a._chains[0](c)); + return true; + } + if (segs.length === 3 && segs[1] === "user") { + const rv = new ReqView(req, qs, path); + rv._seedPm(["id", dec(segs[2])]); + const c = new Ctx(rv, new NodeResponseSink(res)); + _guard(res, c, () => this._a._chains[1](c)); + return true; + } + if (segs.length >= 3 && segs[1] === "files") { + let t = ""; + for (let j = 2; j < segs.length; j += 1) { + if (j > 2) t = t + "/"; + t = t + segs[j]; + } + const rv = new ReqView(req, qs, path); + rv._seedPm(["*", dec(t)]); + const c = new Ctx(rv, new NodeResponseSink(res)); + _guard(res, c, () => this._a._chains[2](c)); + return true; + } + return false; + } + if (m === "POST") { + if (segs.length === 2 && segs[1] === "echo") { + const rv = new ReqView(req, qs, path); + const c = new Ctx(rv, new NodeResponseSink(res)); + _buffer(req, res, c, () => this._a._chains[3](c)); + return true; + } + return false; + } + return false; + } +} + +// Fetch-arm dispatch in the emitted shape (contracts/route-contract-fetch.md). +class TestFetchDispatch extends GenFetch { + constructor(a) { + super(); + this._a = a; + this.ROUTE_CONTRACT_VERSION = ROUTE_CONTRACT_VERSION; + } + async dispatchFetch(method, url, headers, webReq, body, _bodyU8, sink) { + const m = String(method || ""); + const u = String(url || "/"); + let path = u; + let qs = ""; + const qi = u.indexOf("?"); + if (qi >= 0) { + path = String(u.slice(0, qi)); + qs = String(u.slice(qi + 1)); + } + const segs = path.split("/"); + if (m === "GET") { + if (segs.length === 2 && segs[1] === "json") { + const rv = new ReqView(null, qs, path, u, m, headers === null ? undefined : headers); + rv._seedPm([]); + rv._setWeb(webReq); + if (webReq !== null) rv.url = String(webReq.url); + rv._setBody(body); + const c = new Ctx(rv, sink); + await _guardFetch(c, sink, () => this._a._chains[0](c)); + return true; + } + if (segs.length === 3 && segs[1] === "user") { + const rv = new ReqView(null, qs, path, u, m, headers === null ? undefined : headers); + rv._seedPm(["id", dec(segs[2])]); + rv._setWeb(webReq); + if (webReq !== null) rv.url = String(webReq.url); + rv._setBody(body); + const c = new Ctx(rv, sink); + await _guardFetch(c, sink, () => this._a._chains[1](c)); + return true; + } + if (segs.length >= 3 && segs[1] === "files") { + let t = ""; + for (let j = 2; j < segs.length; j += 1) { + if (j > 2) t = t + "/"; + t = t + segs[j]; + } + const rv = new ReqView(null, qs, path, u, m, headers === null ? undefined : headers); + rv._seedPm(["*", dec(t)]); + rv._setWeb(webReq); + if (webReq !== null) rv.url = String(webReq.url); + rv._setBody(body); + const c = new Ctx(rv, sink); + await _guardFetch(c, sink, () => this._a._chains[2](c)); + return true; + } + return false; + } + if (m === "POST") { + if (segs.length === 2 && segs[1] === "echo") { + const rv = new ReqView(null, qs, path, u, m, headers === null ? undefined : headers); + rv._seedPm([]); + rv._setWeb(webReq); + if (webReq !== null) rv.url = String(webReq.url); + const c = new Ctx(rv, sink); + await _bufferFetch(c, sink, webReq, body, () => this._a._chains[3](c)); + return true; + } + return false; + } + return false; + } +} + +function makeApp() { + const app = createRouter(); + app.get("/json", (c) => c.json({ hello: "world" })); + app.get("/user/:id", (c) => c.json({ id: c.req.param("id") })); + app.get("/files/*", (c) => c.text(c.req.param("*") || "")); + app.post("/echo", (c) => c.text(c.req.body()), { body: true }); + app.get("/runtime-only", (c) => c.text("rt")); + app.notFound((c) => { + c.status(404); + c.text("nf"); + }); + return app; +} + +test("fetch emit: status/headers/body parity with the Node arm", async () => { + const app = makeApp(); + app.withDispatch(new TestNodeDispatch(app)); + app.withFetchDispatch(new TestFetchDispatch(app)); + + const srv = app.serve({ port: 0 }); + await new Promise((r) => srv.on("listening", r)); + const port = srv.address().port; + + const cases = [ + ["GET", "/json", null], + ["GET", "/user/42", null], + ["GET", "/files/a/b/c.txt", null], + ["POST", "/echo", "hello-body"], + ]; + for (const [method, path, reqBody] of cases) { + const nr = await fetch("http://127.0.0.1:" + port + path, { + method, + body: reqBody === null ? undefined : reqBody, + }); + const fr = await app.fetch( + new Request("http://x" + path, { + method, + body: reqBody === null ? undefined : reqBody, + }), + ); + assert.equal(fr.status, nr.status, path + " status"); + assert.equal(await fr.text(), await nr.text(), path + " body"); + assert.equal( + fr.headers.get("content-type"), + nr.headers.get("content-type"), + path + " content-type", + ); + } + srv.close(); +}); + +test("fetch emit: decline falls through to _dispatchPrim (runtime route, 404, 405)", async () => { + const app = makeApp(); + app.withFetchDispatch(new TestFetchDispatch(app)); + // a route the emit doesn't cover is answered by the runtime path + const rt = await app.fetch(new Request("http://x/runtime-only")); + assert.equal(rt.status, 200); + assert.equal(await rt.text(), "rt"); + // unmatched path -> the app's notFound + const nf = await app.fetch(new Request("http://x/nope")); + assert.equal(nf.status, 404); + assert.equal(await nf.text(), "nf"); + // path match + method miss -> 405 with Allow + const na = await app.fetch(new Request("http://x/json", { method: "DELETE" })); + assert.equal(na.status, 405); + assert.equal(na.headers.get("allow"), "GET, HEAD"); +}); + +test("fetch emit: fetchShim drives the same emitted dispatch (ELF parity)", async () => { + const app = makeApp(); + app.withFetchDispatch(new TestFetchDispatch(app)); + const out = await new Promise((resolve) => { + app.fetchShim("GET", "/user/99", null, "", (status, hdrList, body) => { + resolve({ status, hdrList, body }); + }); + }); + assert.equal(out.status, 200); + assert.equal(out.body, '{"id":"99"}'); + // seeded body path: fetchShim seeds non-GET/HEAD bodies + const out2 = await new Promise((resolve) => { + app.fetchShim("POST", "/echo", null, "shim-body", (status, hdrList, body) => { + resolve({ status, body }); + }); + }); + assert.equal(out2.body, "shim-body"); +}); + +test("fetch emit: lazy body buffering via _bufferFetch (webReq.text())", async () => { + const app = makeApp(); + app.withFetchDispatch(new TestFetchDispatch(app)); + const res = await app.fetch( + new Request("http://x/echo", { method: "POST", body: "streamed-body" }), + ); + assert.equal(res.status, 200); + assert.equal(await res.text(), "streamed-body"); +}); + +test("streaming sink: writeHead+write+endStream produce a chunked ReadableStream", async () => { + const app = createRouter(); + app.get("/sse", (c) => { + c.sink.writeHead(200, ["content-type", "text/event-stream; charset=utf-8"]); + c.sink.write("data: one\n\n"); + c.sent = true; + setTimeout(() => { + c.sink.write("data: two\n\n"); + c.sink.endStream(); + }, 5); + }); + const res = await app.fetch(new Request("http://x/sse")); + assert.ok(res.body instanceof ReadableStream, "body is a ReadableStream, not a buffered string"); + const chunks = []; + const reader = res.body.getReader(); + const dec8 = new TextDecoder(); + for (;;) { + const r = await reader.read(); + if (r.done) break; + chunks.push(dec8.decode(r.value)); + } + assert.deepEqual(chunks, ["data: one\n\n", "data: two\n\n"]); + assert.equal(res.headers.get("content-type"), "text/event-stream; charset=utf-8"); +}); + +test("withFetchDispatch: contract-version drift guard", () => { + const app = createRouter(); + assert.throws( + () => app.withFetchDispatch({ dispatchFetch: () => false, ROUTE_CONTRACT_VERSION: 999 }), + /contract version mismatch/, + ); + // a missing stamp is accepted (predates versioning) + app.withFetchDispatch({ dispatchFetch: async () => false }); +}); From a7328cc09e0d97efa0274f8657c7851799a24867 Mon Sep 17 00:00:00 2001 From: Quim T <26749475+espetro@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:54:11 +0000 Subject: [PATCH 2/7] feat(stream): ReadableStream output on the fetch surface ChunkStream gains a FetchStreamWriter arm: when constructed without a ServerResponse (c._nodeRes === null) it writes through the ResponseSink streaming members, so streamSSE/streamText produce a real chunked ReadableStream under app.fetch. The Node arm is unchanged; the connection: keep-alive header stays Node-only (forbidden header name). --- packages/stream/src/index.ts | 150 +++++++++++++++++++++------- packages/stream/test/stream.test.js | 41 ++++++++ 2 files changed, 154 insertions(+), 37 deletions(-) diff --git a/packages/stream/src/index.ts b/packages/stream/src/index.ts index c4891d5..db944ca 100644 --- a/packages/stream/src/index.ts +++ b/packages/stream/src/index.ts @@ -8,7 +8,7 @@ import type { ServerResponse } from "node:http"; import type { Socket } from "node:net"; import { Writable } from "node:stream"; -import type { Ctx } from "@chttp/router/src/index.ts"; +import type { Ctx, ResponseSink } from "@chttp/router/src/index.ts"; // UTF-8 byte length of s (surrogate pairs count 4, lone surrogates 3). function utf8len(s: string): number { @@ -192,6 +192,39 @@ export class ResWriter extends Writable { } } +// Write-side seam for the fetch surface: sendChunk forwards into +// ResponseSink.write(), which buffers chunks until the sink hands the +// ReadableStream controller over at toResponse() and enqueues straight +// into it after. close() ends the stream the writeHead() armed. The +// pending-bytes window cap in ChunkStream is the only slow-consumer +// policy here — a ReadableStream exposes no writable-queue surface the +// sink could read backpressure from. +export class FetchStreamWriter { + sink: ResponseSink; + _ended: boolean; + + constructor(sink: ResponseSink) { + this.sink = sink; + this._ended = false; + } + + sendChunk(chunk: string): void { + if (this._ended) { + return; + } + this.sink.write(chunk); + } + + // Queue-drain + end stubs matching the ResWriter surface ChunkStream + // calls — there is no internal queue on the fetch arm. + flushQ(): void {} + + close(): void { + this._ended = true; + this.sink.endStream(); + } +} + // Base writer: tracks closed state, abort callbacks and the pending-bytes cap. // // scriptc < 0.3.2 could not observe backpressure (write() result, "drain" @@ -201,7 +234,7 @@ export class ResWriter extends Writable { // fail-safe: a stream that writes more than maxPending bytes inside one // windowMs window is still treated as a slow consumer and closed. export class ChunkStream { - res: ServerResponse; + res: ServerResponse | null; closed: boolean; maxPending: number; windowMs: number; @@ -209,9 +242,10 @@ export class ChunkStream { _windowStart: number; _abortFns: Array<() => void>; _fired: boolean; - _writer: ResWriter; + _writer: ResWriter | null; + _fwriter: FetchStreamWriter | null; - constructor(res: ServerResponse) { + constructor(res: ServerResponse | null, sink?: ResponseSink | null) { this.res = res; this.closed = false; this.maxPending = 1048576; @@ -220,7 +254,19 @@ export class ChunkStream { this._windowStart = Date.now(); this._abortFns = []; this._fired = false; - this._writer = new ResWriter(res); + const s = sink === null || sink === undefined ? null : sink; + if (res !== null) { + this._writer = new ResWriter(res); + this._fwriter = null; + } else if (s !== null) { + this._writer = null; + this._fwriter = new FetchStreamWriter(s); + } else { + // Bare test fakes: neither surface — writes/close become no-ops, + // same toleration the _writer !== undefined guards already gave. + this._writer = null; + this._fwriter = null; + } } // Raw chunk write. Returns false once the stream is closed, including when @@ -239,7 +285,15 @@ export class ChunkStream { this.close(); return false; } - this._writer.sendChunk(chunk); + const fw = this._fwriter; + if (fw !== null) { + fw.sendChunk(chunk); + return true; + } + const w = this._writer; + if (w !== null && w !== undefined) { + w.sendChunk(chunk); + } return true; } @@ -252,8 +306,20 @@ export class ChunkStream { return; } this.closed = true; - this._writer.flushQ(); - this._writer.close(); + const fw = this._fwriter; + if (fw !== null) { + fw.flushQ(); + fw.close(); + // No 'close' event exists on the fetch surface — fire the abort + // hooks deterministically here (idempotent via _fired). + this._onClose(); + return; + } + const w = this._writer; + if (w !== null && w !== undefined) { + w.flushQ(); + w.close(); + } } // Runs fn once when the connection closes, whether the client left or @@ -272,8 +338,13 @@ export class ChunkStream { this._fired = true; this.closed = true; // Tests build bare prototype fakes without a writer; guard for those. - if (this._writer !== undefined) { - this._writer._ended = true; + const w = this._writer; + if (w !== null && w !== undefined) { + w._ended = true; + } + const fw = this._fwriter; + if (fw !== null && fw !== undefined) { + fw._ended = true; } const fns = this._abortFns; this._abortFns = []; @@ -320,29 +391,25 @@ export class TextStream extends ChunkStream { } } -function open(c: Ctx, contentType: string, extra: string): ServerResponse { - // Stream only runs through the Node handler surface (the Fetch surface - // produces a ReadableStream Response, which is a separate adapter). - // Direct field access avoids the nullable c.res getter — c._nodeRes is - // null only on the Fetch path, where streamSSE/streamText are not used. - const res = c._nodeRes as ServerResponse; +// Commits status + stream headers through the sink's writeHead seam, so +// the same call opens a chunked response on both surfaces: res.writeHead +// + flushHeaders under Node, ReadableStream arming under app.fetch. +// `connection` stays Node-only — it is a forbidden header name on the +// Web Standards surface (Response init would drop it). +function open(c: Ctx, contentType: string, extra: string): void { + const hdrs: string[] = ["content-type", contentType]; if (extra === "sse") { - res.writeHead(res.statusCode, { - "content-type": contentType, - "cache-control": "no-cache, no-transform", - connection: "keep-alive", - "x-accel-buffering": "no", - }); + hdrs.push("cache-control", "no-cache, no-transform"); + if (c._nodeRes !== null) { + hdrs.push("connection", "keep-alive"); + } + hdrs.push("x-accel-buffering", "no"); } else { - res.writeHead(res.statusCode, { - "content-type": contentType, - "cache-control": "no-cache", - "x-accel-buffering": "no", - }); + hdrs.push("cache-control", "no-cache"); + hdrs.push("x-accel-buffering", "no"); } - res.flushHeaders(); + c.sink.writeHead(c.sink.statusOf(), hdrs); c.sent = true; - return res; } // Starts a text/event-stream response and returns fn(stream). A promise @@ -369,16 +436,25 @@ function wire(res: ServerResponse, s: ChunkStream): void { } export function streamSSE(c: Ctx, fn: (s: SSEStream) => unknown): unknown { - const res = open(c, "text/event-stream; charset=utf-8", "sse"); - const s = new SSEStream(res); - wire(res, s); - return fn(s); + open(c, "text/event-stream; charset=utf-8", "sse"); + const res = c._nodeRes; + if (res !== null) { + const s = new SSEStream(res); + wire(res, s); + return fn(s); + } + // Fetch arm: writes feed the sink's ReadableStream instead of a socket. + return fn(new SSEStream(null, c.sink)); } // Starts a chunked text/plain response and returns fn(stream). export function streamText(c: Ctx, fn: (s: TextStream) => unknown): unknown { - const res = open(c, "text/plain; charset=utf-8", "text"); - const s = new TextStream(res); - wire(res, s); - return fn(s); + open(c, "text/plain; charset=utf-8", "text"); + const res = c._nodeRes; + if (res !== null) { + const s = new TextStream(res); + wire(res, s); + return fn(s); + } + return fn(new TextStream(null, c.sink)); } diff --git a/packages/stream/test/stream.test.js b/packages/stream/test/stream.test.js index 518df9a..154443b 100644 --- a/packages/stream/test/stream.test.js +++ b/packages/stream/test/stream.test.js @@ -476,3 +476,44 @@ test("onAbort fires exactly once whether registered before or after close", asyn fake._onClose(); // second close event must not re-run anything assert.deepEqual(runs, ["before", "after"]); }); + +// app.fetch arm: the same streamSSE/streamText handlers must emit chunked +// ReadableStream bodies (not a buffered single-string body) when the host is +// Web Standards — contracts/route-contract-fetch.md "streaming" section. +test("streamSSE on app.fetch emits a chunked ReadableStream", async () => { + const app = createRouter(); + app.get("/e", (c) => + streamSSE(c, (s) => { + s.send("one"); + s.close(); + }), + ); + const res = await app.fetch(new Request("http://x/e")); + assert.equal(res.status, 200); + assert.match(res.headers.get("content-type"), /^text\/event-stream/); + assert.ok(res.body instanceof ReadableStream, "fetch body is a ReadableStream"); + assert.equal(await res.text(), "data: one\n\n"); +}); + +test("streamText on app.fetch chunks through the ReadableStream", async () => { + const app = createRouter(); + app.get("/t", (c) => + streamText(c, (s) => { + s.writeln("line1"); + s.write("tail"); + s.close(); + }), + ); + const res = await app.fetch(new Request("http://x/t")); + assert.equal(res.status, 200); + assert.ok(res.body instanceof ReadableStream); + const chunks = []; + const reader = res.body.getReader(); + const dec = new TextDecoder(); + for (;;) { + const r = await reader.read(); + if (r.done) break; + chunks.push(dec.decode(r.value)); + } + assert.deepEqual(chunks, ["line1\n", "tail"]); +}); From 6dca904228a101de61affdbed4305f5fa8f283a1 Mon Sep 17 00:00:00 2001 From: Quim T <26749475+espetro@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:54:11 +0000 Subject: [PATCH 3/7] feat(unplugin): fetch-arm emitter (.chttp/.gen.fetch.ts) emitfetch.ts mirrors emitGenModule against the Web Standards contract: the emitted class extends GenFetch and its dispatchFetch takes normalized primitives, builds ReqView through the contract surface (_seedPm/_setWeb/ _setBody), and runs chains through _guardFetch/_bufferFetch. generate() writes the fetch sibling beside routes.gen.ts unconditionally; contract stamp bumps to v4. e2e covers a real emitted module driving app.fetch and app.fetchShim end to end. --- packages/unplugin/PLUGIN.md | 12 + packages/unplugin/src/contract.ts | 2 +- packages/unplugin/src/emitfetch.ts | 425 +++++++++++++++++++++++++++++ packages/unplugin/src/index.ts | 17 ++ packages/unplugin/test/e2e.test.ts | 164 +++++++++++ 5 files changed, 619 insertions(+), 1 deletion(-) create mode 100644 packages/unplugin/src/emitfetch.ts diff --git a/packages/unplugin/PLUGIN.md b/packages/unplugin/PLUGIN.md index c1cf27a..e638901 100644 --- a/packages/unplugin/PLUGIN.md +++ b/packages/unplugin/PLUGIN.md @@ -62,6 +62,18 @@ app.serve({ port: 3000 }); `app.serve()`/`app.handler()` call the generated dispatch FIRST on every request; `false` declines to the internal dispatch, which covers non-literal registrations, notFound, and misses. Routes registered while the app is running also keep working through the internal path. +## The Web Standards sibling module + +Alongside `routes.gen.ts` the plugin always emits `src/.chttp/routes.gen.fetch.ts` satisfying `contracts/route-contract-fetch.md`: `export class CompiledFetchRoutes extends GenFetch` whose `dispatchFetch(method, url, headers, webReq, body, bodyU8, sink)` takes normalized primitives instead of `IncomingMessage`/`ServerResponse`, so `app.fetch` and `app.fetchShim` (the ELF entry, where `new Request` is unconstructable) can run generated dispatch without a synthesized `ServerResponse`. + +```ts +import { CompiledFetchRoutes } from "./.chttp/routes.gen.fetch.ts"; + +app.withFetchDispatch(new CompiledFetchRoutes(app)); +``` + +`app.fetch`/`app.fetchShim` try it first per request; `false` declines to the internal dispatch, same as the Node arm. Route checks, precedence, declared-body buffering (`_bufferFetch`), and 405/`Allow` behavior mirror `routes.gen.ts` — stream handlers (`@chttp/stream`) write through `sink.writeHead`/`sink.write`/`sink.endStream` so SSE answers carry a chunked `ReadableStream` body on the Web Standards surface. + ## Declared body buffering Body buffering is opt-in per the contract's declared-body rule: a handler that reads `c.req.body()` must register with `{ body: true }` or it receives `''`. diff --git a/packages/unplugin/src/contract.ts b/packages/unplugin/src/contract.ts index 662918e..ddd8973 100644 --- a/packages/unplugin/src/contract.ts +++ b/packages/unplugin/src/contract.ts @@ -6,4 +6,4 @@ // together on any contract change (contracts/route-contract.md, Contract // versioning) — never let them drift. -export const ROUTE_CONTRACT_VERSION = 3; +export const ROUTE_CONTRACT_VERSION = 4; diff --git a/packages/unplugin/src/emitfetch.ts b/packages/unplugin/src/emitfetch.ts new file mode 100644 index 0000000..b26f4c0 --- /dev/null +++ b/packages/unplugin/src/emitfetch.ts @@ -0,0 +1,425 @@ +// Codegen: ModuleModel -> .gen.fetch.ts source text (Web Standards arm). +// +// The emitted module satisfies contracts/route-contract-fetch.md: a +// `CompiledFetchRoutes` class extending GenFetch, registered via +// `app.withFetchDispatch(...)`. It is the fetch-surface twin of emit.ts — +// same matcher shape, but dispatchFetch takes the normalized primitives +// _dispatchPrim consumes (method, url, headers, webReq, seeded body, +// sink) instead of IncomingMessage/ServerResponse, so the module runs +// identically under app.fetch (a real Request through the WebReqView +// view) and app.fetchShim (plain primitives, webReq === null). +// +// The same subset rules apply as to src/index.ts and emit.ts: full-shape +// record literals, handlers through App._chains, indexed loops, concrete +// types. The app instance arrives through the constructor, never an +// import (SC1090 — see emit.ts for the full note). + +import * as path from "node:path"; +import type { ModuleModel, RouteSpec, StaticSend } from "./model.ts"; +import { METHOD_ORDER, WRITE_METHODS } from "./model.ts"; +import { ROUTE_CONTRACT_VERSION } from "./contract.ts"; + +const J = JSON.stringify; + +// '/routes.chttp.ts' -> 'routes.gen.fetch.ts' +export function genFetchFileName(modulePath: string): string { + const base = path.basename(modulePath); + const stem = base + .replace(/\.chttp\.(ts|js|mts|mjs|cts|cjs)$/i, "") + .replace(/\.(ts|js|mts|mjs|cts|cjs)$/i, ""); + return stem + ".gen.fetch.ts"; +} + +function routeKind(r: RouteSpec): string { + let kind = "static"; + for (const s of r.segs) { + if (s.kind === 2) { + return "wildcard"; + } + if (s.kind === 1) { + kind = "param"; + } + } + return kind; +} + +// Same precedence rule as the Node emit: at the first segment position +// whose KIND differs, static < param < wildcard; same-kind positions +// fall back to registration order for determinism. +function segCmp(a: RouteSpec, b: RouteSpec): number { + const n = a.segs.length < b.segs.length ? a.segs.length : b.segs.length; + for (let i = 0; i < n; i++) { + const d = a.segs[i].kind - b.segs[i].kind; + if (d !== 0) { + return d; + } + } + return a.registered - b.registered; +} + +function methodRank(r: RouteSpec, m: string): number { + if (r.method === m) { + return 0; + } + if (r.method === "*") { + return 1; + } + return 2; // GET acting as HEAD fallback +} + +// Literal send-chains compile to straight sink writes — no Ctx, no chain. +// Mirrors emit.ts's res.* calls onto the sink surface: setStatus + +// setHeader pairs + writeBody. Redirects end with an empty writeBody so +// `sent` is set (a bare header commit would look like a hang upstream). +function emitStaticRunner(name: string, s: StaticSend): string { + const lines: string[] = []; + lines.push("function " + name + "(sink: FetchResponseSink): void {"); + lines.push(" try {"); + lines.push(" sink.setStatus(" + String(s.status) + ")"); + for (const h of s.headers) { + lines.push(" sink.setHeader(" + J(h[0]) + ", " + J(h[1]) + ")"); + } + if (s.location !== "") { + lines.push(' sink.setHeader("location", ' + J(s.location) + ")"); + lines.push(' sink.writeBody("")'); + } else { + lines.push(' sink.setHeader("content-type", ' + J(s.contentType) + ")"); + const len = Buffer.byteLength(s.body, "utf8"); + lines.push(' sink.setHeader("content-length", ' + J(String(len)) + ")"); + lines.push(" sink.writeBody(" + J(s.body) + ")"); + } + lines.push(" } catch (e) {"); + lines.push(" __chttpSend500(sink)"); + lines.push(" }"); + lines.push("}"); + return lines.join("\n"); +} + +// Ctx routes build ReqView/Ctx through the fetch contract surface and run +// the composed middleware+handler at App._chains[idx]. The ReqView is +// constructed exactly like _dispatchPrim constructs it: raw === null (the +// fetch arm), url/method/headers stub args, _setWeb for c.req.raw, and +// the absolute url overwrite for Hono parity. The seeded body (fetchShim) +// is always installed — _dispatchPrim does the same unconditionally so +// undeclared routes see it identically. +function emitCtxRunner(name: string, r: RouteSpec): string { + const idx = String(r.handlerIdx); + const lines: string[] = []; + lines.push( + "function " + + name + + "(a: App, m: string, url: string, pm: string[], qs: string, path: string, headers: Record | null, webReq: WebReqView | null, body: string, sink: FetchResponseSink): Promise {", + ); + lines.push( + " const rv = new ReqView(null, qs, path, url, m, headers === null ? undefined : headers)", + ); + lines.push(" rv._seedPm(pm)"); + lines.push(" rv._setWeb(webReq)"); + lines.push(" if (webReq !== null) {"); + lines.push(" rv.url = String(webReq.url)"); + lines.push(" }"); + lines.push(" const c = new Ctx(rv, sink)"); + // The closures MUST return the chain result — same settle rule as the + // Node emit (_guardFetch/_bufferFetch drain it through runChainPromise; + // a dropped return would unhandled-reject and hang the response). + const chain = "a._chains[" + idx + "](c)"; + const guarded = "_guardFetch(c, sink, function (): unknown { return " + chain + " })"; + const buffered = + "_bufferFetch(c, sink, webReq, body, function (): unknown { return " + chain + " })"; + if (r.needsBody) { + if (r.method === "*") { + lines.push(' if (m === "POST" || m === "PUT" || m === "PATCH" || m === "DELETE") {'); + lines.push(" return " + buffered); + lines.push(" }"); + lines.push(" rv._setBody(body)"); + lines.push(" return " + guarded); + } else if (WRITE_METHODS[r.method] === true) { + lines.push(" return " + buffered); + } else { + lines.push(" rv._setBody(body)"); + lines.push(" return " + guarded); + } + } else { + lines.push(" rv._setBody(body)"); + lines.push(" return " + guarded); + } + lines.push("}"); + return lines.join("\n"); +} + +// The match check + runner call for one route inside a method section — +// same matcher text as the Node emit; ctx runners are awaited (their +// Promise resolves once the response has settled into the sink). +function emitCheck(r: RouteSpec, runner: string, ind: string): string { + const conds: string[] = []; + let wildcardIdx = -1; + const minLen = r.segs.length + 1; + for (let i = 0; i < r.segs.length; i++) { + const s = r.segs[i]; + if (s.kind === 2) { + wildcardIdx = i; + } + } + if (wildcardIdx >= 0) { + conds.push("segs.length >= " + String(wildcardIdx + 2)); + } else { + conds.push("segs.length === " + String(minLen)); + } + for (let i = 0; i < r.segs.length; i++) { + const s = r.segs[i]; + if (s.kind === 0) { + conds.push("segs[" + String(i + 1) + "] === " + J(s.lit)); + } + } + const out: string[] = []; + out.push(ind + "if (" + conds.join(" && ") + ") {"); + if (r.staticSend !== null) { + out.push(ind + " " + runner + "(sink)"); + out.push(ind + " return true"); + out.push(ind + "}"); + return out.join("\n"); + } + const pmParts: string[] = []; + let needsTail = false; + let tailStart = 0; + for (let i = 0; i < r.segs.length; i++) { + const s = r.segs[i]; + if (s.kind === 1) { + pmParts.push(J(s.lit), "dec(segs[" + String(i + 1) + "])"); + } else if (s.kind === 2) { + needsTail = true; + tailStart = i + 1; + pmParts.push(J("*"), "dec(t)"); + } + } + if (needsTail) { + out.push(ind + " let t = " + J("")); + out.push(ind + " for (let j = " + String(tailStart) + "; j < segs.length; j += 1) {"); + out.push(ind + " if (j > " + String(tailStart) + ") {"); + out.push(ind + ' t = t + "/"'); + out.push(ind + " }"); + out.push(ind + " t = t + segs[j]"); + out.push(ind + " }"); + } + const pm = pmParts.length === 0 ? "[]" : "[" + pmParts.join(", ") + "]"; + out.push( + ind + + " await " + + runner + + "(this._a, m, u, " + + pm + + ", qs, path, headers, webReq, body, sink)", + ); + out.push(ind + " return true"); + out.push(ind + "}"); + return out.join("\n"); +} + +export function emitFetchModule(model: ModuleModel, opts: { importFrom: string }): string { + const out: string[] = []; + out.push("// GENERATED by unplugin-chttp — do not edit."); + out.push("// Source: " + path.basename(model.fileName)); + out.push("// Web Standards arm — satisfies contracts/route-contract-fetch.md."); + out.push("export const ROUTE_CONTRACT_VERSION = " + String(ROUTE_CONTRACT_VERSION) + ";"); + const emitted = model.routes.filter((r) => r.emit); + const dropped = model.routes.filter((r) => !r.emit); + out.push( + "// " + + String(emitted.length) + + " route(s) lowered, " + + String(dropped.length) + + " left to the runtime path.", + ); + for (const w of model.warnings) { + out.push("// warning: " + w); + } + out.push(""); + + const header = + "export class CompiledFetchRoutes extends GenFetch {\n" + + " private _a: App\n" + + " ROUTE_CONTRACT_VERSION = " + + String(ROUTE_CONTRACT_VERSION) + + "\n" + + " constructor(a: App) {\n" + + " super()\n" + + " this._a = a\n" + + " }\n" + + " async dispatchFetch(\n" + + " method: string,\n" + + " url: string,\n" + + " headers: Record | null,\n" + + " webReq: WebReqView | null,\n" + + " body: string,\n" + + " bodyU8: Uint8Array | null,\n" + + " sink: FetchResponseSink,\n" + + " ): Promise {\n"; + + if (model.app === null || model.fatal !== "") { + // stub: compiles everywhere, declines every request to the runtime path + out.push("import { GenFetch } from " + J(opts.importFrom)); + out.push("import type { App, FetchResponseSink, WebReqView } from " + J(opts.importFrom)); + out.push(""); + out.push("export interface ChttpRouteEntry {"); + out.push(" method: string"); + out.push(" path: string"); + out.push(" kind: string"); + out.push(" handlerIdx: number"); + out.push(" needsBody: boolean"); + out.push("}"); + out.push(""); + out.push("export const routeTable: ChttpRouteEntry[] = []"); + out.push(""); + out.push(header); + out.push(" return false"); + out.push(" }"); + out.push("}"); + out.push(""); + return out.join("\n"); + } + + const ctxEmitted = emitted.filter((r) => r.staticSend === null); + const needCtx = ctxEmitted.length > 0; + const needBuffer = ctxEmitted.some( + (r) => r.needsBody && (r.method === "*" || WRITE_METHODS[r.method] === true), + ); + const needDec = ctxEmitted.some((r) => r.segs.some((s) => s.kind !== 0)); + const members: string[] = ["GenFetch"]; + if (needCtx) { + members.push("ReqView"); + members.push("Ctx"); + members.push("_guardFetch"); + } + if (needBuffer) { + members.push("_bufferFetch"); + } + if (needDec) { + members.push("dec"); + } + out.push("import { " + members.join(", ") + " } from " + J(opts.importFrom)); + out.push("import type { App, FetchResponseSink, WebReqView } from " + J(opts.importFrom)); + out.push(""); + + out.push("export interface ChttpRouteEntry {"); + out.push(" method: string"); + out.push(" path: string"); + out.push(" kind: string"); + out.push(" handlerIdx: number"); + out.push(" needsBody: boolean"); + out.push("}"); + out.push(""); + out.push("export const routeTable: ChttpRouteEntry[] = ["); + for (const r of emitted) { + out.push( + " { method: " + + J(r.method) + + ", path: " + + J(r.path) + + ", kind: " + + J(routeKind(r)) + + ", handlerIdx: " + + String(r.handlerIdx) + + ", needsBody: " + + String(r.needsBody) + + " },", + ); + } + out.push("]"); + out.push(""); + + // 500 through the same sink members the runners use — writeBody sets + // `sent` so the settle bookkeeping stays consistent. + out.push("function __chttpSend500(sink: FetchResponseSink): void {"); + out.push(" try {"); + out.push(" if (sink.isHeadersSent()) {"); + out.push(" sink.destroy()"); + out.push(" } else {"); + out.push(" sink.setStatus(500)"); + out.push(' sink.setHeader("content-type", "text/plain; charset=UTF-8")'); + out.push(' sink.setHeader("content-length", "21")'); + out.push(' sink.writeBody("Internal Server Error")'); + out.push(" }"); + out.push(" } catch (e) {"); + out.push(" sink.destroy()"); + out.push(" }"); + out.push("}"); + out.push(""); + + const runnerNames = new Map(); + let n = 0; + for (const r of emitted) { + const name = "run" + String(n); + n += 1; + runnerNames.set(r, name); + if (r.staticSend !== null) { + out.push(emitStaticRunner(name, r.staticSend)); + } else { + out.push(emitCtxRunner(name, r)); + } + out.push(""); + } + + const methods: string[] = []; + for (const r of emitted) { + if (r.method !== "*" && methods.indexOf(r.method) < 0) { + methods.push(r.method); + } + } + if (methods.indexOf("GET") >= 0 && methods.indexOf("HEAD") < 0) { + methods.push("HEAD"); + } + methods.sort((a, b) => { + const oa = METHOD_ORDER[a] === undefined ? 100 : METHOD_ORDER[a]; + const ob = METHOD_ORDER[b] === undefined ? 100 : METHOD_ORDER[b]; + if (oa !== ob) { + return oa - ob; + } + return a < b ? -1 : a > b ? 1 : 0; + }); + + out.push(header); + out.push(' const m = String(method || "")'); + out.push(' const u = String(url || "/")'); + out.push(" let path = u"); + out.push(' let qs = ""'); + out.push(' const qi = u.indexOf("?")'); + out.push(" if (qi >= 0) {"); + out.push(" path = String(u.slice(0, qi))"); + out.push(" qs = String(u.slice(qi + 1))"); + out.push(" }"); + out.push(' const segs = path.split("/")'); + for (const m of methods) { + const list = emitted + .filter((r) => r.method === m || r.method === "*" || (m === "HEAD" && r.method === "GET")) + .sort((a, b) => { + const d = segCmp(a, b); + if (d !== 0) { + return d; + } + const md = methodRank(a, m) - methodRank(b, m); + if (md !== 0) { + return md; + } + return a.registered - b.registered; + }); + if (list.length === 0) { + continue; + } + out.push(" if (m === " + J(m) + ") {"); + for (const r of list) { + out.push(emitCheck(r, runnerNames.get(r) as string, " ")); + } + out.push(" return false"); + out.push(" }"); + } + // fallthrough: methods with no section only see '*' routes + const star = emitted.filter((r) => r.method === "*").sort(segCmp); + for (const r of star) { + out.push(emitCheck(r, runnerNames.get(r) as string, " ")); + } + out.push(" return false"); + out.push(" }"); + out.push("}"); + out.push(""); + return out.join("\n"); +} diff --git a/packages/unplugin/src/index.ts b/packages/unplugin/src/index.ts index b6039f4..f18e6fa 100644 --- a/packages/unplugin/src/index.ts +++ b/packages/unplugin/src/index.ts @@ -20,6 +20,7 @@ import * as path from "node:path"; import picomatch from "picomatch"; import { createUnplugin } from "unplugin"; import { emitGenModule, genFileName } from "./emit.ts"; +import { emitFetchModule, genFetchFileName } from "./emitfetch.ts"; import { extractModule } from "./extract.ts"; import { emitOpenApiModule, openApiFileName } from "./openapi.ts"; import { emitClientModule, clientFileName } from "./client.ts"; @@ -167,6 +168,22 @@ export const chttpPlugin = createUnplugin((options?: ChttpPluginOptions) => { fs.mkdirSync(path.dirname(out), { recursive: true }); fs.writeFileSync(out, gen); } + // Web Standards arm (contracts/route-contract-fetch.md): the + // .gen.fetch.ts sibling the app.fetch/app.fetchShim path registers + // through withFetchDispatch. Emitted unconditionally — an app that + // never imports it pays nothing. + const genFetch = emitFetchModule(model, { importFrom: importFrom }); + const outFetch = path.join(path.dirname(id), ".chttp", genFetchFileName(id)); + let prevFetch = ""; + try { + prevFetch = fs.readFileSync(outFetch, "utf8"); + } catch { + prevFetch = ""; + } + if (prevFetch !== genFetch) { + fs.mkdirSync(path.dirname(outFetch), { recursive: true }); + fs.writeFileSync(outFetch, genFetch); + } if (openapiOn) { const stem = path .basename(id) diff --git a/packages/unplugin/test/e2e.test.ts b/packages/unplugin/test/e2e.test.ts index 0d92b35..35a53ec 100644 --- a/packages/unplugin/test/e2e.test.ts +++ b/packages/unplugin/test/e2e.test.ts @@ -293,3 +293,167 @@ describe("e2e: contract version validation at withDispatch", () => { assert.ok(true); }); }); + +// --------------------------------------------------------------------------- +// Fetch arm (contracts/route-contract-fetch.md): emit the Web Standards +// sibling module beside the Node arm, register it via withFetchDispatch, +// and drive it through the real app.fetch / app.fetchShim entries. +interface FetchGenInstance { + dispatchFetch: ( + method: string, + url: string, + headers: { [k: string]: string } | null, + webReq: unknown, + body: string, + bodyU8: Uint8Array | null, + sink: unknown, + ) => Promise; +} + +interface FetchApp { + withFetchDispatch: (d: FetchGenInstance) => FetchApp; + fetch: (req: Request) => Promise; + fetchShim: ( + method: string, + url: string, + headers: { [k: string]: string } | null, + body: string, + respond: (status: number, hdrList: string[], body: string) => void, + ) => void; +} + +describe("e2e: generated fetch dispatch executes against the real App", () => { + let dir = ""; + let app: FetchApp | null = null; + + before(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "unplugin-chttp-fe-")); + const modPath = path.join(dir, "routes.chttp.ts"); + const relChttp = + "./" + + path.relative(fs.realpathSync(dir), fs.realpathSync(CHTTP_SRC)).split(path.sep).join("/"); + fs.writeFileSync(modPath, ROUTES_SRC.replace(/'@chttp\/router'/g, "'" + relChttp + "'")); + const model = extractModule(modPath, fs.readFileSync(modPath, "utf8"), { + imports: [relChttp], + }); + const genDir = path.join(dir, ".chttp"); + fs.mkdirSync(genDir, { recursive: true }); + const relChttpFromGen = + "./" + + path.relative(fs.realpathSync(genDir), fs.realpathSync(CHTTP_SRC)).split(path.sep).join("/"); + const { emitFetchModule, genFetchFileName } = await import("../src/emitfetch.ts"); + const genSrc = emitFetchModule(model, { + importFrom: relChttpFromGen, + modulePath: modPath, + }); + const genPath = path.join(genDir, genFetchFileName(modPath)); + fs.writeFileSync(genPath, genSrc); + const mod = (await import(genPath)) as { + CompiledFetchRoutes: new (a: FetchApp) => FetchGenInstance; + }; + const routesMod = (await import(modPath)) as { app: FetchApp }; + app = routesMod.app; + app.withFetchDispatch(new mod.CompiledFetchRoutes(app)); + }); + + after(() => { + if (dir !== "") { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + const go = (method: string, url: string, body: string | null = null): Promise => { + return (app as NonNullable).fetch( + new Request("http://x" + url, { + method: method, + body: body === null ? undefined : body, + }), + ); + }; + + it("serves static, param and wildcard routes through app.fetch", async () => { + const j = await go("GET", "/json"); + assert.equal(j.status, 200); + assert.equal(await j.text(), '{"hello":"world"}'); + assert.match(j.headers.get("content-type") ?? "", /json/); + const u = await go("GET", "/user/a%20b"); + assert.equal(await u.text(), '{"id":"a b"}'); + const w = await go("GET", "/files/a/b/c.txt"); + assert.equal(await w.text(), "a/b/c.txt"); + }); + + it("buffers the body via _bufferFetch for write methods", async () => { + const r = await go("POST", "/echo", "hi there"); + assert.equal(r.status, 200); + assert.equal(await r.text(), "hi there"); + }); + + it("redirects via a specialized runner and runs middleware chains", async () => { + const rd = await go("GET", "/redir"); + assert.equal(rd.status, 302); + assert.equal(rd.headers.get("location"), "/json"); + const mw = await go("GET", "/mwd"); + assert.equal(await mw.text(), "with-mw"); + assert.equal(mw.headers.get("x-mw"), "1"); + }); + + it("mounted sub-router + *-method + HEAD fallback all land", async () => { + const h = await go("GET", "/api/health"); + assert.equal(await h.text(), '{"ok":true}'); + const a = await go("DELETE", "/any"); + assert.equal(await a.text(), "any:DELETE"); + const hd = await go("HEAD", "/json"); + assert.equal(hd.status, 200); + }); + + it("declines cleanly — runtime path answers misses and bailed routes", async () => { + const nf = await go("GET", "/missing"); + assert.equal(nf.status, 404); + assert.equal(await nf.text(), "nope"); + const dyn = await go("GET", "/dyn"); + assert.equal(await dyn.text(), "dynamic"); + }); + + it("fetchShim drives the same emitted dispatch (ELF parity)", async () => { + const out = await new Promise<{ status: number; body: string }>((resolve) => { + (app as NonNullable).fetchShim( + "GET", + "/user/99", + null, + "", + (status, _hdrList, body) => { + resolve({ status, body }); + }, + ); + }); + assert.equal(out.status, 200); + assert.equal(out.body, '{"id":"99"}'); + const out2 = await new Promise<{ status: number; body: string }>((resolve) => { + (app as NonNullable).fetchShim( + "POST", + "/echo", + null, + "shim-body", + (status, _hdrList, body) => { + resolve({ status, body }); + }, + ); + }); + assert.equal(out2.body, "shim-body"); + }); + + it("withFetchDispatch rejects a stale contract stamp", async () => { + const m = (await import(CHTTP_SRC)) as { + createRouter: () => { withFetchDispatch: (d: unknown) => unknown }; + ROUTE_CONTRACT_VERSION: number; + }; + assert.throws( + () => + m.createRouter().withFetchDispatch({ + ROUTE_CONTRACT_VERSION: 999, + dispatchFetch: async () => false, + }), + new RegExp("contract version mismatch.*v999.*v" + String(m.ROUTE_CONTRACT_VERSION)), + ); + }); +}); From 1dfa43a17a277381f84562b0a778146d2fd773ca Mon Sep 17 00:00:00 2001 From: Quim T <26749475+espetro@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:54:14 +0000 Subject: [PATCH 4/7] chore(fixture): register the fetch arm in vite-app Regenerates .chttp/ artifacts at contract v4 and wires CompiledFetchRoutes via app.withFetchDispatch so the fixture exercises the emitted fetch dispatch at startup. --- .../vite-app/src/.chttp/routes.gen.fetch.ts | 315 ++++++++++++++++++ fixtures/vite-app/src/.chttp/routes.gen.ts | 6 +- fixtures/vite-app/src/main.ts | 4 + 3 files changed, 322 insertions(+), 3 deletions(-) create mode 100644 fixtures/vite-app/src/.chttp/routes.gen.fetch.ts diff --git a/fixtures/vite-app/src/.chttp/routes.gen.fetch.ts b/fixtures/vite-app/src/.chttp/routes.gen.fetch.ts new file mode 100644 index 0000000..59e5542 --- /dev/null +++ b/fixtures/vite-app/src/.chttp/routes.gen.fetch.ts @@ -0,0 +1,315 @@ +// GENERATED by unplugin-chttp — do not edit. +// Source: routes.chttp.ts +// Web Standards arm — satisfies contracts/route-contract-fetch.md. +export const ROUTE_CONTRACT_VERSION = 4; +// 11 route(s) lowered, 0 left to the runtime path. +// warning: unplugin-chttp: /home/ubuntu/repos/chttp/fixtures/vite-app/src/routes.chttp.ts:41 get() with a non-literal path — this route stays runtime-only +// warning: unplugin-chttp: /home/ubuntu/repos/chttp/fixtures/vite-app/src/routes.chttp.ts:44 multipart route — stays runtime-only (internal dispatch parses) + +import { GenFetch, ReqView, Ctx, _guardFetch, _bufferFetch, dec } from "../chttp.ts" +import type { App, FetchResponseSink, WebReqView } from "../chttp.ts" + +export interface ChttpRouteEntry { + method: string + path: string + kind: string + handlerIdx: number + needsBody: boolean +} + +export const routeTable: ChttpRouteEntry[] = [ + { method: "GET", path: "/json", kind: "static", handlerIdx: 0, needsBody: false }, + { method: "GET", path: "/user/:id", kind: "param", handlerIdx: 1, needsBody: false }, + { method: "GET", path: "/files/*", kind: "wildcard", handlerIdx: 2, needsBody: false }, + { method: "GET", path: "/q", kind: "static", handlerIdx: 3, needsBody: false }, + { method: "POST", path: "/echo", kind: "static", handlerIdx: 4, needsBody: true }, + { method: "POST", path: "/echo2", kind: "static", handlerIdx: 5, needsBody: true }, + { method: "GET", path: "/redir", kind: "static", handlerIdx: 6, needsBody: false }, + { method: "GET", path: "/api/deep/:id", kind: "param", handlerIdx: 7, needsBody: false }, + { method: "GET", path: "/api/health", kind: "static", handlerIdx: 8, needsBody: false }, + { method: "GET", path: "/mwd", kind: "static", handlerIdx: 9, needsBody: false }, + { method: "*", path: "/any", kind: "static", handlerIdx: 10, needsBody: false }, +] + +function __chttpSend500(sink: FetchResponseSink): void { + try { + if (sink.isHeadersSent()) { + sink.destroy() + } else { + sink.setStatus(500) + sink.setHeader("content-type", "text/plain; charset=UTF-8") + sink.setHeader("content-length", "21") + sink.writeBody("Internal Server Error") + } + } catch (e) { + sink.destroy() + } +} + +function run0(sink: FetchResponseSink): void { + try { + sink.setStatus(200) + sink.setHeader("content-type", "application/json; charset=utf-8") + sink.setHeader("content-length", "17") + sink.writeBody("{\"hello\":\"world\"}") + } catch (e) { + __chttpSend500(sink) + } +} + +function run1(a: App, m: string, url: string, pm: string[], qs: string, path: string, headers: Record | null, webReq: WebReqView | null, body: string, sink: FetchResponseSink): Promise { + const rv = new ReqView(null, qs, path, url, m, headers === null ? undefined : headers) + rv._seedPm(pm) + rv._setWeb(webReq) + if (webReq !== null) { + rv.url = String(webReq.url) + } + const c = new Ctx(rv, sink) + rv._setBody(body) + return _guardFetch(c, sink, function (): unknown { return a._chains[1](c) }) +} + +function run2(a: App, m: string, url: string, pm: string[], qs: string, path: string, headers: Record | null, webReq: WebReqView | null, body: string, sink: FetchResponseSink): Promise { + const rv = new ReqView(null, qs, path, url, m, headers === null ? undefined : headers) + rv._seedPm(pm) + rv._setWeb(webReq) + if (webReq !== null) { + rv.url = String(webReq.url) + } + const c = new Ctx(rv, sink) + rv._setBody(body) + return _guardFetch(c, sink, function (): unknown { return a._chains[2](c) }) +} + +function run3(a: App, m: string, url: string, pm: string[], qs: string, path: string, headers: Record | null, webReq: WebReqView | null, body: string, sink: FetchResponseSink): Promise { + const rv = new ReqView(null, qs, path, url, m, headers === null ? undefined : headers) + rv._seedPm(pm) + rv._setWeb(webReq) + if (webReq !== null) { + rv.url = String(webReq.url) + } + const c = new Ctx(rv, sink) + rv._setBody(body) + return _guardFetch(c, sink, function (): unknown { return a._chains[3](c) }) +} + +function run4(a: App, m: string, url: string, pm: string[], qs: string, path: string, headers: Record | null, webReq: WebReqView | null, body: string, sink: FetchResponseSink): Promise { + const rv = new ReqView(null, qs, path, url, m, headers === null ? undefined : headers) + rv._seedPm(pm) + rv._setWeb(webReq) + if (webReq !== null) { + rv.url = String(webReq.url) + } + const c = new Ctx(rv, sink) + return _bufferFetch(c, sink, webReq, body, function (): unknown { return a._chains[4](c) }) +} + +function run5(a: App, m: string, url: string, pm: string[], qs: string, path: string, headers: Record | null, webReq: WebReqView | null, body: string, sink: FetchResponseSink): Promise { + const rv = new ReqView(null, qs, path, url, m, headers === null ? undefined : headers) + rv._seedPm(pm) + rv._setWeb(webReq) + if (webReq !== null) { + rv.url = String(webReq.url) + } + const c = new Ctx(rv, sink) + return _bufferFetch(c, sink, webReq, body, function (): unknown { return a._chains[5](c) }) +} + +function run6(sink: FetchResponseSink): void { + try { + sink.setStatus(302) + sink.setHeader("location", "/json") + sink.writeBody("") + } catch (e) { + __chttpSend500(sink) + } +} + +function run7(a: App, m: string, url: string, pm: string[], qs: string, path: string, headers: Record | null, webReq: WebReqView | null, body: string, sink: FetchResponseSink): Promise { + const rv = new ReqView(null, qs, path, url, m, headers === null ? undefined : headers) + rv._seedPm(pm) + rv._setWeb(webReq) + if (webReq !== null) { + rv.url = String(webReq.url) + } + const c = new Ctx(rv, sink) + rv._setBody(body) + return _guardFetch(c, sink, function (): unknown { return a._chains[7](c) }) +} + +function run8(sink: FetchResponseSink): void { + try { + sink.setStatus(200) + sink.setHeader("content-type", "application/json; charset=utf-8") + sink.setHeader("content-length", "11") + sink.writeBody("{\"ok\":true}") + } catch (e) { + __chttpSend500(sink) + } +} + +function run9(a: App, m: string, url: string, pm: string[], qs: string, path: string, headers: Record | null, webReq: WebReqView | null, body: string, sink: FetchResponseSink): Promise { + const rv = new ReqView(null, qs, path, url, m, headers === null ? undefined : headers) + rv._seedPm(pm) + rv._setWeb(webReq) + if (webReq !== null) { + rv.url = String(webReq.url) + } + const c = new Ctx(rv, sink) + rv._setBody(body) + return _guardFetch(c, sink, function (): unknown { return a._chains[9](c) }) +} + +function run10(a: App, m: string, url: string, pm: string[], qs: string, path: string, headers: Record | null, webReq: WebReqView | null, body: string, sink: FetchResponseSink): Promise { + const rv = new ReqView(null, qs, path, url, m, headers === null ? undefined : headers) + rv._seedPm(pm) + rv._setWeb(webReq) + if (webReq !== null) { + rv.url = String(webReq.url) + } + const c = new Ctx(rv, sink) + rv._setBody(body) + return _guardFetch(c, sink, function (): unknown { return a._chains[10](c) }) +} + +export class CompiledFetchRoutes extends GenFetch { + private _a: App + ROUTE_CONTRACT_VERSION = 4 + constructor(a: App) { + super() + this._a = a + } + async dispatchFetch( + method: string, + url: string, + headers: Record | null, + webReq: WebReqView | null, + body: string, + bodyU8: Uint8Array | null, + sink: FetchResponseSink, + ): Promise { + + const m = String(method || "") + const u = String(url || "/") + let path = u + let qs = "" + const qi = u.indexOf("?") + if (qi >= 0) { + path = String(u.slice(0, qi)) + qs = String(u.slice(qi + 1)) + } + const segs = path.split("/") + if (m === "GET") { + if (segs.length === 2 && segs[1] === "json") { + run0(sink) + return true + } + if (segs.length === 3 && segs[1] === "user") { + await run1(this._a, m, u, ["id", dec(segs[2])], qs, path, headers, webReq, body, sink) + return true + } + if (segs.length >= 3 && segs[1] === "files") { + let t = "" + for (let j = 2; j < segs.length; j += 1) { + if (j > 2) { + t = t + "/" + } + t = t + segs[j] + } + await run2(this._a, m, u, ["*", dec(t)], qs, path, headers, webReq, body, sink) + return true + } + if (segs.length === 2 && segs[1] === "q") { + await run3(this._a, m, u, [], qs, path, headers, webReq, body, sink) + return true + } + if (segs.length === 2 && segs[1] === "redir") { + run6(sink) + return true + } + if (segs.length === 4 && segs[1] === "api" && segs[2] === "deep") { + await run7(this._a, m, u, ["id", dec(segs[3])], qs, path, headers, webReq, body, sink) + return true + } + if (segs.length === 3 && segs[1] === "api" && segs[2] === "health") { + run8(sink) + return true + } + if (segs.length === 2 && segs[1] === "mwd") { + await run9(this._a, m, u, [], qs, path, headers, webReq, body, sink) + return true + } + if (segs.length === 2 && segs[1] === "any") { + await run10(this._a, m, u, [], qs, path, headers, webReq, body, sink) + return true + } + return false + } + if (m === "HEAD") { + if (segs.length === 2 && segs[1] === "json") { + run0(sink) + return true + } + if (segs.length === 3 && segs[1] === "user") { + await run1(this._a, m, u, ["id", dec(segs[2])], qs, path, headers, webReq, body, sink) + return true + } + if (segs.length >= 3 && segs[1] === "files") { + let t = "" + for (let j = 2; j < segs.length; j += 1) { + if (j > 2) { + t = t + "/" + } + t = t + segs[j] + } + await run2(this._a, m, u, ["*", dec(t)], qs, path, headers, webReq, body, sink) + return true + } + if (segs.length === 2 && segs[1] === "q") { + await run3(this._a, m, u, [], qs, path, headers, webReq, body, sink) + return true + } + if (segs.length === 2 && segs[1] === "redir") { + run6(sink) + return true + } + if (segs.length === 4 && segs[1] === "api" && segs[2] === "deep") { + await run7(this._a, m, u, ["id", dec(segs[3])], qs, path, headers, webReq, body, sink) + return true + } + if (segs.length === 3 && segs[1] === "api" && segs[2] === "health") { + run8(sink) + return true + } + if (segs.length === 2 && segs[1] === "mwd") { + await run9(this._a, m, u, [], qs, path, headers, webReq, body, sink) + return true + } + if (segs.length === 2 && segs[1] === "any") { + await run10(this._a, m, u, [], qs, path, headers, webReq, body, sink) + return true + } + return false + } + if (m === "POST") { + if (segs.length === 2 && segs[1] === "echo") { + await run4(this._a, m, u, [], qs, path, headers, webReq, body, sink) + return true + } + if (segs.length === 2 && segs[1] === "echo2") { + await run5(this._a, m, u, [], qs, path, headers, webReq, body, sink) + return true + } + if (segs.length === 2 && segs[1] === "any") { + await run10(this._a, m, u, [], qs, path, headers, webReq, body, sink) + return true + } + return false + } + if (segs.length === 2 && segs[1] === "any") { + await run10(this._a, m, u, [], qs, path, headers, webReq, body, sink) + return true + } + return false + } +} diff --git a/fixtures/vite-app/src/.chttp/routes.gen.ts b/fixtures/vite-app/src/.chttp/routes.gen.ts index ed5d44a..bb1c880 100644 --- a/fixtures/vite-app/src/.chttp/routes.gen.ts +++ b/fixtures/vite-app/src/.chttp/routes.gen.ts @@ -1,6 +1,6 @@ // GENERATED by unplugin-chttp — do not edit. // Source: routes.chttp.ts -export const ROUTE_CONTRACT_VERSION = 3; +export const ROUTE_CONTRACT_VERSION = 4; // 11 route(s) lowered, 0 left to the runtime path. // warning: unplugin-chttp: /home/ubuntu/repos/chttp/fixtures/vite-app/src/routes.chttp.ts:41 get() with a non-literal path — this route stays runtime-only // warning: unplugin-chttp: /home/ubuntu/repos/chttp/fixtures/vite-app/src/routes.chttp.ts:44 multipart route — stays runtime-only (internal dispatch parses) @@ -38,7 +38,7 @@ function __chttpSend500(res: ServerResponse): void { res.destroy() } else { res.statusCode = 500 - res.setHeader("content-type", "text/plain; charset=utf-8") + res.setHeader("content-type", "text/plain; charset=UTF-8") res.setHeader("content-length", "21") res.end("Internal Server Error") } @@ -141,7 +141,7 @@ function run10(a: App, req: IncomingMessage, res: ServerResponse, pm: string[], export class CompiledRoutes extends GenDispatch { private _a: App - ROUTE_CONTRACT_VERSION = 3 + ROUTE_CONTRACT_VERSION = 4 constructor(a: App) { super() this._a = a diff --git a/fixtures/vite-app/src/main.ts b/fixtures/vite-app/src/main.ts index 5a24f8f..9f504e1 100644 --- a/fixtures/vite-app/src/main.ts +++ b/fixtures/vite-app/src/main.ts @@ -4,10 +4,14 @@ // which covers bailed routes, notFound, and misses. import { app } from './routes.chttp.ts' import { CompiledRoutes } from './.chttp/routes.gen.ts' +import { CompiledFetchRoutes } from './.chttp/routes.gen.fetch.ts' const port = process.argv.length > 2 ? Number(process.argv[2]) : 3100 app.withDispatch(new CompiledRoutes(app)) +// Web Standards arm (contracts/route-contract-fetch.md): app.fetch tries +// this generated dispatch first and declines to the runtime path on false. +app.withFetchDispatch(new CompiledFetchRoutes(app)) app.serve({ port: port }) console.log('vite-app (compiled dispatch) listening on :' + port) From 25645ebe5470c07c7d600f97669115d9f92cb420 Mon Sep 17 00:00:00 2001 From: Quim T <26749475+espetro@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:54:14 +0000 Subject: [PATCH 5/7] chore(release): intent for the GenFetch arm + fetch emitter --- .changeset/clever-sheep-taste.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/clever-sheep-taste.md diff --git a/.changeset/clever-sheep-taste.md b/.changeset/clever-sheep-taste.md new file mode 100644 index 0000000..6e845b0 --- /dev/null +++ b/.changeset/clever-sheep-taste.md @@ -0,0 +1,7 @@ +--- +"@chttp/router": minor +"@chttp/unplugin": minor +"@chttp/stream": minor +--- + +feat(router): Web Standards generated-dispatch arm (GenFetch/withFetchDispatch, contract v4) + fetch emitter in unplugin; @chttp/stream streams through FetchResponseSink as a real ReadableStream From 5269a0324b566613c59844ac23dd5c4a512d8a5c Mon Sep 17 00:00:00 2001 From: Quim T <26749475+espetro@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:54:23 +0000 Subject: [PATCH 6/7] bench: chttp-fetch + chttp-elf-fetch cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/chttp-fetch.ts is now the canonical route set served through app.fetch (a node:http bridge builds a real Request per message) — it keeps its scriptc-gate routes (/url, /vars, /cookies) and stays at 100% coverage. apps/chttp-elf-fetch.ts serves the same table through app.fetchShim for the scriptc ELF cell. run.sh gains chttp-fetch-node, chttp-fetch-bun, chttp-scriptc, and chttp-elf-fetch subjects; the Dockerfile builds the new ELF. --- .changeset/thick-frogs-invent.md | 5 ++ bench/Dockerfile | 1 + bench/apps/chttp-elf-fetch.ts | 62 ++++++++++++++++ bench/apps/chttp-fetch.ts | 121 +++++++++++++++++++++++-------- bench/run.sh | 4 + 5 files changed, 162 insertions(+), 31 deletions(-) create mode 100644 .changeset/thick-frogs-invent.md create mode 100644 bench/apps/chttp-elf-fetch.ts diff --git a/.changeset/thick-frogs-invent.md b/.changeset/thick-frogs-invent.md new file mode 100644 index 0000000..7d598ef --- /dev/null +++ b/.changeset/thick-frogs-invent.md @@ -0,0 +1,5 @@ +--- +"bench": none +--- + +bench: chttp-fetch-node/chttp-fetch-bun/chttp-elf-fetch/chttp-scriptc cells + RESULTS-v2 re-baseline diff --git a/bench/Dockerfile b/bench/Dockerfile index 3fc5add..7ed57f4 100644 --- a/bench/Dockerfile +++ b/bench/Dockerfile @@ -40,6 +40,7 @@ RUN npm ci \ && gcc -O2 -o ceilings/bin/c-mongoose ceilings/c-mongoose.c ceilings/mongoose.c -DMG_ENABLE_LOG=0 \ && scriptc build apps/raw.ts -o bin/raw-scriptc --optimization=speed \ && scriptc build apps/chttp.ts -o bin/chttp-scriptc --optimization=speed \ + && scriptc build apps/chttp-elf-fetch.ts -o bin/chttp-elf-fetch --optimization=speed \ && scriptc build apps/chttp-fast.ts -o bin/chttp-fast-scriptc --optimization=speed # Full run: autocannon matrix -> wrk2 cross-validation -> aggregates. diff --git a/bench/apps/chttp-elf-fetch.ts b/bench/apps/chttp-elf-fetch.ts new file mode 100644 index 0000000..2fe4043 --- /dev/null +++ b/bench/apps/chttp-elf-fetch.ts @@ -0,0 +1,62 @@ +// chttp-elf-fetch bench subject: the canonical bench route set served +// through the Web Standards dispatch arm in a scriptc ELF. `new Request` +// is unconstructable under scriptc, so the entry here is app.fetchShim — +// the (method, url, headers, body) -> (status, hdrList, body) shim that +// drives the same emitted/runtime fetch dispatch app.fetch drives +// (contracts/route-contract-fetch.md). +// +// scriptc build apps/chttp-elf-fetch.ts -o bin/chttp-elf-fetch --optimization=speed +// bench/run.sh wires it as `chttp-elf-fetch`. + +import { createServer } from "node:http"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { createRouter } from "../../packages/router/src/index.ts"; +import { MIXED_STATIC, MIXED_PARAM, MIXED_WILD } from "./common.mjs"; + +const app = createRouter(); + +app.use("*", (c, next) => { + c.header("x-power", "elf"); + next(); +}); + +// canonical bench route set — identical to apps/chttp.ts +app.get("/", (c) => c.text("")); +app.get("/hello", (c) => c.text("Hello World!")); +app.get("/json", (c) => c.json({ hello: "world" })); +app.get("/user/:id", (c) => c.json({ id: c.req.param("id") })); +app.post("/echo", (c) => c.text(c.req.body()), { body: true }); +app.post("/user", (c) => c.text("")); +app.get("/deep/a/b/c/d/e", (c) => c.json({ ok: true })); +app.get("/files/*", (c) => c.text(c.req.param("*") || "")); + +// 100-route mixed table: 60 static / 30 param / 10 wildcard. +for (const p of MIXED_STATIC) app.get(p, (c) => c.json({ ok: true })); +for (const p of MIXED_PARAM) app.get(p, (c) => c.json({ ok: true })); +for (const p of MIXED_WILD) app.get(p, (c) => c.json({ ok: true })); + +const srv = createServer(function (req: IncomingMessage, res: ServerResponse): void { + let body = ""; + req.on("data", function (chunk: Buffer): void { + body = body + chunk.toString(); + }); + req.on("end", function (): void { + app.fetchShim( + String(req.method || "GET"), + String(req.url || "/"), + null, + body, + function (status: number, hdrList: string[], out: string): void { + res.statusCode = status; + for (let i = 0; i + 1 < hdrList.length; i += 2) { + res.setHeader(String(hdrList[i]), String(hdrList[i + 1])); + } + res.end(out); + }, + ); + }); +}); + +srv.listen(Number(process.argv[2] || process.env.PORT || 3000), function (): void { + console.log("chttp-elf-fetch listening"); +}); diff --git a/bench/apps/chttp-fetch.ts b/bench/apps/chttp-fetch.ts index ac8e6d0..8d67ef8 100644 --- a/bench/apps/chttp-fetch.ts +++ b/bench/apps/chttp-fetch.ts @@ -1,20 +1,20 @@ -// chttp app.fetch Web Standards compile gate: -// `scriptc coverage bench/apps/chttp-fetch.ts` must stay 100% static. +// chttp-fetch bench subject: the canonical bench route set served through +// app.fetch — a node:http bridge builds a real `Request` per incoming +// message and pipes the `Response` back out. This is the Web Standards +// entry surface the fetch arm of the dispatch contract answers +// (contracts/route-contract-fetch.md); bench/run.sh wires it as +// `chttp-fetch-node` and `chttp-fetch-bun`. // -// Exercises the new app.fetch(req: Request) -> Promise -// surface end-to-end so a regression in the Web Standards -// lowerer shows up here before it ships. Uses a single in-process -// `new Request(...)` build (no network) so the gate runs offline -// and finishes in milliseconds; the runtime is verified by the -// router test suite, not by this gate. -// -// Also exercises the three Ctx surface additions that ship with -// app.fetch — c.set/c.get, c.req.url, c.header append — and the -// path-scoped middleware shape (app.use('*', ...)). Each is a -// separate route so a regression in any one fails the gate with -// a clear error rather than a generic compile failure. +// Doubles as the chttp-fetch scriptc gate: `scriptc coverage +// bench/apps/chttp-fetch.ts` must stay 100% static. The gate's original +// coverage targets are kept — c.set/c.get (/vars), c.req.url (/url), +// header append (/cookies), path middleware (app.use('*', ...)) and a +// direct in-process `new Request` + app.fetch call. +import { createServer } from "node:http"; +import type { IncomingMessage, ServerResponse } from "node:http"; import { createRouter } from "../../packages/router/src/index.ts"; +import { MIXED_STATIC, MIXED_PARAM, MIXED_WILD } from "./common.mjs"; const app = createRouter(); @@ -23,34 +23,93 @@ app.use("*", (c, next) => { next(); }); +// gate routes (coverage targets — not benched) app.get("/url", (c) => { return c.json({ url: c.req.url, path: c.req.path }); }); - app.get("/vars", (c) => { c.set("user", { id: 1, name: "ada" }); const u = c.get<{ id: number; name: string }>("user"); return c.json({ user: u }); }); - app.get("/cookies", (c) => { c.header("set-cookie", "a=1", { append: true }); c.header("set-cookie", "b=2", { append: true }); return c.text("ok"); }); -app.get("/echo", (c) => c.text("hello from app.fetch")); - -// Synthesize a Request so the scriptc coverage tool sees a real -// call to app.fetch with a Web Request in scope. The Promise is -// intentionally not awaited — scriptc treats the rejection as -// unreachable, but the call shape is what the gate checks. -const req = new Request("http://localhost/echo"); -const res = app.fetch(req); -void res; - -// The Node path stays available for local runs of the same -// source (pnpm --filter bench start serves on :3000). The gate -// is the static coverage check above; serve() is here so the -// file is also a runnable app for human spot-checks. -app.serve({ port: Number(process.env.PORT ?? 3000) }); +// canonical bench route set — identical to apps/chttp.ts +app.get("/", (c) => c.text("")); +app.get("/hello", (c) => c.text("Hello World!")); +app.get("/json", (c) => c.json({ hello: "world" })); +app.get("/user/:id", (c) => c.json({ id: c.req.param("id") })); +app.post("/echo", (c) => c.text(c.req.body()), { body: true }); +app.post("/user", (c) => c.text("")); +app.get("/deep/a/b/c/d/e", (c) => c.json({ ok: true })); +app.get("/files/*", (c) => c.text(c.req.param("*") || "")); + +// 100-route mixed table: 60 static / 30 param / 10 wildcard. +for (const p of MIXED_STATIC) app.get(p, (c) => c.json({ ok: true })); +for (const p of MIXED_PARAM) app.get(p, (c) => c.json({ ok: true })); +for (const p of MIXED_WILD) app.get(p, (c) => c.json({ ok: true })); + +// In-process call so the gate sees `new Request` + `app.fetch` directly. +const probe = new Request("http://localhost/hello"); +void app.fetch(probe); + +const srv = createServer(function (req: IncomingMessage, res: ServerResponse): void { + const method = String(req.method || "GET"); + const url = String(req.url || "/"); + let body = ""; + req.on("data", function (chunk: Buffer): void { + body = body + chunk.toString(); + }); + req.on("end", function (): void { + const headers: { [k: string]: string } = {}; + const raw = req.headers; + const keys = Object.keys(raw); + for (let i = 0; i < keys.length; i++) { + const k = keys[i]; + const v = raw[k]; + if (typeof v === "string") { + headers[k] = v; + } + } + let wreq: Request; + if (method === "GET" || method === "HEAD") { + wreq = new Request("http://x" + url, { method: method, headers: headers }); + } else { + wreq = new Request("http://x" + url, { + method: method, + headers: headers, + body: body, + }); + } + const p = app.fetch(wreq); + p.then( + function (resp: Response): void { + res.statusCode = resp.status; + resp.headers.forEach(function (v: string, k: string): void { + res.setHeader(k, v); + }); + const tp = resp.text(); + tp.then( + function (text: string): void { + res.end(text); + }, + function (): void { + res.end(""); + }, + ); + }, + function (): void { + res.statusCode = 500; + res.end(""); + }, + ); + }); +}); + +srv.listen(Number(process.argv[2] || process.env.PORT || 3000), function (): void { + console.log("chttp-fetch listening"); +}); diff --git a/bench/run.sh b/bench/run.sh index 146d538..e7db1e3 100644 --- a/bench/run.sh +++ b/bench/run.sh @@ -20,6 +20,10 @@ mkdir -p "$RESULTS" logs SUBJECTS=( "chttp-node|node apps/chttp.ts|full|" "chttp-bun|bun apps/chttp.ts|full|" + "chttp-fetch-node|node apps/chttp-fetch.ts|full|" + "chttp-fetch-bun|bun apps/chttp-fetch.ts|full|" + "chttp-scriptc|./bin/chttp-scriptc|full|bin/chttp-scriptc" + "chttp-elf-fetch|./bin/chttp-elf-fetch|full|bin/chttp-elf-fetch" "raw-node|node apps/raw.ts|full|" "raw-bun|bun apps/raw-bun.ts|full|" "raw-scriptc|./bin/raw-scriptc|full|bin/raw-scriptc" From 3fea8bec570a25833aa801e4eedb9efb3238069d Mon Sep 17 00:00:00 2001 From: Quim T <26749475+espetro@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:13:53 +0000 Subject: [PATCH 7/7] bench: re-baseline v2 with fetch-surface cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the 2026-10-18 section: chttp-fetch-node/-bun/-elf-fetch medians on the canonical 8-cell matrix, cross-compares, and caveats. Headline: the fetch-shaped dispatch arm is at parity with the Node arm (chttp-elf-fetch 93-140% of chttp-scriptc); the app.fetch node:http bridge pays undici Request/Response churn (53-73% of chttp-node, below the 80-95% prediction — documented). Raw controls did not hold +/-5% on echo/deep cells this run; flagged rather than smoothed. --- bench/RESULTS-v2.md | 131 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/bench/RESULTS-v2.md b/bench/RESULTS-v2.md index 16183a4..edb6d60 100644 --- a/bench/RESULTS-v2.md +++ b/bench/RESULTS-v2.md @@ -538,3 +538,134 @@ scriptc build with the embedded dynamic engine enabled, or it conflates a the lowerings above (or hono's dispatch is restructured into the subset), the comparison chttp-ELF vs compiled-Hono has no measurable right-hand side, and the only honest compiled-Hono row is "does not run". +## Fetch-surface cells + re-baseline (2026-10-18, Linux VM) + +Branch `devin/1792348542-genfetch-bench`, post-#58 dispatch (contract v4). +New subjects: `chttp-fetch-node`/`chttp-fetch-bun` serve the canonical table +through `app.fetch` behind a node:http bridge that builds a real `Request` +per message; `chttp-elf-fetch` compiles `apps/chttp-elf-fetch.ts` (`app.fetchShim` +— `new Request` is not runtime-constructible under scriptc) into a scriptc ELF. +Same-run `vs raw-node` uses this run's raw-node column. + +| subject | cell | rps | vs raw-node | p50 | p99 | max | MB/s | err/t/o | non2xx | +|---------|------|-----|-------------|-----|-----|-----|------|---------|--------| +| chttp-node | json | 56,907 | 0.68x | 0.00 | 5.0 | 39 | 10.1 | 0/0 | 0 | +| chttp-node | user | 60,243 | 0.71x | 0.00 | 4.0 | 22 | 10.4 | 0/0 | 0 | +| chttp-node | echo64 | 42,396 | 0.77x | 0.00 | 5.0 | 37 | 9.2 | 0/0 | 0 | +| chttp-node | echo1k | 36,675 | 0.72x | 1.00 | 5.0 | 31 | 41.6 | 0/0 | 0 | +| chttp-node | deep | 61,461 | 0.84x | 0.00 | 4.0 | 19 | 10.6 | 0/0 | 0 | +| chttp-node | files | 69,616 | 0.85x | 0.00 | 3.0 | 22 | 11.4 | 0/0 | 0 | +| chttp-node | missing | 74,259 | 0.94x | 0.00 | 3.0 | 20 | 12.7 | 0/0 | 1356172 | +| chttp-node | mixed | 61,747 | 0.80x | 0.00 | 4.0 | 279 | 10.7 | 0/0 | 0 | +| chttp-fetch-node | json | 30,769 | 0.37x | 1.00 | 6.0 | 30 | 5.9 | 0/0 | 0 | +| chttp-fetch-node | user | 32,340 | 0.38x | 1.00 | 6.0 | 21 | 6.0 | 0/0 | 0 | +| chttp-fetch-node | echo64 | 26,562 | 0.49x | 1.00 | 7.0 | 30 | 6.2 | 0/0 | 0 | +| chttp-fetch-node | echo1k | 26,824 | 0.52x | 2.00 | 6.0 | 26 | 30.8 | 0/0 | 0 | +| chttp-fetch-node | deep | 38,193 | 0.52x | 1.00 | 4.0 | 20 | 7.1 | 0/0 | 0 | +| chttp-fetch-node | files | 37,499 | 0.46x | 1.00 | 4.0 | 21 | 6.7 | 0/0 | 0 | +| chttp-fetch-node | missing | 39,280 | 0.50x | 1.00 | 4.0 | 27 | 7.3 | 0/0 | 705130 | +| chttp-fetch-node | mixed | 35,465 | 0.46x | 1.00 | 5.0 | 231 | 6.6 | 0/0 | 0 | +| chttp-fetch-bun | json | 53,611 | 0.64x | 0.00 | 5.0 | 35 | 10.3 | 0/0 | 0 | +| chttp-fetch-bun | user | 51,696 | 0.61x | 0.00 | 5.0 | 24 | 9.7 | 0/0 | 0 | +| chttp-fetch-bun | echo64 | 46,401 | 0.85x | 1.00 | 5.0 | 24 | 10.8 | 0/0 | 0 | +| chttp-fetch-bun | echo1k | 44,373 | 0.87x | 1.00 | 4.0 | 20 | 51.0 | 0/0 | 0 | +| chttp-fetch-bun | deep | 54,160 | 0.74x | 0.00 | 4.0 | 23 | 10.1 | 0/0 | 0 | +| chttp-fetch-bun | files | 48,672 | 0.60x | 0.00 | 5.0 | 27 | 8.7 | 0/0 | 0 | +| chttp-fetch-bun | missing | 52,928 | 0.67x | 0.00 | 4.0 | 24 | 9.8 | 0/0 | 955802 | +| chttp-fetch-bun | mixed | 50,181 | 0.65x | 0.00 | 5.0 | 406 | 9.4 | 0/0 | 0 | +| chttp-bun | json | 95,275 | 1.14x | 0.00 | 2.0 | 19 | 17.0 | 0/0 | 0 | +| chttp-bun | user | 94,592 | 1.12x | 0.00 | 2.0 | 23 | 16.3 | 0/0 | 0 | +| chttp-bun | echo64 | 69,149 | 1.26x | 0.00 | 4.0 | 26 | 15.0 | 0/0 | 0 | +| chttp-bun | echo1k | 55,307 | 1.08x | 0.00 | 4.0 | 23 | 62.8 | 0/0 | 0 | +| chttp-bun | deep | 68,789 | 0.94x | 0.00 | 4.0 | 20 | 11.9 | 0/0 | 0 | +| chttp-bun | files | 68,699 | 0.84x | 0.00 | 4.0 | 19 | 11.3 | 0/0 | 0 | +| chttp-bun | missing | 69,627 | 0.88x | 0.00 | 4.0 | 41 | 11.9 | 0/0 | 1273118 | +| chttp-bun | mixed | 54,117 | 0.70x | 0.00 | 5.0 | 246 | 9.3 | 0/0 | 0 | +| chttp-scriptc | json | 50,651 | 0.60x | 1.00 | 4.0 | 20 | 9.0 | 0/0 | 0 | +| chttp-scriptc | user | 43,435 | 0.51x | 1.00 | 4.0 | 23 | 7.5 | 0/0 | 0 | +| chttp-scriptc | echo64 | 33,205 | 0.61x | 1.00 | 5.0 | 25 | 7.2 | 0/0 | 0 | +| chttp-scriptc | echo1k | 31,549 | 0.62x | 1.00 | 5.0 | 22 | 35.8 | 0/0 | 0 | +| chttp-scriptc | deep | 42,901 | 0.59x | 1.00 | 5.0 | 33 | 7.4 | 0/0 | 0 | +| chttp-scriptc | files | 46,773 | 0.57x | 1.00 | 4.0 | 22 | 7.7 | 0/0 | 0 | +| chttp-scriptc | missing | 48,965 | 0.62x | 1.00 | 4.0 | 22 | 8.4 | 0/0 | 868312 | +| chttp-scriptc | mixed | 48,288 | 0.63x | 1.00 | 4.0 | 241 | 8.3 | 0/0 | 0 | +| chttp-elf-fetch | json | 47,093 | 0.56x | 1.00 | 4.0 | 29 | 9.0 | 0/0 | 0 | +| chttp-elf-fetch | user | 47,877 | 0.57x | 1.00 | 4.0 | 30 | 8.9 | 0/0 | 0 | +| chttp-elf-fetch | echo64 | 46,459 | 0.85x | 1.00 | 4.0 | 21 | 10.7 | 0/0 | 0 | +| chttp-elf-fetch | echo1k | 43,787 | 0.86x | 1.00 | 4.0 | 25 | 50.3 | 0/0 | 0 | +| chttp-elf-fetch | deep | 50,411 | 0.69x | 1.00 | 4.0 | 25 | 9.4 | 0/0 | 0 | +| chttp-elf-fetch | files | 43,461 | 0.53x | 1.00 | 4.0 | 38 | 7.7 | 0/0 | 0 | +| chttp-elf-fetch | missing | 47,584 | 0.60x | 1.00 | 4.0 | 30 | 8.8 | 0/0 | 852084 | +| chttp-elf-fetch | mixed | 46,661 | 0.61x | 1.00 | 4.0 | 259 | 8.7 | 0/0 | 0 | +| raw-node | json | 83,840 | 1.00x | 0.00 | 3.0 | 28 | 13.8 | 0/0 | 0 | +| raw-node | user | 84,437 | 1.00x | 0.00 | 3.0 | 21 | 13.4 | 0/0 | 0 | +| raw-node | echo64 | 54,715 | 1.00x | 0.00 | 4.0 | 20 | 11.1 | 0/0 | 0 | +| raw-node | echo1k | 51,211 | 1.00x | 0.00 | 5.0 | 22 | 57.4 | 0/0 | 0 | +| raw-node | deep | 73,088 | 1.00x | 0.00 | 4.0 | 25 | 11.6 | 0/0 | 0 | +| raw-node | files | 81,525 | 1.00x | 0.00 | 3.0 | 25 | 12.2 | 0/0 | 0 | +| raw-node | missing | 79,275 | 1.00x | 0.00 | 3.0 | 23 | 12.4 | 0/0 | 1456341 | +| raw-node | mixed | 77,067 | 1.00x | 0.00 | 4.0 | 236 | 12.2 | 0/0 | 0 | +| raw-scriptc | json | 94,645 | 1.13x | 0.00 | 2.0 | 24 | 15.5 | 0/0 | 0 | +| raw-scriptc | user | 70,067 | 0.83x | 0.00 | 4.0 | 36 | 11.1 | 0/0 | 0 | +| raw-scriptc | echo64 | 71,363 | 1.30x | 0.00 | 4.0 | 33 | 14.5 | 0/0 | 0 | +| raw-scriptc | echo1k | 68,616 | 1.34x | 0.00 | 4.0 | 28 | 76.9 | 0/0 | 0 | +| raw-scriptc | deep | 86,389 | 1.18x | 0.00 | 3.0 | 27 | 13.7 | 0/0 | 0 | +| raw-scriptc | files | 89,632 | 1.10x | 0.00 | 3.0 | 23 | 13.4 | 0/0 | 0 | +| raw-scriptc | missing | 90,859 | 1.15x | 0.00 | 3.0 | 21 | 14.2 | 0/0 | 1619221 | +| raw-scriptc | mixed | 60,795 | 0.79x | 0.00 | 4.0 | 253 | 9.6 | 0/0 | 0 | +| hono-bun | json | 98,219 | 1.17x | 0.00 | 3.0 | 25 | 11.7 | 0/0 | 0 | +| hono-bun | user | 100,160 | 1.19x | 0.00 | 3.0 | 22 | 11.4 | 0/0 | 0 | +| hono-bun | echo64 | 97,291 | 1.78x | 0.00 | 2.0 | 23 | 16.7 | 0/0 | 0 | +| hono-bun | echo1k | 92,832 | 1.81x | 0.00 | 3.0 | 18 | 101.1 | 0/0 | 0 | +| hono-bun | deep | 97,771 | 1.34x | 0.00 | 3.0 | 17 | 11.1 | 0/0 | 0 | +| hono-bun | files | 102,699 | 1.26x | 0.00 | 2.0 | 20 | 12.1 | 0/0 | 0 | +| hono-bun | missing | 105,621 | 1.33x | 0.00 | 2.0 | 21 | 13.8 | 0/0 | 1899699 | +| hono-bun | mixed | 99,392 | 1.29x | 0.00 | 3.0 | 225 | 11.3 | 0/0 | 0 | + +### Fetch-surface cross-compares (same-run medians) + +| compare | json | user | echo64 | echo1k | deep | files | missing | mixed | +|---------|------|------|--------|--------|------|-------|---------|-------| +| chttp-fetch-node / chttp-node | 54% | 54% | 63% | 73% | 62% | 54% | 53% | 57% | +| chttp-fetch-bun / chttp-bun | 56% | 55% | 67% | 80% | 79% | 71% | 76% | 93% | +| chttp-elf-fetch / chttp-scriptc | 93% | 110% | 140% | 139% | 118% | 93% | 97% | 97% | +| chttp-bun / hono-bun | 97% | 94% | 71% | 60% | 70% | 67% | 66% | 54% | + +### What the fetch cells say + +- **The dispatch arm is not the gap.** `chttp-elf-fetch` (fetchShim primitives → + same `_dispatchPrim` → `FetchResponseSink` → respond callback) lands at + 93-140% of `chttp-scriptc` — at parity or ahead on every cell, no Request/ + Response construction anywhere on that path. Post-#58 the fetch-shaped + dispatch costs nothing measurable. +- **`chttp-fetch-node` runs at 53-73% of `chttp-node`** — the issue's 80-95% + prediction does not hold on the Node bridge. The cost is upstream of + dispatch: undici `new Request` + `Headers` construction per message and + `new Response`/`resp.text()` re-buffering on the way out (~2 wrapped + object constructions + 2 stream bufferings per request vs one + `res.writeHead`/`res.end`). `chttp-fetch-bun` lands at 55-93% of + `chttp-bun` — same shape, smaller penalty (Bun's Web primitives are + cheaper), confirming the gap is runtime Web-object churn, not dispatch. +- **chttp-bun is at 94-97% of hono-bun on json/user**, 54-71% elsewhere + (hono-bun's flat ~97-105k across cells continues to hold). +- **Controls did not stay flat on every cell**: raw-node is within ±5% on + user/missing (+0.2%/-1.3% vs v2) but down 13-22% on echo64/echo1k/deep; + raw-scriptc json +4% but user -29%. Treat absolute deltas vs the v2 table + as noise-bound (rep spread this run reached 77-97k on raw-scriptc json); + same-run `vs raw-node` ratios and the cross-compares above are the stable + reads. + +### Caveats + +- **Fetch-surface non-2xx cells**: `missing` counts 404s by design + (~705k-956k non-2xx on fetch subjects — the cell hammers unmounted paths; + totals scale with throughput, not a regression). +- **Request bodies are fully buffered** on both fetch bridges before + dispatch — `chttp-fetch.ts` concatenates `data` chunks into the Request + body, `chttp-elf-fetch.ts` into the fetchShim body param; the contract + resolves the body before dispatch, so streaming request bodies are not + exercised. `echo1k` is the largest buffered-body cell. +- `new Request` is not runtime-constructible under scriptc (module-level + globals only) — the ELF cell enters through `app.fetchShim`, which skips + Request/Response construction entirely; it is not a like-for-like of + `chttp-fetch-node` (that's the point of the gap analysis above).