From 6feebb5aa1a4a765625c6c46e22c5361c784bd99 Mon Sep 17 00:00:00 2001 From: Quino Terrasa <26749475+espetro@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:50:08 +0000 Subject: [PATCH 1/4] fix(router): probe _headers with hasOwnProperty in ReqView.header() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare this._headers[key] read throws "record has no key" under program-code (single-graph/vendored) scriptc compilation — the typed record slot cannot represent undefined — while packed-dist compilation tolerates the read. Any optional request header (if-none-match, accept-encoding, accept) therefore fenced compiled apps that read headers through vendored sources; packed consumers were unaffected. Guard both the _headers and _headersDistinct lookups with Object.prototype.hasOwnProperty.call, the same idiom ReqView already uses for optional fields. Verified: a vendored @chttp/router + @chttp/static graph now serves 200/304 where it previously aborted. --- packages/router/src/index.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 2e0a52b..fca9f7c 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -372,8 +372,14 @@ export class ReqViewCore { const key = String(name).toLowerCase(); // Read from the materialized _headers table. The Node path copies // `raw.headers` once at construction; the app.fetch path populates - // it from the captured Web Request's Headers. - const v = this._headers[key]; + // it from the captured Web Request's Headers. Presence is probed with + // hasOwnProperty, never a bare index: under program-code compilation a + // missing typed-record key throws ("record has no key"), while packed + // dist tolerates the read — this is what fenced `If-None-Match` / + // `Accept-Encoding` lookups in single-graph (vendored) builds. + const v = Object.prototype.hasOwnProperty.call(this._headers, key) + ? this._headers[key] + : undefined; if (v !== undefined) { return String(v); } @@ -383,7 +389,9 @@ export class ReqViewCore { // Headers are single-value, joined in copyHeaders' string arm). // d[0] === v[0] for Node joins, so the first-element read is // identical to the IncomingMessage semantics. - const d = this._headersDistinct[key]; + const d = Object.prototype.hasOwnProperty.call(this._headersDistinct, key) + ? this._headersDistinct[key] + : undefined; if (d !== undefined && d.length > 0) { return String(d[0]); } From 7e43dcaff1bb7e5a62010939b33f56fadfdc53c5 Mon Sep 17 00:00:00 2001 From: Quino Terrasa <26749475+espetro@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:50:08 +0000 Subject: [PATCH 2/4] =?UTF-8?q?feat(examples):=20csr-react=20=E2=80=94=20R?= =?UTF-8?q?eact=20SPA=20on=20@chttp/static=20+=20baked-assets=20recipe=20(?= =?UTF-8?q?issue=20#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds examples/integrations/csr-react: a Vite-built React SPA served by serveStatic({ spa: true }) with an /api slice on the same router, plus the JSON-baked assets recipe — scripts/bake.mjs walks dist/ and emits an EmbeddedAssets subclass (BakedAssets) for the single-binary path, and scripts/vendor.mjs stages one program-code graph (the same pattern as ws-echo) so scriptc lowers the subclass. docs/static.md gains "Baking assets into the binary" documenting the recipe and the case-6 (JSON b64) vs case-9 (FFI blob) tradeoffs from the frontend probes; docs/context.md documents c.bytes. Node tests cover the honesty gates: sha256 byte-integrity of a hashed asset, ETag -> 304 with empty body, SPA Accept rules, /api JSON 404, and the same assertions against the baked source. ELF smoke verified manually under scriptc 0.3.6: 100% static coverage, byte-identical asset, 304 revalidation, SPA fallback. --- docs/context.md | 1 + docs/examples.md | 23 ++-- docs/static.md | 59 +++++---- examples/README.md | 39 +++--- examples/integrations/csr-react/.gitignore | 5 + examples/integrations/csr-react/README.md | 25 ++++ examples/integrations/csr-react/package.json | 26 ++++ .../integrations/csr-react/scripts/bake.mjs | 108 ++++++++++++++++ .../integrations/csr-react/scripts/vendor.mjs | 49 +++++++ examples/integrations/csr-react/src/app.ts | 14 ++ .../integrations/csr-react/src/main.baked.ts | 9 ++ examples/integrations/csr-react/src/main.ts | 6 + .../integrations/csr-react/test/csr.test.ts | 122 ++++++++++++++++++ .../integrations/csr-react/vite.config.mjs | 9 ++ .../integrations/csr-react/web/index.html | 12 ++ .../integrations/csr-react/web/src/App.jsx | 21 +++ .../integrations/csr-react/web/src/main.jsx | 10 ++ .../integrations/csr-react/web/src/style.css | 16 +++ pnpm-lock.yaml | 46 +++++++ pnpm-workspace.yaml | 1 + 20 files changed, 549 insertions(+), 52 deletions(-) create mode 100644 examples/integrations/csr-react/.gitignore create mode 100644 examples/integrations/csr-react/README.md create mode 100644 examples/integrations/csr-react/package.json create mode 100644 examples/integrations/csr-react/scripts/bake.mjs create mode 100644 examples/integrations/csr-react/scripts/vendor.mjs create mode 100644 examples/integrations/csr-react/src/app.ts create mode 100644 examples/integrations/csr-react/src/main.baked.ts create mode 100644 examples/integrations/csr-react/src/main.ts create mode 100644 examples/integrations/csr-react/test/csr.test.ts create mode 100644 examples/integrations/csr-react/vite.config.mjs create mode 100644 examples/integrations/csr-react/web/index.html create mode 100644 examples/integrations/csr-react/web/src/App.jsx create mode 100644 examples/integrations/csr-react/web/src/main.jsx create mode 100644 examples/integrations/csr-react/web/src/style.css diff --git a/docs/context.md b/docs/context.md index b684fce..3a2766a 100644 --- a/docs/context.md +++ b/docs/context.md @@ -26,6 +26,7 @@ Helpers write at statement level and return `c` for chaining. After one runs, th c.json({ ok: true }); c.text("plain"); c.html("

hi

"); +c.bytes(new Uint8Array([0xff, 0xd8]), "image/jpeg"); // binary body c.status(201).json({ created: true }); c.header("x-trace", "abc"); c.redirect("/elsewhere", 302); diff --git a/docs/examples.md b/docs/examples.md index 37b3763..9fffed0 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -2,17 +2,18 @@ Standalone templates in [`examples/`](https://github.com/sigilco/chttp/tree/main/examples). Each is a normal project: copy the folder, install, build. -| Template | Shows | -| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------- | -| [`hello`](https://github.com/sigilco/chttp/tree/main/examples/hello) | Static, param and wildcard GET routes | -| [`middleware`](https://github.com/sigilco/chttp/tree/main/examples/middleware) | `app.use` global and per-route middleware | -| [`json-api`](https://github.com/sigilco/chttp/tree/main/examples/json-api) | POST body, `onError`, `notFound` | -| [`router-mount`](https://github.com/sigilco/chttp/tree/main/examples/router-mount) | `app.route` sub-router mounting | -| [`sse-clock`](https://github.com/sigilco/chttp/tree/main/examples/sse-clock) | Server-sent events, see [Streaming](streaming.md) | -| [`ws-echo`](https://github.com/sigilco/chttp/tree/main/examples/ws-echo) | `@chttp/ws` echo and room, see [WebSocket](websocket.md) | -| [`vite-elf`](https://github.com/sigilco/chttp/tree/main/examples/vite-elf) | Vite with `unplugin-scriptc` | -| [`vite-lowering`](https://github.com/sigilco/chttp/tree/main/examples/vite-lowering) | Build-time route lowering, see [Route lowering plugin](unplugin.md) | -| [`deploy-docker`](https://github.com/sigilco/chttp/tree/main/examples/deploy-docker) | Scratch image, see [Deploy](deploy.md) | +| Template | Shows | +| ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| [`hello`](https://github.com/sigilco/chttp/tree/main/examples/hello) | Static, param and wildcard GET routes | +| [`middleware`](https://github.com/sigilco/chttp/tree/main/examples/middleware) | `app.use` global and per-route middleware | +| [`json-api`](https://github.com/sigilco/chttp/tree/main/examples/json-api) | POST body, `onError`, `notFound` | +| [`router-mount`](https://github.com/sigilco/chttp/tree/main/examples/router-mount) | `app.route` sub-router mounting | +| [`sse-clock`](https://github.com/sigilco/chttp/tree/main/examples/sse-clock) | Server-sent events, see [Streaming](streaming.md) | +| [`ws-echo`](https://github.com/sigilco/chttp/tree/main/examples/ws-echo) | `@chttp/ws` echo and room, see [WebSocket](websocket.md) | +| [`vite-elf`](https://github.com/sigilco/chttp/tree/main/examples/vite-elf) | Vite with `unplugin-scriptc` | +| [`vite-lowering`](https://github.com/sigilco/chttp/tree/main/examples/vite-lowering) | Build-time route lowering, see [Route lowering plugin](unplugin.md) | +| [`deploy-docker`](https://github.com/sigilco/chttp/tree/main/examples/deploy-docker) | Scratch image, see [Deploy](deploy.md) | +| [`csr-react`](https://github.com/sigilco/chttp/tree/main/examples/integrations/csr-react) | React SPA on `@chttp/static`, baked-assets single binary, see [Static files](static.md) | ```sh cd examples/hello diff --git a/docs/static.md b/docs/static.md index db87110..f6dc70d 100644 --- a/docs/static.md +++ b/docs/static.md @@ -16,45 +16,60 @@ app.serve({ port: 3000 }); `AssetSource` is the port: one method, `get(path): Asset | null`, where `path` is decoded, relative to the root and has no leading slash. Strategies are subclasses. -| Source | Reads from | Notes | -| --- | --- | --- | +| Source | Reads from | Notes | +| ------------------ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `DiskAssets(root)` | the filesystem, as `Buffer`s | stats on every request, re-reads only when mtime or size changed, SHA-1 ETag computed on first request of a path | -| `EmbeddedAssets` | bytes held in the program | `add(path, asset)` or `load(records)` with a baked array of `EmbedRecord` | +| `EmbeddedAssets` | bytes held in the program | `add(path, asset)` or `load(records)` with a baked array of `EmbedRecord` | An `Asset` is a plain record: `body` (Buffer), `type`, `etag`, `size`, `mtimeMs`, `immutable` and `encodings` (precompressed variants, each with its own `enc`, `body` and `etag`). -Baked data must be an array of records, not a path-keyed object: the subset cannot index a string-keyed JSON object. The planned `assets` slice of `@chttp/unplugin` will emit a subclass of `EmbeddedAssets` whose constructor calls `load()` with such an array (`path`, `type`, `b64`, `etag`, `mtimeMs`, `immutable`, `encs`). +Baked data must be an array of records, not a path-keyed object: the subset cannot index a string-keyed JSON object. The `assets` slice recipe emits a subclass of `EmbeddedAssets` whose constructor calls `load()` with such an array (`path`, `type`, `b64`, `etag`, `mtimeMs`, `immutable`, `encs`) — see "Baking assets into the binary" below. ## serveStatic `serveStatic(app, prefix, src, opts?)` registers a GET splat route (HEAD comes from the router's GET fallback). `"/"` serves from the root, `"/assets"` serves `/assets/*`. -| Behaviour | Detail | -| --- | --- | -| Traversal | The splat is already percent-decoded, so `%2e%2e` and `..%2f` arrive as `..` segments and answer 403. Backslash, NUL, drive-letter tails (`C:/...`) and absolute tails are refused too | -| Dotfiles | Any segment starting with `.` answers 404, except `.well-known` | -| Symlinks | Paths are resolved with `realpath` before serving. A link that stays inside the root keeps working; one that escapes it answers 404 | -| Directories | `/docs/` and `/docs` serve `docs/index.html` | -| MIME | Built-in table by extension, `application/octet-stream` otherwise | -| Validators | Quoted ETag on every response, `Last-Modified` when the mtime is known. `If-None-Match` (including `W/` and `*`) answers 304 with no body | -| Cache-Control | `public, max-age=31536000, immutable` for hashed filenames, `no-cache` for HTML, `opts.cacheControl` (default `no-cache`) for the rest | -| Compression | If `app.js.br` or `app.js.gz` sit next to `app.js` (or are in `encodings`), the client's `accept-encoding` picks `br` over `gzip`; `Vary: accept-encoding` is always set for such assets. Codings match exactly and case-insensitively, never by substring. `q=0` (or `q=0.0`, `q=0.000`) excludes a coding; other q-values are not ranked, so the fixed br-over-gzip preference decides | -| SPA fallback | With `spa: true`: GET or HEAD, `accept` contains `text/html`, and the path has no extension serves the index file. Missing assets with an extension stay 404 | -| API prefixes | Paths under `opts.api` (default `["/api"]`) never reach the filesystem or the fallback, and an unmatched one answers `{"error":"not_found"}` with status 404 | +| Behaviour | Detail | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Traversal | The splat is already percent-decoded, so `%2e%2e` and `..%2f` arrive as `..` segments and answer 403. Backslash, NUL, drive-letter tails (`C:/...`) and absolute tails are refused too | +| Dotfiles | Any segment starting with `.` answers 404, except `.well-known` | +| Symlinks | Paths are resolved with `realpath` before serving. A link that stays inside the root keeps working; one that escapes it answers 404 | +| Directories | `/docs/` and `/docs` serve `docs/index.html` | +| MIME | Built-in table by extension, `application/octet-stream` otherwise | +| Validators | Quoted ETag on every response, `Last-Modified` when the mtime is known. `If-None-Match` (including `W/` and `*`) answers 304 with no body | +| Cache-Control | `public, max-age=31536000, immutable` for hashed filenames, `no-cache` for HTML, `opts.cacheControl` (default `no-cache`) for the rest | +| Compression | If `app.js.br` or `app.js.gz` sit next to `app.js` (or are in `encodings`), the client's `accept-encoding` picks `br` over `gzip`; `Vary: accept-encoding` is always set for such assets. Codings match exactly and case-insensitively, never by substring. `q=0` (or `q=0.0`, `q=0.000`) excludes a coding; other q-values are not ranked, so the fixed br-over-gzip preference decides | +| SPA fallback | With `spa: true`: GET or HEAD, `accept` contains `text/html`, and the path has no extension serves the index file. Missing assets with an extension stay 404 | +| API prefixes | Paths under `opts.api` (default `["/api"]`) never reach the filesystem or the fallback, and an unmatched one answers `{"error":"not_found"}` with status 404 | Hashed filenames are detected by name: the last `-` or `.` separated token of the stem is 8 to 32 alphanumerics mixing letters and digits (`index-Ab3dE9fG.js`, `chunk.3f9a1c2e.css`). Date-like tokens such as `report-20241014.pdf` are not hashed. ## Options -| Option | Default | Meaning | -| --- | --- | --- | -| `spa` | `false` | enable the SPA fallback | -| `api` | `["/api"]` | prefixes owned by the API | -| `index` | `"index.html"` | directory index and SPA shell | -| `cacheControl` | `"no-cache"` | for non-hashed, non-HTML assets | +| Option | Default | Meaning | +| -------------- | -------------- | ------------------------------- | +| `spa` | `false` | enable the SPA fallback | +| `api` | `["/api"]` | prefixes owned by the API | +| `index` | `"index.html"` | directory index and SPA shell | +| `cacheControl` | `"no-cache"` | for non-hashed, non-HTML assets | ## Compiling to an ELF The package is covered at 100% by `scriptc coverage packages/static/gate.ts`, and `scriptc build` produces a binary that serves images byte for byte (sha256 verified through curl). Two subset notes from authoring it: reading a missing key from a string-keyed record throws in a native binary, so presence is tested with `in` and keys are prefixed to avoid prototype names; and `httpDate()` is hand-rolled from the `getUTC*` getters until `toUTCString` lowers. Not included: range requests, `If-Modified-Since`, ISR caching. + +## Baking assets into the binary + +`EmbeddedAssets` holds every byte inside the program, so `scriptc build` emits one binary that needs no filesystem access. The recipe: after `vite build`, run a small script that walks `dist/` and emits a generated `EmbeddedAssets` subclass whose constructor calls `load(records)` — [`examples/integrations/csr-react`](https://github.com/sigilco/chttp/tree/main/examples/integrations/csr-react) ships a working one (`scripts/bake.mjs` → `src/assets.gen.ts`). + +Two ways to get the bytes into the binary, measured on the frontend probes (`bench/scriptc-evidence/frontend-probes-2026-10-14.txt`, 273 KB of assets, scriptc 0.3.1): + +| | JSON-baked (case 6) | FFI blob (case 9) | +| --------------- | ---------------------------------------------------- | --------------------------------------------------- | +| Mechanism | array of `{path,type,b64,…}` records in program code | static C arrays linked via `--ffi ffi.json` | +| Binary overhead | ~= base64 size (+365 KB) | ~= raw size + page alignment (+303 KB, ~9% smaller) | +| Startup load | ~0.15 ms base64 decode | ~0.055 ms buffer copy | +| Toolchain | none extra | `cc -c` + `ffi.json` manifest | + +JSON-baked is the default recipe: zero toolchain, and base64 decode cost is noise at any realistic bundle size. Reach for the FFI variant only when binary size is a hard constraint; it needs a C file, an object per target, and a `mutable-bytes` copy function (ffi format ≥ 6). diff --git a/examples/README.md b/examples/README.md index 0208eb1..80fdbc4 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,20 +2,20 @@ Standalone templates for common Hono/H3-style use cases. Each is a normal `npm i @chttp/router` project: the basic templates compile straight to a native ELF via `scriptc build` (chttp lowers directly like any scriptc-subset library, no bundler or plugin required); `vite-elf` shows the bundler pipeline with `unplugin-scriptc`, and `vite-lowering` the elect build-time route lowering with a plain JS dist. -| Template | Shows | -| --- | --- | -| `hello` | Static, param, and wildcard GET routes | -| `middleware` | `app.use` global and per-route middleware | -| `json-api` | POST body (`{ body: true }`), `onError`, `notFound` | -| `env` | Boot-time env validation with `@chttp/val` (coerce `PORT`, feature flags, fail with `summarize()`) | -| `router-mount` | `app.route` sub-router mounting | -| `ws-echo` | `@chttp/ws` echo and `:param` room, vendored into one source graph for `scriptc build` | -| `vite-elf` | Vite + `unplugin-scriptc` bundler pipeline | -| `vite-lowering` | Vite + `@chttp/unplugin` build-time route lowering, JS dist | +| Template | Shows | +| --------------- | --------------------------------------------------------------------------------------------------------------- | +| `hello` | Static, param, and wildcard GET routes | +| `middleware` | `app.use` global and per-route middleware | +| `json-api` | POST body (`{ body: true }`), `onError`, `notFound` | +| `env` | Boot-time env validation with `@chttp/val` (coerce `PORT`, feature flags, fail with `summarize()`) | +| `router-mount` | `app.route` sub-router mounting | +| `ws-echo` | `@chttp/ws` echo and `:param` room, vendored into one source graph for `scriptc build` | +| `vite-elf` | Vite + `unplugin-scriptc` bundler pipeline | +| `vite-lowering` | Vite + `@chttp/unplugin` build-time route lowering, JS dist | | `elf-roundtrip` | Native ELF server (`scriptc build`) + typed client (`@chttp/unplugin` `client: true`) sharing one routes module | -| `rate-limit` | Per-client fixed window limiter middleware: 429 + `Retry-After`, bounded memory | -| `backpressure` | In-flight cap (503 + `Retry-After`) and body size cap (413) as global middleware | -| `deploy-docker` | Multi-stage Dockerfile: scriptc build in alpine, `FROM scratch` runtime (~1.2 MB image) | +| `rate-limit` | Per-client fixed window limiter middleware: 429 + `Retry-After`, bounded memory | +| `backpressure` | In-flight cap (503 + `Retry-After`) and body size cap (413) as global middleware | +| `deploy-docker` | Multi-stage Dockerfile: scriptc build in alpine, `FROM scratch` runtime (~1.2 MB image) | ```sh cd examples/hello @@ -30,9 +30,10 @@ For build-time route lowering (compiled dispatch) with a plain JS dist, see `exa Recipes live under `examples/integrations/` and are workspace members (`workspace:*` pins), so `pnpm test` runs their tests. -| Recipe | Shows | -| --- | --- | -| `integrations/logging-file-audit` | Request and audit events to a file: subset-native `appendFileSync` sink for ELF, LogTape plus `@logtape/file` on node | -| `integrations/prometheus-metrics` | Request counter and latency histogram on `/metrics`: hand rolled exposition module for ELF, `prom-client` on node | -| `integrations/otlp-traces` | Server spans exported to an OTLP/HTTP JSON collector via `node:http`, with `traceparent` propagation, same source for ELF and node | -| `integrations/webhook-server` | Config driven webhooks: val validated config, HMAC verification, trigger rules, `execFileSync` argv and env, ELF and node | +| Recipe | Shows | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `integrations/logging-file-audit` | Request and audit events to a file: subset-native `appendFileSync` sink for ELF, LogTape plus `@logtape/file` on node | +| `integrations/prometheus-metrics` | Request counter and latency histogram on `/metrics`: hand rolled exposition module for ELF, `prom-client` on node | +| `integrations/otlp-traces` | Server spans exported to an OTLP/HTTP JSON collector via `node:http`, with `traceparent` propagation, same source for ELF and node | +| `integrations/webhook-server` | Config driven webhooks: val validated config, HMAC verification, trigger rules, `execFileSync` argv and env, ELF and node | +| `integrations/csr-react` | React SPA built by Vite served with `@chttp/static` (`spa: true`) plus an `/api` slice; `bake` script emits an `EmbeddedAssets` subclass for a single-binary build | diff --git a/examples/integrations/csr-react/.gitignore b/examples/integrations/csr-react/.gitignore new file mode 100644 index 0000000..4b17e09 --- /dev/null +++ b/examples/integrations/csr-react/.gitignore @@ -0,0 +1,5 @@ +.vendor/ +dist/ +app +src/assets.gen.ts +node_modules/ diff --git a/examples/integrations/csr-react/README.md b/examples/integrations/csr-react/README.md new file mode 100644 index 0000000..316d280 --- /dev/null +++ b/examples/integrations/csr-react/README.md @@ -0,0 +1,25 @@ +# csr-react + +Client-side rendered React SPA served by `@chttp/static`, with a `/api` +slice on the same router. Two ways to ship the assets: + +- **Disk** (`src/main.ts`): `DiskAssets("./dist")` re-reads files when + mtime or size changes — the dev loop. +- **Baked** (`src/main.baked.ts`): `pnpm bake` regenerates + `src/assets.gen.ts`, an `EmbeddedAssets` subclass with every dist file + as a base64 record. `pnpm build` then produces a single binary that + needs no filesystem. See + [Baking assets into the binary](https://github.com/sigilco/chttp/blob/main/docs/static.md#baking-assets-into-the-binary). + +```sh +pnpm install +pnpm build:web # vite build -> dist/ +pnpm start # node, serves dist/ on :3000 + +pnpm bake # regenerate src/assets.gen.ts +pnpm build # scriptc build src/main.baked.ts -o app +./app # single binary, no dist/ needed +``` + +SPA fallback: `GET /dashboard` with `Accept: text/html` serves +`index.html`; asset misses stay 404; `/api/*` never hits the filesystem. diff --git a/examples/integrations/csr-react/package.json b/examples/integrations/csr-react/package.json new file mode 100644 index 0000000..3bdeb3c --- /dev/null +++ b/examples/integrations/csr-react/package.json @@ -0,0 +1,26 @@ +{ + "name": "chttp-example-csr-react", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build:web": "vite build", + "bake": "node scripts/bake.mjs", + "vendor": "node scripts/vendor.mjs", + "build": "node scripts/bake.mjs && node scripts/vendor.mjs && scriptc build src/main.baked.ts -o app", + "start": "node --experimental-strip-types src/main.ts", + "start:elf": "./app", + "test": "node --experimental-strip-types --test test/csr.test.ts" + }, + "dependencies": { + "@chttp/router": "workspace:*", + "@chttp/static": "workspace:*", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "scriptc": ">=0.3.2", + "vite": "^7.0.0" + } +} diff --git a/examples/integrations/csr-react/scripts/bake.mjs b/examples/integrations/csr-react/scripts/bake.mjs new file mode 100644 index 0000000..65cb4ab --- /dev/null +++ b/examples/integrations/csr-react/scripts/bake.mjs @@ -0,0 +1,108 @@ +// Bake a built asset tree (vite dist/) into src/assets.gen.ts: an +// EmbeddedAssets subclass whose constructor loads an array of +// EmbedRecord records. This is the JSON-baked variant of the assets +// recipe — see docs/static.md "Baking assets into the binary" for the +// tradeoffs vs an FFI-linked blob. +// +// Usage: node scripts/bake.mjs [distDir] [outFile] +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; + +const distDir = process.argv[2] ?? "dist"; +const outFile = process.argv[3] ?? "src/assets.gen.ts"; + +const MIME = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".txt": "text/plain; charset=utf-8", + ".map": "application/json", +}; + +const sha1 = (b) => createHash("sha1").update(b).digest("hex"); + +// Content-hashed filename: same heuristic as @chttp/static's isHashedName — +// the last `-`/`.` separated stem token is 8-32 alphanumerics mixing +// letters and digits. +function isHashed(name) { + const stem = name.replace(/\.[^.]*$/, ""); + const tok = stem.split(/[-.]/).pop() ?? ""; + return ( + tok.length >= 8 && + tok.length <= 32 && + /^[A-Za-z0-9]+$/.test(tok) && + /[A-Za-z]/.test(tok) && + /[0-9]/.test(tok) + ); +} + +function* walk(dir) { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name); + if (e.isDirectory()) yield* walk(p); + else if (e.isFile()) yield p; + } +} + +// Collect real files, then attach .br/.gz siblings as encodings of the +// file they compress. +const files = [...walk(distDir)].filter((p) => !p.endsWith(".br") && !p.endsWith(".gz")); + +const records = []; +for (const file of files) { + const rel = relative(distDir, file).split("\\").join("/"); + const body = readFileSync(file); + const encs = []; + for (const enc of ["br", "gz"]) { + const sib = file + "." + enc; + try { + encs.push({ + enc, + b64: readFileSync(sib).toString("base64"), + etag: sha1(readFileSync(sib)), + }); + } catch { + /* no sibling */ + } + } + records.push({ + path: rel, + type: MIME[rel.slice(rel.lastIndexOf("."))] ?? "application/octet-stream", + b64: body.toString("base64"), + etag: sha1(body), + mtimeMs: Math.floor(statSync(file).mtimeMs), + immutable: isHashed(rel.split("/").pop() ?? ""), + encs, + }); +} + +const out = `// Generated by scripts/bake.mjs — do not edit. Re-run \`pnpm bake\` +// after every vite build. The records array is program data in the +// scriptc subset: an array of records, never a path-keyed object. +import { EmbeddedAssets } from "@chttp/static"; +import type { EmbedRecord } from "@chttp/static"; + +const RECORDS: EmbedRecord[] = ${JSON.stringify(records, null, 2)}; + +export class BakedAssets extends EmbeddedAssets { + constructor() { + super(); + this.load(RECORDS); + } +} +`; + +writeFileSync(outFile, out); +console.log(`baked ${records.length} assets -> ${outFile}`); diff --git a/examples/integrations/csr-react/scripts/vendor.mjs b/examples/integrations/csr-react/scripts/vendor.mjs new file mode 100644 index 0000000..59836f1 --- /dev/null +++ b/examples/integrations/csr-react/scripts/vendor.mjs @@ -0,0 +1,49 @@ +// scriptc lowers subclasses of package classes only when the whole graph +// is program code (see ws-echo/scripts/vendor.mjs, docs/websocket.md). +// BakedAssets extends EmbeddedAssets, so the router and static sources +// are copied into .vendor/ and the app files' "@chttp/*" imports are +// pointed at the vendored copies — `scriptc build src/main.baked.ts` +// then sees one source graph. +import { + cpSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, ".."); +const out = join(root, ".vendor"); + +function srcOf(name) { + const mono = resolve(root, "../../packages", name, "src"); + if (existsSync(mono)) return mono; + return join(root, "node_modules/@chttp", name, "src"); +} + +rmSync(out, { recursive: true, force: true }); +mkdirSync(join(out, "app"), { recursive: true }); +cpSync(srcOf("router"), join(out, "router"), { recursive: true }); +cpSync(srcOf("static"), join(out, "static"), { recursive: true }); + +for (const f of readdirSync(join(out, "static"))) { + if (!f.endsWith(".ts")) continue; + const p = join(out, "static", f); + writeFileSync(p, readFileSync(p, "utf8").replaceAll('"@chttp/router"', '"../router/index.ts"')); +} + +// The app modules keep their "@chttp/*" imports for the node path; the +// vendored copies get rewritten imports instead. +for (const f of ["app.ts", "assets.gen.ts"]) { + writeFileSync( + join(out, "app", f), + readFileSync(join(root, "src", f), "utf8") + .replaceAll('"@chttp/router"', '"../router/index.ts"') + .replaceAll('"@chttp/static"', '"../static/index.ts"'), + ); +} diff --git a/examples/integrations/csr-react/src/app.ts b/examples/integrations/csr-react/src/app.ts new file mode 100644 index 0000000..d3f59ef --- /dev/null +++ b/examples/integrations/csr-react/src/app.ts @@ -0,0 +1,14 @@ +import { createRouter } from "@chttp/router"; +import type { App } from "@chttp/router"; +import { serveStatic } from "@chttp/static"; +import type { AssetSource } from "@chttp/static"; + +// The app is identical for disk and baked sources — the AssetSource port +// is the only difference between "serve ./dist" and "serve bytes compiled +// into the binary". +export function buildApp(src: AssetSource): App { + const app = createRouter(); + app.get("/api/hello", (c) => c.json({ message: "hello from chttp" })); + serveStatic(app, "/", src, { spa: true }); + return app; +} diff --git a/examples/integrations/csr-react/src/main.baked.ts b/examples/integrations/csr-react/src/main.baked.ts new file mode 100644 index 0000000..310713f --- /dev/null +++ b/examples/integrations/csr-react/src/main.baked.ts @@ -0,0 +1,9 @@ +// Single-binary entry: assets were baked by `pnpm bake` into +// src/assets.gen.ts and `pnpm vendor` staged one program-code graph under +// .vendor/ (BakedAssets subclasses the package's EmbeddedAssets, which +// scriptc only lowers when the whole graph is program code). Build with +// `pnpm build` — bake, vendor, then `scriptc build`. +import { buildApp } from "../.vendor/app/app.ts"; +import { BakedAssets } from "../.vendor/app/assets.gen.ts"; + +buildApp(new BakedAssets()).serve({ port: 3000 }); diff --git a/examples/integrations/csr-react/src/main.ts b/examples/integrations/csr-react/src/main.ts new file mode 100644 index 0000000..906a432 --- /dev/null +++ b/examples/integrations/csr-react/src/main.ts @@ -0,0 +1,6 @@ +import { DiskAssets } from "@chttp/static"; +import { buildApp } from "./app.ts"; + +// Dev / plain-node entry: reads dist/ on every request (mtime + size +// revalidation), so `vite build` output is picked up without a restart. +buildApp(new DiskAssets("./dist")).serve({ port: 3000 }); diff --git a/examples/integrations/csr-react/test/csr.test.ts b/examples/integrations/csr-react/test/csr.test.ts new file mode 100644 index 0000000..d71c9cc --- /dev/null +++ b/examples/integrations/csr-react/test/csr.test.ts @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import http from "node:http"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; +import { build } from "vite"; +import { buildApp } from "../src/app.ts"; +import { DiskAssets } from "@chttp/static"; + +const sha256 = (b: Buffer) => createHash("sha256").update(b).digest("hex"); + +const DIST = new URL("../dist/", import.meta.url).pathname; + +// Build the React app once for the whole file — the assertions below are +// meaningless without real hashed assets on disk. +await build({ root: "web", build: { outDir: "../dist", emptyOutDir: true }, logLevel: "silent" }); +assert.ok(existsSync(join(DIST, "index.html")), "vite build produced dist/index.html"); + +function boot(src: import("@chttp/static").AssetSource) { + const app = buildApp(src); + return new Promise<{ s: http.Server; port: number }>((resolve) => { + const s = http.createServer(app.handler()); + s.listen(0, "127.0.0.1", () => { + const a = s.address(); + resolve({ s, port: typeof a === "object" && a !== null ? a.port : 0 }); + }); + }); +} + +function req(port: number, path: string, headers: Record = {}) { + return new Promise<{ status: number; headers: http.IncomingHttpHeaders; body: Buffer }>( + (resolve, reject) => { + const r = http.request( + { host: "127.0.0.1", port, path, headers: { accept: "*/*", ...headers } }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (d) => chunks.push(d)); + res.on("end", () => + resolve({ + status: res.statusCode ?? 0, + headers: res.headers, + body: Buffer.concat(chunks), + }), + ); + }, + ); + r.on("error", reject); + r.end(); + }, + ); +} + +const hashedAsset = readdirSync(join(DIST, "assets")).find((f) => f.endsWith(".js")); +assert.ok(hashedAsset, "vite emitted a hashed js asset"); + +const ctx = await boot(new DiskAssets(DIST)); +const port = ctx.port; +test.after(() => ctx.s.close()); + +test("GET / serves the SPA index", async () => { + const r = await req(port, "/"); + assert.equal(r.status, 200); + assert.match(r.headers["content-type"] ?? "", /text\/html/); + assert.match(r.body.toString(), /
/); +}); + +test("hashed asset is byte-identical to dist and cached immutable", async () => { + const fileBytes = readFileSync(join(DIST, "assets", hashedAsset)); + const r = await req(port, "/assets/" + hashedAsset); + assert.equal(r.status, 200); + assert.equal(sha256(r.body), sha256(fileBytes)); + assert.match(r.headers["cache-control"] ?? "", /immutable/); +}); + +test("ETag round trip answers 304 with no body", async () => { + const a = await req(port, "/assets/" + hashedAsset); + const tag = a.headers.etag; + assert.ok(tag); + const b = await req(port, "/assets/" + hashedAsset, { "if-none-match": tag }); + assert.equal(b.status, 304); + assert.equal(b.body.length, 0); + assert.equal(b.headers.etag, tag); +}); + +test("extensionless navigation gets the SPA fallback, asset misses stay 404", async () => { + const nav = await req(port, "/dashboard/settings", { accept: "text/html" }); + assert.equal(nav.status, 200); + assert.match(nav.body.toString(), /
/); + const miss = await req(port, "/nope.js"); + assert.equal(miss.status, 404); +}); + +test("/api prefix is owned by the router, misses answer JSON 404", async () => { + const ok = await req(port, "/api/hello"); + assert.equal(ok.status, 200); + assert.deepEqual(JSON.parse(ok.body.toString()), { message: "hello from chttp" }); + const miss = await req(port, "/api/nope"); + assert.equal(miss.status, 404); + assert.equal(JSON.parse(miss.body.toString()).error, "not_found"); +}); + +test("baked assets serve the same bytes and validators", async () => { + const bake = spawnSync(process.execPath, ["scripts/bake.mjs"], { stdio: "pipe" }); + assert.equal(bake.status, 0, bake.stderr.toString()); + const { BakedAssets } = await import("../src/assets.gen.ts"); + const b = await boot(new BakedAssets()); + try { + const r = await req(b.port, "/assets/" + hashedAsset); + assert.equal(r.status, 200); + assert.equal(sha256(r.body), sha256(readFileSync(join(DIST, "assets", hashedAsset)))); + const again = await req(b.port, "/assets/" + hashedAsset, { + "if-none-match": r.headers.etag as string, + }); + assert.equal(again.status, 304); + const index = await req(b.port, "/", { accept: "text/html" }); + assert.match(index.body.toString(), /
/); + } finally { + b.s.close(); + } +}); diff --git a/examples/integrations/csr-react/vite.config.mjs b/examples/integrations/csr-react/vite.config.mjs new file mode 100644 index 0000000..549927e --- /dev/null +++ b/examples/integrations/csr-react/vite.config.mjs @@ -0,0 +1,9 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + root: "web", + build: { + outDir: "../dist", + emptyOutDir: true, + }, +}); diff --git a/examples/integrations/csr-react/web/index.html b/examples/integrations/csr-react/web/index.html new file mode 100644 index 0000000..58249ac --- /dev/null +++ b/examples/integrations/csr-react/web/index.html @@ -0,0 +1,12 @@ + + + + + + chttp + React + + +
+ + + diff --git a/examples/integrations/csr-react/web/src/App.jsx b/examples/integrations/csr-react/web/src/App.jsx new file mode 100644 index 0000000..0a91f8d --- /dev/null +++ b/examples/integrations/csr-react/web/src/App.jsx @@ -0,0 +1,21 @@ +import { useEffect, useState } from "react"; + +export function App() { + const [message, setMessage] = useState("loading…"); + + useEffect(() => { + fetch("/api/hello") + .then((r) => r.json()) + .then((d) => setMessage(d.message)) + .catch(() => setMessage("api unreachable")); + }, []); + + return ( +
+

chttp + React

+

+ Server says: {message} +

+
+ ); +} diff --git a/examples/integrations/csr-react/web/src/main.jsx b/examples/integrations/csr-react/web/src/main.jsx new file mode 100644 index 0000000..aa7667e --- /dev/null +++ b/examples/integrations/csr-react/web/src/main.jsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App.jsx"; +import "./style.css"; + +createRoot(document.getElementById("root")).render( + + + , +); diff --git a/examples/integrations/csr-react/web/src/style.css b/examples/integrations/csr-react/web/src/style.css new file mode 100644 index 0000000..220c0ec --- /dev/null +++ b/examples/integrations/csr-react/web/src/style.css @@ -0,0 +1,16 @@ +:root { + color-scheme: light dark; + font-family: system-ui, sans-serif; +} + +body { + margin: 0; + display: grid; + place-items: center; + min-height: 100vh; +} + +main { + max-width: 40rem; + padding: 2rem; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f572cc..a5efd1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -267,6 +267,31 @@ importers: specifier: ^4.0.0 version: 4.151.0(@cloudflare/workers-types@4.20260702.1)(@types/node@24.19.2) + examples/integrations/csr-react: + dependencies: + '@chttp/router': + specifier: workspace:* + version: link:../../../packages/router + '@chttp/static': + specifier: workspace:* + version: link:../../../packages/static + react: + specifier: ^19.0.0 + version: 19.3.1 + react-dom: + specifier: ^19.0.0 + version: 19.3.1(react@19.3.1) + devDependencies: + '@types/node': + specifier: ^24.0.0 + version: 24.19.2 + scriptc: + specifier: '>=0.3.2' + version: 0.3.6 + vite: + specifier: ^7.0.0 + version: 7.3.7(@types/node@24.19.2)(yaml@2.9.1) + examples/integrations/hono-middleware: dependencies: '@chttp/router': @@ -2217,6 +2242,15 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + react-dom@19.3.1: + resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==} + peerDependencies: + react: ^19.3.1 + + react@19.3.1: + resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==} + engines: {node: '>=0.10.0'} + real-require@1.0.0: resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} @@ -2265,6 +2299,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + scriptc@0.3.1: resolution: {integrity: sha512-5iROCIAsGUU8TRb9XunxcydOJehl6GNaZAlVvN+nlG2tKVPHCMUNDWTLNTPgvltTkEH/iKRfZlvMB5IBjG2hKQ==} engines: {node: '>=24'} @@ -3941,6 +3978,13 @@ snapshots: iconv-lite: 0.7.3 unpipe: 1.0.0 + react-dom@19.3.1(react@19.3.1): + dependencies: + react: 19.3.1 + scheduler: 0.28.0 + + react@19.3.1: {} + real-require@1.0.0: {} reinterval@1.1.0: {} @@ -4007,6 +4051,8 @@ snapshots: safer-buffer@2.1.2: {} + scheduler@0.28.0: {} + scriptc@0.3.1: optionalDependencies: '@scriptc/cli-darwin-arm64': 0.3.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5e03f75..046ad0f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -23,6 +23,7 @@ versioning: - chttp-example-backpressure - chttp-example-aws-lambda - chttp-example-cloudflare-workers + - chttp-example-csr-react - chttp-example-hono-middleware - chttp-example-mcp-stdio - chttp-example-logging-file-audit From bbb9124a03ba301225dbefcbd71ca12c336e978d Mon Sep 17 00:00:00 2001 From: Quino Terrasa <26749475+espetro@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:50:15 +0000 Subject: [PATCH 3/4] chore(changeset): header() missing-key guard --- .changeset/header-missing-key-guard.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/header-missing-key-guard.md diff --git a/.changeset/header-missing-key-guard.md b/.changeset/header-missing-key-guard.md new file mode 100644 index 0000000..663b2bf --- /dev/null +++ b/.changeset/header-missing-key-guard.md @@ -0,0 +1,5 @@ +--- +"@chttp/router": patch +--- + +fix(req): `c.req.header(name)` no longer crashes on absent headers in single-graph (vendored/program-code) scriptc builds — `_headers`/`_headersDistinct` are probed with `hasOwnProperty` before indexing instead of a bare record read that throws "record has no key". From 0aa9aae9cf28440b42d321d1d4768e090ac60b0f Mon Sep 17 00:00:00 2001 From: Quino Terrasa <26749475+espetro@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:55:33 +0000 Subject: [PATCH 4/4] fix(examples): run vite build inside csr-react's build script CI's pnpm -r build invokes the package's build directly; bake needs dist/ to exist. --- examples/integrations/csr-react/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/integrations/csr-react/package.json b/examples/integrations/csr-react/package.json index 3bdeb3c..d79117a 100644 --- a/examples/integrations/csr-react/package.json +++ b/examples/integrations/csr-react/package.json @@ -7,7 +7,7 @@ "build:web": "vite build", "bake": "node scripts/bake.mjs", "vendor": "node scripts/vendor.mjs", - "build": "node scripts/bake.mjs && node scripts/vendor.mjs && scriptc build src/main.baked.ts -o app", + "build": "vite build && node scripts/bake.mjs && node scripts/vendor.mjs && scriptc build src/main.baked.ts -o app", "start": "node --experimental-strip-types src/main.ts", "start:elf": "./app", "test": "node --experimental-strip-types --test test/csr.test.ts"