@rangojs/router 0.0.0-experimental.142 → 0.0.0-experimental.143

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2393,7 +2393,7 @@ import { resolve } from "node:path";
2393
2393
  // package.json
2394
2394
  var package_default = {
2395
2395
  name: "@rangojs/router",
2396
- version: "0.0.0-experimental.142",
2396
+ version: "0.0.0-experimental.143",
2397
2397
  description: "Django-inspired RSC router with composable URL patterns",
2398
2398
  keywords: [
2399
2399
  "react",
@@ -2583,6 +2583,8 @@ var package_default = {
2583
2583
  },
2584
2584
  devDependencies: {
2585
2585
  "@opentelemetry/api": "^1.9.0",
2586
+ "@opentelemetry/context-async-hooks": "^2.9.0",
2587
+ "@opentelemetry/sdk-trace-base": "^2.9.0",
2586
2588
  "@playwright/test": "^1.49.1",
2587
2589
  "@shared/e2e": "workspace:*",
2588
2590
  "@testing-library/dom": "^10.4.1",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.142",
3
+ "version": "0.0.0-experimental.143",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -190,6 +190,8 @@
190
190
  },
191
191
  "devDependencies": {
192
192
  "@opentelemetry/api": "^1.9.0",
193
+ "@opentelemetry/context-async-hooks": "^2.9.0",
194
+ "@opentelemetry/sdk-trace-base": "^2.9.0",
193
195
  "@playwright/test": "^1.49.1",
194
196
  "@shared/e2e": "workspace:*",
195
197
  "@testing-library/dom": "^10.4.1",
@@ -91,6 +91,24 @@ cache(
91
91
  );
92
92
  ```
93
93
 
94
+ ## When cache() does not pay
95
+
96
+ A cache hit is not free: it still runs middleware, the store read, and the
97
+ document render AROUND the cached segment. The win is proportional to what
98
+ the cached render itself costs — measured on a deployed Cloudflare worker
99
+ (2026-07), a trivial page inside `cache()` served hits at p50 36 ms while
100
+ misses (render + store) served at 35 ms: indistinguishable. The same
101
+ boundary around an expensive render (slow data, big trees) is where the TTL
102
+ pays for itself.
103
+
104
+ Rule of thumb: reach for `cache()` when the segment's own render cost is
105
+ meaningfully above your latency floor — expensive render-embedded data work,
106
+ large component trees, third-party calls captured in the render. Do not wrap cheap
107
+ pages "just in case": you add store traffic and invalidation surface for no
108
+ latency win. If the data is what's expensive and it changes per-request,
109
+ prefer a loader with `cache()` on the loader DATA (see "Loader-Level
110
+ Caching") over caching the rendered segment.
111
+
94
112
  ## Tag-Based Invalidation
95
113
 
96
114
  Tag cached entries, then invalidate them on demand. Tags can be attached three ways:
@@ -210,6 +210,38 @@ in the group — including nested `include()`s inside the split module. Only the
210
210
  module's runtime evaluation defers. `rango generate` resolves the `() => import()`
211
211
  the same way, so a code-split group is still fully typed.
212
212
 
213
+ ### Sizing async include groups (measured)
214
+
215
+ The first request into an async group pays that group's chunk import; every
216
+ request after that is flat. Measured on a deployed Cloudflare worker with
217
+ 26k routes (2026-07, warm RTT floor ~23 ms):
218
+
219
+ | Group size | First-hit latency |
220
+ | -------------------------- | ------------------------------------ |
221
+ | ~240 routes | ~75 ms (≈ RTT + eval) |
222
+ | ~5,000 routes | ~137 ms |
223
+ | ~9,000 routes | ~188 ms |
224
+ | 3-level nested async chain | ~464 ms (levels import sequentially) |
225
+
226
+ Three rules fall out of those numbers:
227
+
228
+ 1. **Prefer more, smaller groups over few giant ones.** First-hit cost scales
229
+ with routes-per-chunk; fifty 250-route groups each cost a fraction of one
230
+ 9k-route group, and only the group actually visited pays anything.
231
+ 2. **Keep async-include chains shallow on latency-sensitive paths.** Each
232
+ nested `() => import()` level awaits in sequence, so depth multiplies the
233
+ first hit. Nesting eager includes inside one async module costs one chunk;
234
+ nesting async inside async costs one chunk per level.
235
+ 3. **Give sibling groups distinct static prefixes.** Siblings that share a
236
+ static prefix (`include("/x/:a", …)` next to `include("/x/:b", …)`) all
237
+ import on the first hit to that prefix — the router cannot tell which one
238
+ matches before loading them.
239
+
240
+ Warm-path matching is O(path segments) via the precomputed trie regardless of
241
+ group layout — this sizing only shapes cold/first-hit behavior. For
242
+ latency-critical prefixes, a post-deploy warmup ping (one request per prefix)
243
+ erases first-hit cost for the isolate entirely.
244
+
213
245
  ## Composition Types
214
246
 
215
247
  For typed factories, import the composition types:
@@ -58,6 +58,14 @@ Read the timeline as intervals:
58
58
  - Cache, route matching, middleware pre/post, RSC serialization, and SSR phases
59
59
  appear as separate spans, so the slow phase is visible without guessing.
60
60
 
61
+ **Deployed Cloudflare caveat**: on production Workers, timers are frozen
62
+ during request execution (Spectre mitigation), so `Server-Timing` durations
63
+ read as ~0 on the deployed edge — they only advance across genuine awaited
64
+ I/O. The waterfall is a LOCAL diagnostic (dev, `vite preview`,
65
+ `wrangler dev`); for deployed workers, measure from the client
66
+ (`PerformanceResourceTiming`, TTFB) and use structured telemetry below for
67
+ server-side events.
68
+
61
69
  ## Structured telemetry
62
70
 
63
71
  Use telemetry when you want durable production events rather than a one-request
@@ -271,6 +271,8 @@ parallel(
271
271
 
272
272
  Per-slot merge order is **handler.use → shared use → slot-local use**. Slot-local is the narrowest scope, so it wins for last-write-wins items. See [skills/handler-use § `loading()` is a single-assignment item — scope it correctly](../handler-use/SKILL.md#loading-is-a-single-assignment-item--scope-it-correctly) for the full reasoning.
273
273
 
274
+ Typing note: a BARE arrow slot handler infers its ctx (`"@cart": (ctx) => ...`), but an arrow inside a DESCRIPTOR needs an explicit annotation — `handler: (ctx: HandlerContext) => ...` — because `StaticHandlerDefinition` in the slot union contributes a second callable to the contextual type and TS declines to pick a signature.
275
+
274
276
  ## Slot Override Semantics
275
277
 
276
278
  When multiple `parallel()` calls define the same slot name, **the last
@@ -169,6 +169,31 @@ On a document GET to a ppr route the router runs:
169
169
  point is after the chain, an unauthorized request NEVER sees shell bytes — put
170
170
  auth middleware anywhere (global or route DSL) and it guards PPR for free.
171
171
 
172
+ ## Verifying it works
173
+
174
+ The header exists on DOCUMENT responses only. A bare `curl` (no `Accept`)
175
+ content-negotiates a Flight payload (`text/x-component`) with NO
176
+ `x-rango-shell` header at all — which reads as "PPR is off" but is only the
177
+ wrong request shape:
178
+
179
+ ```
180
+ curl -s -D - -o /dev/null -H "Accept: text/html" https://app.example.com/products/1 | grep -i x-rango-shell
181
+ ```
182
+
183
+ - First document GET: `MISS`, plus a background capture.
184
+ - Production (workerd/node): the SECOND request is a `HIT`.
185
+ - Dev: expect a few extra MISSes — cold module transforms abort the capture
186
+ window (per-attempt breadcrumbs: start the server with
187
+ `INTERNAL_RANGO_DEBUG=1`). This self-heals; only a route that NEVER flips
188
+ has a real hole/eligibility problem (the once-per-key warning tells the two
189
+ apart).
190
+ - A HIT is one ordinary document: the frozen prelude first (view-source shows
191
+ your baked shell, with hole fallbacks in place), then
192
+ `<div hidden id="S:0">…` segments as the holes resume, per request.
193
+ - A ppr-declared route that CANNOT be honored (missing shell store family,
194
+ per-request nonce) serves plain axis 1 with NO header and warns once per
195
+ key — no header + a declared `ppr` means look for that warning.
196
+
172
197
  ## The hole doctrine (encode this in your head)
173
198
 
174
199
  Holes are **render-defined**, decided by the shape of the tree, on three rules:
@@ -230,7 +255,14 @@ The physics caveat in one line: promise holes are holes because the I/O is
230
255
  genuinely pending at capture. If the value can resolve near-instantly (memory
231
256
  read, warmed cache), it may bake into the shell — when liveness must be
232
257
  guaranteed rather than probable, use the live lane (`loading()`). The same
233
- physics governs bake-lane nested promises.
258
+ physics governs bake-lane nested promises, with one shape guarantee: a nested
259
+ promise that settles inside the window pins its VALUE, but the container key
260
+ KEEPS its promise shape on HITs (the snapshot rehydrates a
261
+ `Promise.resolve(pinned)`), so an unconditional `use(data.x)` consumer never
262
+ breaks — it just reads the pinned value. Note the timing consequence: whether
263
+ such a value is pinned or live can vary per capture (concurrent loader traffic
264
+ extends the quiet window), so treat "fast-resolving promise on the bake lane"
265
+ as PINNED for correctness purposes.
234
266
 
235
267
  ### Handles: "nesting = liveness"
236
268
 
@@ -278,7 +310,10 @@ Three hard edges (each e2e/unit-pinned):
278
310
  throws during capture and the capture REFUSES (deterministic, once-per-key
279
311
  warned) — identity can never bake into the shared shell. Give that loader's
280
312
  entry `loading()` (the live lane is exempt) or move the identity-dependent
281
- part into a nested promise.
313
+ part into a nested promise. The guard's scope is EXACTLY those two calls:
314
+ per-user state read from a middleware-provided object (`ctx.get("session")`)
315
+ does NOT refuse — it bakes silently as the capturing user's data (see
316
+ Pitfalls: the session-object bake trap).
282
317
  - **A rejecting bake-lane loader refuses.** Error UI never bakes.
283
318
  - **Baked containers show CAPTURE-time data** for the shell's lifetime on
284
319
  document GETs (client navigations stay fresh — axis 1). That IS the bake
@@ -316,10 +351,16 @@ stay live. Your levers, in order of preference:
316
351
  loader(BasketLoader),
317
352
  loading(<BadgeSkeleton />), // hole the size of a badge, not a page
318
353
  ]),
319
- parallel({ "@wishlist": WishlistBadge }, () => [
320
- loader(WishlistLoader),
321
- loading(<BadgeSkeleton />),
322
- ]),
354
+ // Descriptor form when the slot handler needs ctx (annotate it
355
+ // StaticHandlerDefinition in the union blocks inference there):
356
+ parallel({
357
+ "@wishlist": {
358
+ handler: (ctx: HandlerContext) => (
359
+ <WishlistBadge listUrl={ctx.reverse("wishlist")} />
360
+ ),
361
+ use: () => [loader(WishlistLoader), loading(<BadgeSkeleton />)],
362
+ },
363
+ }),
323
364
  path("/", HomePage, { name: "home", ppr: true }),
324
365
  ]),
325
366
  ```
@@ -387,9 +428,14 @@ PPR-ineligible by construction; the live lane (`loading()`) stays exempt.
387
428
  **(c) Residual hazard — middleware-derived per-user state.** A `ctx` variable
388
429
  set by an upstream auth middleware and rendered by shell material is
389
430
  photographed into the SHARED shell (the capture inherits post-middleware
390
- state). That is scope fidelity working as designed — for shared values. If the
391
- value is per-user: shell-cache only public/shared pages, put per-user content
392
- in loaders, or key per variant at the CDN tier.
431
+ state). That is scope fidelity working as designed — for shared values. The
432
+ same hazard reaches BAKE-LANE LOADERS: a loader reading a middleware-provided
433
+ session object (`ctx.get("session")`) never calls `cookies()` itself, so the
434
+ guard cannot see it — whatever it returns as settled container data is
435
+ photographed as the CAPTURING user's state. If the
436
+ value is per-user: shell-cache only public/shared pages, keep per-user content
437
+ in nested pending promises or live-lane (`loading()`) loaders — NOT in a
438
+ bake-lane container — or key per variant at the CDN tier.
393
439
 
394
440
  ## What always stays on axis 1
395
441
 
@@ -483,7 +529,27 @@ know (a tenant id, a deploy marker).
483
529
  - **Per-user value in shell material**: baked into the shared shell —
484
530
  deterministically, not by race (handler promises deep-settle at the ring-3
485
531
  write on cached chains; awaited/resolved values bake everywhere). Put
486
- per-user data in a loader.
532
+ per-user data in a nested pending promise or a live-lane (`loading()`)
533
+ loader — a BAKE-lane loader container bakes just like handler material.
534
+ - **The session-object bake trap (the guard cannot save you here)**: the
535
+ capture guard sees `cookies()`/`headers()` calls ONLY. A bake-lane loader
536
+ reading a middleware-provided session object (`ctx.get("session")`) refuses
537
+ nothing — and its FAST-RESOLVE branch is the killer:
538
+
539
+ ```typescript
540
+ const CartLoader = createLoader(async (ctx) => {
541
+ const basketId = ctx.get("session")!.get("basketId");
542
+ if (!basketId) return { cart: Promise.resolve(null) }; // SETTLED → BAKES
543
+ return { cart: fetchBasket(basketId) }; // pending → hole
544
+ });
545
+ ```
546
+
547
+ If the capturing request is anonymous (it usually is), `cart: null` bakes
548
+ and is snapshot-pinned: every logged-in user gets the anonymous badge on
549
+ every HIT. The branch asymmetry makes it nondeterministic per capture. Any
550
+ loader whose data is per-user belongs on the live lane — for a header
551
+ widget, a parallel slot with its own `loading()` (playbook lever 3).
552
+
487
553
  - **Theme on a HIT is capture-then-corrected**: the resume tree replays the
488
554
  CAPTURE's `initialTheme` (resume requires it to match the frozen prelude);
489
555
  the visitor's cookie theme is applied pre-paint by the FOUC script and
@@ -479,6 +479,14 @@ urls(({ path, layout }) => [
479
479
  ])
480
480
  ```
481
481
 
482
+ For composing whole route MODULES, reach for `include()` — and prefer the
483
+ code-split form `include("/shop", () => import("./shop-patterns"))` for any
484
+ group that is a natural unit: it keeps the group off the cold-start path, and
485
+ measured first-hit cost scales with routes-per-chunk, so many small groups
486
+ beat one giant one. Sizing rules and the numbers behind them:
487
+ [skills/composability](../composability/SKILL.md) → "Sizing async include
488
+ groups (measured)".
489
+
482
490
  ## View Transitions
483
491
 
484
492
  A route can configure its own `transition()` — the wrap goes around the route's component itself (routes are leaves; they have no separate default outlet channel). If the route component renders a `<ParallelOutlet />` directly, that slot remains inside the route's VT subtree, so prefer mounting parallel slots in a layout when combining intercept modals with route-level transitions. See [skills/view-transitions](../view-transitions/SKILL.md) for examples and the wrap-location rules across layouts, routes, and slots.
@@ -25,6 +25,7 @@ below. Read the one for your case.
25
25
  | Typed `search` schemas, `RouteSearchParams`/`RouteParams`, loader return types | Search params & loader typing | [`./params-and-search.md`](./params-and-search.md) |
26
26
  | Typed `env`/bindings, `Rango.Vars`, `createVar()`, handle typing, loader/handle ref props, location state typing | Environment, context, and state typing | [`./env-and-bindings.md`](./env-and-bindings.md) |
27
27
  | Multi-app / multi-router tsconfig setup, avoiding `GeneratedRouteMap` collisions | Multi-project setup & full walkthrough | [`./generated-files-and-cli.md`](./generated-files-and-cli.md) |
28
+ | Slow typecheck with many `include()` modules (instantiation blowup), wide `UrlPatterns<any>` annotations | Typecheck cost at route scale | [`./generated-files-and-cli.md`](./generated-files-and-cli.md) |
28
29
 
29
30
  ## Companion files
30
31
 
@@ -120,6 +120,36 @@ Do not document or use a public `router.routeNames` API unless one is
120
120
  intentionally added. Today, the public extraction surface is `router.routeMap`;
121
121
  the generated file and `$$routeNames` are build machinery.
122
122
 
123
+ ### Typecheck cost when composing many include modules
124
+
125
+ `urls()` infers a route registry from everything in its array — including the
126
+ module types behind every `include()` thunk, recursively. In an app composing
127
+ MANY include modules (dozens of groups, or factory-produced groups), that
128
+ inference chain can explode: measured on a 26k-route app with 50 nested
129
+ include modules, root inference hit 4.05M type instantiations / 20 s check
130
+ time; the same app checks at ~140k / 3.6 s after widening.
131
+
132
+ The fix is to annotate the intermediate modules' exports with the wide
133
+ `UrlPatterns` type, which stops per-route literal types from propagating
134
+ upward:
135
+
136
+ ```typescript
137
+ import { urls, type UrlPatterns } from "@rangojs/router";
138
+
139
+ export const shopPatterns: UrlPatterns<any> = urls(({ path }) => [
140
+ // ...hundreds of routes
141
+ ]);
142
+ export default shopPatterns;
143
+ ```
144
+
145
+ Nothing is lost: named-route typing (`Handler<"name">`, `ctx.reverse`,
146
+ `href`) comes from the generated `router.named-routes.gen.ts`, not from the
147
+ inferred `urls()` type. Keep full inference on modules whose
148
+ `Rango.PathResponse` payloads you assert (e.g. `path.json` response routes);
149
+ widen the big mechanical groups. Use `UrlPatterns<any>` (not
150
+ `UrlPatterns<unknown>` — `unknown` env breaks handler assignability).
151
+ Diagnose with `tsc --extendedDiagnostics` and watch the Instantiations count.
152
+
123
153
  ## Multi-Project tsconfig Setup
124
154
 
125
155
  For monorepos or multi-app setups, each app should have its own TypeScript
@@ -39,7 +39,7 @@ import { _getRequestContext } from "../server/request-context.js";
39
39
  import {
40
40
  type RouterTracingConfig,
41
41
  type SpanRunner,
42
- type TracePhaseToggles,
42
+ type TracingToggleOptions,
43
43
  NOOP_TRACE_SPAN,
44
44
  } from "../router/tracing.js";
45
45
 
@@ -57,13 +57,12 @@ interface CloudflareTracing {
57
57
  enterSpan<T>(name: string, callback: (span: CloudflareSpan) => T): T;
58
58
  }
59
59
 
60
- /** Options for createCloudflareTracing. */
61
- export interface CloudflareTracingOptions {
62
- /** Master switch. Defaults to true. */
63
- enabled?: boolean;
64
- /** Per-phase span toggles. Omitted phases default to enabled. */
65
- spans?: TracePhaseToggles;
66
- }
60
+ /**
61
+ * Options for createCloudflareTracing. Alias of the shared
62
+ * {@link TracingToggleOptions} (`enabled` master switch + per-phase `spans`
63
+ * toggles); the name is public API.
64
+ */
65
+ export type CloudflareTracingOptions = TracingToggleOptions;
67
66
 
68
67
  /**
69
68
  * Resolve the per-request Cloudflare tracer from the active execution context.
package/src/index.rsc.ts CHANGED
@@ -308,6 +308,7 @@ export type {
308
308
  RouterTracingConfig,
309
309
  TracePhase,
310
310
  TracePhaseToggles,
311
+ TracingToggleOptions,
311
312
  } from "./router/tracing.js";
312
313
 
313
314
  // Timeout types and error class
package/src/index.ts CHANGED
@@ -343,14 +343,17 @@ export {
343
343
  // Path and response types are ambient on the `Rango` namespace (`Rango.Path`,
344
344
  // `Rango.PathResponse`, declared in href-client.ts) — no import needed.
345
345
 
346
- // Telemetry types only — the createConsoleSink/createOTelSink values are
347
- // server-only and live in index.rsc.ts (the `react-server` condition of the
348
- // bare `@rangojs/router` import). Re-exporting them as values from this
349
- // (default/client) entry would pull telemetry.ts and telemetry-otel.ts into
350
- // the client module graph; both tree-shake to zero bytes but still appear in
351
- // bundle analysis output and slow build-time module resolution. Consumers
352
- // who need the values in non-RSC contexts can import from
353
- // `@rangojs/router/server`.
346
+ // Telemetry types only — the createConsoleSink / createOTelSink /
347
+ // createOTelTracing VALUES are server-only and live in index.rsc.ts (the
348
+ // `react-server` condition of the bare `@rangojs/router` import). Re-exporting
349
+ // them as values from this (default/client) entry would pull telemetry.ts and
350
+ // telemetry-otel.ts into the client module graph; both tree-shake to zero bytes
351
+ // but still appear in bundle analysis output and slow build-time module
352
+ // resolution. The factory values are NOT re-exported from `@rangojs/router/server`
353
+ // either — that subpath is internal, not user-facing (see server.ts header).
354
+ // Non-RSC server code imports these TYPES from the root and obtains the factory
355
+ // VALUES from its own router definition module, which resolves to index.rsc.ts
356
+ // under the `react-server` condition.
354
357
  export type {
355
358
  OTelTracer,
356
359
  OTelActiveSpanTracer,
@@ -386,6 +389,7 @@ export type {
386
389
  RouterTracingConfig,
387
390
  TracePhase,
388
391
  TracePhaseToggles,
392
+ TracingToggleOptions,
389
393
  } from "./router/tracing.js";
390
394
 
391
395
  // Timeout types and error class
@@ -149,8 +149,11 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
149
149
  * so they take precedence on `loading()` and other last-write-wins
150
150
  * fields.
151
151
  */
152
- parallel: <
153
- TSlots extends Record<
152
+ // Not generic over the slots record: an inferred type parameter makes the
153
+ // object literal an inference site, which suppresses contextual typing of
154
+ // arrow slot handlers (`(ctx) => ...` was implicit any).
155
+ parallel: (
156
+ slots: Record<
154
157
  `@${string}`,
155
158
  | Handler<any, any, TEnv>
156
159
  | ReactNode
@@ -159,8 +162,6 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
159
162
  use?: () => UseItems<ParallelUseItem>;
160
163
  }
161
164
  >,
162
- >(
163
- slots: TSlots,
164
165
  use?: () => UseItems<ParallelUseItem>,
165
166
  ) => ParallelItem;
166
167
  /**
@@ -139,6 +139,7 @@ export function clearAllRouterData(): void {
139
139
  perRouterManifestMap.clear();
140
140
  perRouterTrieMap.clear();
141
141
  perRouterPrecomputedEntriesMap.clear();
142
+ authoritativeTrieRouters.clear();
142
143
  }
143
144
 
144
145
  export function setRouterManifest(
@@ -162,6 +163,23 @@ export function setRouterTrie(
162
163
  perRouterTrieMap.set(routerId, trie);
163
164
  }
164
165
 
166
+ // Routers whose trie came from the COMPLETE build manifest (deserialized via
167
+ // ensureRouterManifest). For these, a trie miss is a real 404 and findMatch
168
+ // skips the regex fallback scan — the only remaining route-count-proportional
169
+ // match path (#664). Dev rebuilds (manifest-init.ts, router-discovery HMR
170
+ // pushes) deliberately never mark authoritative: the dev-only trie-gap warning
171
+ // in find-match.ts depends on the fallback running on misses, and dev route
172
+ // churn (HMR, dev-time routes) makes a stale-trie 404 unacceptable there.
173
+ const authoritativeTrieRouters: Set<string> = new Set();
174
+
175
+ export function markRouterTrieAuthoritative(routerId: string): void {
176
+ authoritativeTrieRouters.add(routerId);
177
+ }
178
+
179
+ export function isRouterTrieAuthoritative(routerId: string): boolean {
180
+ return authoritativeTrieRouters.has(routerId);
181
+ }
182
+
165
183
  export function getRouterTrie(
166
184
  routerId: string,
167
185
  ): import("./build/route-trie.js").TrieNode | undefined {
@@ -204,7 +222,12 @@ export async function ensureRouterManifest(routerId: string): Promise<void> {
204
222
  if (loader) {
205
223
  const mod = await loader();
206
224
  if (mod.manifest) perRouterManifestMap.set(routerId, mod.manifest);
207
- if (mod.trie) perRouterTrieMap.set(routerId, mod.trie);
225
+ if (mod.trie) {
226
+ perRouterTrieMap.set(routerId, mod.trie);
227
+ // A trie serialized into the build manifest comes from complete
228
+ // discovery — misses are authoritative 404s (see find-match.ts).
229
+ markRouterTrieAuthoritative(routerId);
230
+ }
208
231
  if (mod.precomputedEntries)
209
232
  perRouterPrecomputedEntriesMap.set(routerId, mod.precomputedEntries);
210
233
  routerManifestLoaders.delete(routerId);
@@ -1,5 +1,8 @@
1
1
  import { tryTrieMatch } from "./trie-matching.js";
2
- import { getRouterTrie } from "../route-map-builder.js";
2
+ import {
3
+ getRouterTrie,
4
+ isRouterTrieAuthoritative,
5
+ } from "../route-map-builder.js";
3
6
  import {
4
7
  findMatch as findRouteMatch,
5
8
  isLazyEvaluationNeeded,
@@ -166,6 +169,17 @@ export function createFindMatch<TEnv = any>(
166
169
  };
167
170
  return cloneMatchResult(lastFindMatchResult);
168
171
  }
172
+ } else if (isRouterTrieAuthoritative(deps.routerId)) {
173
+ // Authoritative miss (#664): this trie was deserialized from the
174
+ // COMPLETE build manifest, so trailing-slash redirects are already
175
+ // trie-native hits and a miss means no route exists. Skip the regex
176
+ // fallback — the only route-count-proportional match path — and do
177
+ // not evaluate lazy includes for unmatched (bot-probe) traffic.
178
+ // Trie hits that need lazy splicing keep the fallback loop below
179
+ // (trieMatched === true never reaches this branch).
180
+ lastFindMatchPathname = pathname;
181
+ lastFindMatchResult = null;
182
+ return null;
169
183
  }
170
184
  }
171
185
 
@@ -318,10 +318,10 @@ export function observeHandler<C, R>(
318
318
  * sink is configured.
319
319
  *
320
320
  * This is the canonical emitter for SYNCHRONOUS facts that fire inside the
321
- * request's ALS scope (handler errors, timeouts, origin rejections, revalidation
322
- * decisions). A few emitters deliberately stay on the lower-level
323
- * resolveSink + safeEmit because observeEvent's lazy, per-call
324
- * getRouterContext() read does not fit them — keep this the complete list:
321
+ * request's ALS scope (revalidation decisions, cache-lookup decisions). A few
322
+ * emitters deliberately stay on the lower-level resolveSink + safeEmit because
323
+ * observeEvent's lazy, per-call getRouterContext() read does not fit them — keep
324
+ * this the complete list:
325
325
  * - router.ts wrapLoaderPromise (loader.start/end/error) and
326
326
  * segment-resolution/streamed-handler-telemetry.ts (streamed handler.error)
327
327
  * capture the sink + request id EAGERLY and emit from a fire-and-forget
@@ -330,6 +330,11 @@ export function observeHandler<C, R>(
330
330
  * loop (request.start/end/error, cache.decision, ...).
331
331
  * - segment-resolution/helpers.ts emits via a caller-provided report.telemetry
332
332
  * sink rather than the ALS router context.
333
+ * - rsc/handler.ts handleTimeoutResponse (request.timeout), the origin guard
334
+ * (request.origin-rejected), and handleStore.onError (late-handle
335
+ * handler.error) emit via router.telemetry directly — they run outside the
336
+ * RouterContext ALS (only match()/matchPartial() enter it), so a
337
+ * getRouterContext() read there throws and the event would vanish.
333
338
  */
334
339
  export function observeEvent(event: TelemetryEvent): void {
335
340
  // getRouterContext() either throws (real impl, outside a router context — e.g.