@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.
- package/dist/vite/index.js +3 -1
- package/package.json +3 -1
- package/skills/caching/SKILL.md +18 -0
- package/skills/composability/SKILL.md +32 -0
- package/skills/observability/SKILL.md +8 -0
- package/skills/parallel/SKILL.md +2 -0
- package/skills/ppr/SKILL.md +76 -10
- package/skills/route/SKILL.md +8 -0
- package/skills/typesafety/SKILL.md +1 -0
- package/skills/typesafety/generated-files-and-cli.md +30 -0
- package/src/cloudflare/tracing.ts +7 -8
- package/src/index.rsc.ts +1 -0
- package/src/index.ts +12 -8
- package/src/route-definition/helpers-types.ts +5 -4
- package/src/route-map-builder.ts +24 -1
- package/src/router/find-match.ts +15 -1
- package/src/router/instrument.ts +9 -4
- package/src/router/match-handlers.ts +170 -133
- package/src/router/middleware.ts +36 -29
- package/src/router/router-interfaces.ts +9 -0
- package/src/router/segment-resolution/loader-snapshot.ts +98 -17
- package/src/router/telemetry-otel.ts +6 -8
- package/src/router/tracing.ts +14 -5
- package/src/router.ts +15 -6
- package/src/rsc/handler.ts +46 -30
- package/src/rsc/shell-capture.ts +5 -0
- package/src/testing/dispatch.ts +142 -37
- package/src/urls/path-helper-types.ts +9 -4
- package/src/vercel/tracing.ts +7 -7
package/dist/vite/index.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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",
|
package/skills/caching/SKILL.md
CHANGED
|
@@ -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
|
package/skills/parallel/SKILL.md
CHANGED
|
@@ -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
|
package/skills/ppr/SKILL.md
CHANGED
|
@@ -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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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.
|
|
391
|
-
|
|
392
|
-
|
|
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
|
|
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
|
package/skills/route/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
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
|
|
347
|
-
// server-only and live in index.rsc.ts (the
|
|
348
|
-
// bare `@rangojs/router` import). Re-exporting
|
|
349
|
-
// (default/client) entry would pull telemetry.ts and
|
|
350
|
-
// the client module graph; both tree-shake to zero bytes
|
|
351
|
-
// bundle analysis output and slow build-time module
|
|
352
|
-
//
|
|
353
|
-
//
|
|
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
|
-
|
|
153
|
-
|
|
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
|
/**
|
package/src/route-map-builder.ts
CHANGED
|
@@ -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)
|
|
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);
|
package/src/router/find-match.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { tryTrieMatch } from "./trie-matching.js";
|
|
2
|
-
import {
|
|
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
|
|
package/src/router/instrument.ts
CHANGED
|
@@ -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 (
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
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.
|