/);
+});
+
+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/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]);
}
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