@rangojs/router 0.0.0-experimental.138 → 0.0.0-experimental.139
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/README.md +273 -924
- package/dist/bin/rango.js +28 -14
- package/dist/vite/index.js +472 -77
- package/package.json +33 -19
- package/skills/comparison/SKILL.md +50 -0
- package/skills/comparison/agents/openai.yaml +4 -0
- package/skills/comparison/references/framework-comparison.md +837 -0
- package/skills/host-router/SKILL.md +45 -3
- package/skills/layout/SKILL.md +26 -9
- package/skills/middleware/SKILL.md +6 -2
- package/skills/observability/SKILL.md +23 -1
- package/skills/parallel/SKILL.md +11 -3
- package/skills/rango/SKILL.md +71 -23
- package/skills/route/SKILL.md +19 -3
- package/skills/router-setup/SKILL.md +1 -0
- package/skills/shell-manifest/SKILL.md +185 -0
- package/skills/vercel/SKILL.md +128 -0
- package/src/build/generate-route-types.ts +1 -0
- package/src/build/route-types/router-processing.ts +77 -27
- package/src/cache/index.ts +12 -0
- package/src/cache/vercel/index.ts +11 -0
- package/src/cache/vercel/vercel-cache-store.ts +948 -0
- package/src/host/errors.ts +15 -0
- package/src/host/index.ts +1 -0
- package/src/vercel/index.ts +11 -0
- package/src/vercel/tracing.ts +88 -0
- package/src/vite/discovery/state.ts +1 -1
- package/src/vite/index.ts +2 -0
- package/src/vite/plugin-types.ts +84 -2
- package/src/vite/plugins/vercel-output.ts +384 -0
- package/src/vite/plugins/virtual-entries.ts +72 -0
- package/src/vite/rango.ts +128 -21
- package/src/vite/utils/shared-utils.ts +52 -2
|
@@ -33,6 +33,48 @@ export default {
|
|
|
33
33
|
};
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
+
## Deploying: Cloudflare vs node/vercel
|
|
37
|
+
|
|
38
|
+
How a host router is _served_ depends on the preset, because the preset decides who owns the server entry.
|
|
39
|
+
|
|
40
|
+
| Preset | Who owns the entry | What the host module exports |
|
|
41
|
+
| ----------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
42
|
+
| `cloudflare` | You (your `worker.rsc.tsx`) | `export default { fetch(request, env, ctx) { return router.match(request, { env, ctx }); } }` |
|
|
43
|
+
| `node` / `vercel` | rango (generated RSC entry) | `export default router;` (the `HostRouter` instance itself), or a named `export const hostRouter`/`router`. |
|
|
44
|
+
|
|
45
|
+
On `node`/`vercel`, rango generates the served RSC entry, so it needs the `HostRouter` **instance** to call `hostRouter.match()` for you. Export the instance, not a `{ fetch }` object:
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
// src/worker.rsc.tsx (node / vercel)
|
|
49
|
+
import { createHostRouter } from "@rangojs/router/host";
|
|
50
|
+
|
|
51
|
+
export const hostRouter = createHostRouter();
|
|
52
|
+
hostRouter.host(["admin.*"]).lazy(() => import("./apps/admin/handler.js"));
|
|
53
|
+
hostRouter.host(["."]).lazy(() => import("./apps/site/handler.js"));
|
|
54
|
+
|
|
55
|
+
// Export the instance — the generated entry serves it via hostRouter.match().
|
|
56
|
+
export default hostRouter;
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Each sub-app exports a handler exactly as on Cloudflare (no change):
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
// src/apps/admin/handler.ts
|
|
63
|
+
import { router } from "./router.js";
|
|
64
|
+
export default (request: Request, input: any) => router.fetch(request, input);
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Selecting the host entry — a host app has several `createRouter()` sub-apps, so single-router auto-discovery can't pick one. Either let rango auto-detect the lone `createHostRouter()` file, or point at it explicitly:
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
// vite.config.ts
|
|
71
|
+
rango({ preset: "vercel", hostRouter: "./src/worker.rsc.tsx" });
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
On Vercel this is a single function running `hostRouter.match()` for every request (mirrors the Cloudflare single-worker model); `{ env, ctx }` (`process.env` + `{ waitUntil }`) is threaded unchanged to each matched sub-app's handler and `cache(env, ctx)` factory. See the `vercel` skill.
|
|
75
|
+
|
|
76
|
+
Unmatched hosts on node/vercel: because rango owns the generated entry (you have no worker `try/catch`), it catches `NoRouteMatchError` and returns **404** by default — so you do **not** need a catch-all host route. If you want different behavior (a branded 404, a redirect, a default app), register a catch-all mount as the **last** route, e.g. `host(["**"]).lazy(() => import("./apps/site/handler.js"))` — it matches any host, so the built-in 404 only fires when nothing matched at all. (Note `fallback()` is for cookie-override errors, not general unmatched hosts.)
|
|
77
|
+
|
|
36
78
|
## Inline handlers (`.map`) vs lazy mounts (`.lazy`)
|
|
37
79
|
|
|
38
80
|
A host pattern maps to one of two things, and you pick the method by intent:
|
|
@@ -134,17 +176,17 @@ router.fallback().map((request) => {
|
|
|
134
176
|
});
|
|
135
177
|
```
|
|
136
178
|
|
|
137
|
-
For unmatched hosts without `hostOverride`, catch `NoRouteMatchError` in your worker fetch
|
|
179
|
+
For unmatched hosts without `hostOverride`, catch `NoRouteMatchError` in your worker fetch. Use the `isNoRouteMatchError()` guard rather than a bare `instanceof`: a workspace with a duplicated `@rangojs/router` copy can throw the error with a different class identity, and `instanceof` would then turn the 404 into an opaque 500.
|
|
138
180
|
|
|
139
181
|
```typescript
|
|
140
|
-
import {
|
|
182
|
+
import { isNoRouteMatchError } from "@rangojs/router/host";
|
|
141
183
|
|
|
142
184
|
export default {
|
|
143
185
|
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
|
144
186
|
try {
|
|
145
187
|
return await router.match(request, { env, ctx });
|
|
146
188
|
} catch (err) {
|
|
147
|
-
if (err
|
|
189
|
+
if (isNoRouteMatchError(err)) {
|
|
148
190
|
return new Response("Not Found", { status: 404 });
|
|
149
191
|
}
|
|
150
192
|
throw err;
|
package/skills/layout/SKILL.md
CHANGED
|
@@ -147,12 +147,21 @@ A layout as a child of `path()` wraps the route content and can read
|
|
|
147
147
|
data set by the route handler via `ctx.get()`. The handler always
|
|
148
148
|
executes before its children.
|
|
149
149
|
|
|
150
|
-
This
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
150
|
+
This is the recommended way to pass handler data downward, and it is
|
|
151
|
+
safe under partial action revalidation with zero configuration: orphan
|
|
152
|
+
layouts (and their parallels) belong to the route entry, and on an
|
|
153
|
+
action the whole entry re-runs together by default — route segment,
|
|
154
|
+
loaders, and `belongsToRoute` children all seed revalidate-true, with
|
|
155
|
+
handler-first ordering preserved. Producer and consumer cannot desync
|
|
156
|
+
unless you narrow one side with a predicate that returns a hard `false`
|
|
157
|
+
(then put the same contract on both — see "Revalidation Contracts").
|
|
158
|
+
|
|
159
|
+
Data from an **outer** handler or layout entry is the opposite case:
|
|
160
|
+
outer entries do not revalidate on actions by default (parent-chain
|
|
161
|
+
skip). If an orphan layout depends on data established above its own
|
|
162
|
+
route entry, that outer segment must share a revalidation contract, or
|
|
163
|
+
the orphan must guard/reload the data independently. See `/rango` →
|
|
164
|
+
"Passing data down the tree" for the full safest-first ladder.
|
|
156
165
|
|
|
157
166
|
```typescript
|
|
158
167
|
import { Outlet, ParallelOutlet } from "@rangojs/router/client";
|
|
@@ -191,7 +200,10 @@ orphan layouts to read them.
|
|
|
191
200
|
|
|
192
201
|
## Layout Revalidation
|
|
193
202
|
|
|
194
|
-
|
|
203
|
+
Standalone `layout()` entries don't revalidate by default — on an action,
|
|
204
|
+
parent-chain segments are skipped unless a `revalidate()` opts them in.
|
|
205
|
+
(Orphan layouts inside a `path()` are the opposite: they ride along with
|
|
206
|
+
the route entry by default.) Control with `revalidate()`:
|
|
195
207
|
|
|
196
208
|
```typescript
|
|
197
209
|
layout(<ShopLayout />, () => [
|
|
@@ -218,8 +230,13 @@ their `ctx.set()` state.
|
|
|
218
230
|
|
|
219
231
|
### Revalidation Contracts
|
|
220
232
|
|
|
221
|
-
|
|
222
|
-
|
|
233
|
+
Contracts are the tool for cross-entry sharing — the bottom rung of the
|
|
234
|
+
data-passing ladder (`/rango` → "Passing data down the tree"). Before
|
|
235
|
+
writing one, check whether the producer can move down a rung: into the
|
|
236
|
+
consumer's own entry as an orphan layout, into middleware, or into a
|
|
237
|
+
loader. When the data genuinely must flow from an outer entry, define
|
|
238
|
+
named revalidation functions and reuse them on both producer and
|
|
239
|
+
consumer segments:
|
|
223
240
|
|
|
224
241
|
```typescript
|
|
225
242
|
// revalidation-contracts.ts
|
|
@@ -60,8 +60,12 @@ data itself.
|
|
|
60
60
|
### Revalidation Contracts with Middleware-Backed Trees
|
|
61
61
|
|
|
62
62
|
Middleware can establish request-level context (`ctx.set`) for segments that
|
|
63
|
-
execute in the current render pass.
|
|
64
|
-
|
|
63
|
+
execute in the current render pass. Because route middleware wraps **every**
|
|
64
|
+
render pass — normal renders, post-action revalidation, PE re-renders — its
|
|
65
|
+
variables are never stale: middleware is the safest `ctx.set` rung on the
|
|
66
|
+
data-passing ladder (`/rango` → "Passing data down the tree"). But it does
|
|
67
|
+
not change partial revalidation boundaries between handler/layout/parallel
|
|
68
|
+
segments.
|
|
65
69
|
|
|
66
70
|
For shared segment data, use named revalidation contracts on both the producer
|
|
67
71
|
and consumer segments, even when middleware is present in the chain.
|
|
@@ -112,7 +112,29 @@ const router = createRouter({
|
|
|
112
112
|
});
|
|
113
113
|
```
|
|
114
114
|
|
|
115
|
-
|
|
115
|
+
On **Vercel Functions** (Node runtime), use `createVercelTracing` — a thin
|
|
116
|
+
wrapper over `createOTelTracing` that reads the global OTel tracer
|
|
117
|
+
`@vercel/otel`'s `registerOTel()` installs, so you do not call `trace.getTracer`
|
|
118
|
+
yourself. Custom spans are Node-only (unsupported on the Edge runtime):
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
// instrumentation.ts — install the provider, then export the tracing config.
|
|
122
|
+
// Importing this module is what runs registerOTel() — a Rango/Vite app does not
|
|
123
|
+
// auto-load instrumentation.ts like Next.js, so a standalone registerOTel() that
|
|
124
|
+
// nothing imports is a silent no-op.
|
|
125
|
+
import { registerOTel } from "@vercel/otel";
|
|
126
|
+
import { createVercelTracing } from "@rangojs/router/vercel";
|
|
127
|
+
registerOTel({ serviceName: "my-app" });
|
|
128
|
+
export const tracing = createVercelTracing(); // { enabled, spans, tracerName, tracer }
|
|
129
|
+
|
|
130
|
+
// router.tsx — importing `tracing` runs instrumentation.ts
|
|
131
|
+
import { createRouter } from "@rangojs/router";
|
|
132
|
+
import { tracing } from "./instrumentation.js";
|
|
133
|
+
|
|
134
|
+
const router = createRouter({ document: Document, urls: urlpatterns, tracing });
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
These factories return a `RouterTracingConfig` for the same `tracing` slot;
|
|
116
138
|
`telemetry` stays independent (events only, no phase spans). Phase spans:
|
|
117
139
|
`rango.request`, `rango.middleware`, `rango.action`, `rango.loader`,
|
|
118
140
|
`rango.render`, `rango.ssr` — the same phases the `debugPerformance` timeline
|
package/skills/parallel/SKILL.md
CHANGED
|
@@ -344,9 +344,17 @@ parallel(
|
|
|
344
344
|
)
|
|
345
345
|
```
|
|
346
346
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
347
|
+
Where the slot sits decides its action default. A parallel under a
|
|
348
|
+
`path()` (or one of its orphan layouts) belongs to the route entry and
|
|
349
|
+
revalidates together with it on every action — handler-set data stays
|
|
350
|
+
consistent with no configuration. A parallel under a standalone
|
|
351
|
+
`layout()` entry follows the parent-chain default instead: skipped on
|
|
352
|
+
actions unless a `revalidate()` opts it in.
|
|
353
|
+
|
|
354
|
+
In either position, revalidating only the parallel does not re-run outer
|
|
355
|
+
handlers/layouts. If the slot reads `ctx.get()` data established above
|
|
356
|
+
it, opt the outer segment into revalidation as well (see `/rango` →
|
|
357
|
+
"Passing data down the tree").
|
|
350
358
|
|
|
351
359
|
A `revalidate()` callback may return a hard `boolean`, a soft
|
|
352
360
|
`{ defaultShouldRevalidate }` object, or nothing (`void` / `null` /
|
package/skills/rango/SKILL.md
CHANGED
|
@@ -59,22 +59,57 @@ appears, not up front.
|
|
|
59
59
|
To decide where something can live: **does it define a URL? structure, stays in
|
|
60
60
|
`urls()`. Does it modify a node? config, compose freely.**
|
|
61
61
|
|
|
62
|
+
## Passing data down the tree
|
|
63
|
+
|
|
64
|
+
Four ways to get per-request data to a segment below you, ordered safest-first.
|
|
65
|
+
Reach for the next rung only when the one above doesn't fit — the higher rungs
|
|
66
|
+
are immune to partial-revalidation staleness by construction.
|
|
67
|
+
|
|
68
|
+
1. **A loader** (`loader()` + `useLoader()`). Loaders resolve fresh on every
|
|
69
|
+
pass — full renders, action revalidations, cache hits. Nothing to keep in
|
|
70
|
+
sync. If the data can be a loader, make it a loader.
|
|
71
|
+
2. **Middleware `ctx.set()`**. Route middleware wraps every render pass,
|
|
72
|
+
including post-action revalidation and PE re-renders, so its variables are
|
|
73
|
+
never stale. Right for request-shaped context: auth, session, locale.
|
|
74
|
+
3. **Handler `ctx.set()` to its own children** —
|
|
75
|
+
`path(handler, ..., () => [layout(...)])`. Orphan layouts and their
|
|
76
|
+
parallels belong to the route entry: on an action the whole entry re-runs
|
|
77
|
+
together by default (handler-first preserved), so the data stays consistent
|
|
78
|
+
with zero configuration. Right for data the page must compute anyway —
|
|
79
|
+
e.g. pagination, where the handler's search decides how many pages the
|
|
80
|
+
layout chrome renders. One rule: if you narrow the entry's revalidation
|
|
81
|
+
with a predicate that can return a hard `false`, put the same contract on
|
|
82
|
+
the entry's children too — a hard `false` on one side of a
|
|
83
|
+
producer/consumer pair desyncs it.
|
|
84
|
+
4. **Cross-entry sharing** — an outer `layout()` entry feeding descendants.
|
|
85
|
+
Outer entries do NOT revalidate on actions by default (the revalidation
|
|
86
|
+
trace calls this `action:parent-chain-skip`), so this rung always requires
|
|
87
|
+
a shared revalidation contract: the same named `revalidate()` function on
|
|
88
|
+
the producer and every consumer. See `/layout` → "Revalidation Contracts".
|
|
89
|
+
Before writing one, check whether the producer can move down a rung.
|
|
90
|
+
|
|
91
|
+
The failure mode this ladder prevents: a consumer re-runs, its producer
|
|
92
|
+
doesn't, `ctx.get()` reads `undefined`, and fallback UI silently replaces good
|
|
93
|
+
UI after an action. Rungs 1–3 make that unrepresentable; rung 4 makes it a
|
|
94
|
+
stated, greppable contract.
|
|
95
|
+
|
|
62
96
|
## Pick a primitive
|
|
63
97
|
|
|
64
|
-
| I need to… | Use
|
|
65
|
-
| ------------------------------------- |
|
|
66
|
-
| render data fresh every request | `loader()` + `useLoader()`
|
|
67
|
-
| cache a rendered subtree | `cache()` on a segment
|
|
68
|
-
| cache one function/component's result | `"use cache"`
|
|
69
|
-
| cache a loader's data | `loader(L, () => [cache()])`
|
|
70
|
-
| re-render a segment after an action | `revalidate()`
|
|
71
|
-
| mutate | `"use server"` action
|
|
72
|
-
| debug a slow request | `debugPerformance` / telemetry
|
|
73
|
-
| share config across routes | factory returning a helper array
|
|
74
|
-
| compose a sub-app / module | `include()`
|
|
75
|
-
| modal / soft navigation | `intercept()`
|
|
76
|
-
| pre-render a route at build time | `Prerender(...)` wrapper
|
|
77
|
-
|
|
|
98
|
+
| I need to… | Use | Skill |
|
|
99
|
+
| ------------------------------------- | ---------------------------------- | ----------------------- |
|
|
100
|
+
| render data fresh every request | `loader()` + `useLoader()` | /loader |
|
|
101
|
+
| cache a rendered subtree | `cache()` on a segment | /caching |
|
|
102
|
+
| cache one function/component's result | `"use cache"` | /use-cache |
|
|
103
|
+
| cache a loader's data | `loader(L, () => [cache()])` | /loader, /caching |
|
|
104
|
+
| re-render a segment after an action | `revalidate()` | /loader |
|
|
105
|
+
| mutate | `"use server"` action | /server-actions |
|
|
106
|
+
| debug a slow request | `debugPerformance` / telemetry | /observability |
|
|
107
|
+
| share config across routes | factory returning a helper array | /composability |
|
|
108
|
+
| compose a sub-app / module | `include()` | /route |
|
|
109
|
+
| modal / soft navigation | `intercept()` | /intercept |
|
|
110
|
+
| pre-render a route at build time | `Prerender(...)` wrapper | /prerender |
|
|
111
|
+
| feed live loaders from a cached shell | replayed handle + `ctx.rendered()` | /shell-manifest |
|
|
112
|
+
| stream SSE / upgrade a WebSocket | `path.stream()` / `path.any()` | /streams-and-websockets |
|
|
78
113
|
|
|
79
114
|
## Invariants
|
|
80
115
|
|
|
@@ -185,6 +220,12 @@ resolve `dist/` outside `./vite`, and it may lag `src/`.
|
|
|
185
220
|
|
|
186
221
|
Grouped by concern — read when you need to…
|
|
187
222
|
|
|
223
|
+
**Positioning & evaluation**:
|
|
224
|
+
|
|
225
|
+
| Skill | Description |
|
|
226
|
+
| ------------- | ------------------------------------------------------------ |
|
|
227
|
+
| `/comparison` | Compare Rango with Next.js, TanStack Start, and Waku fairly. |
|
|
228
|
+
|
|
188
229
|
**Structure & routing** — shape URLs, layouts, navigation, and request processing:
|
|
189
230
|
|
|
190
231
|
| Skill | Description |
|
|
@@ -206,15 +247,16 @@ Grouped by concern — read when you need to…
|
|
|
206
247
|
|
|
207
248
|
**Data & caching** — fetch, mutate, and cache:
|
|
208
249
|
|
|
209
|
-
| Skill | Description
|
|
210
|
-
| ----------------- |
|
|
211
|
-
| `/loader` | Data loaders with `createLoader()` and `revalidate()`
|
|
212
|
-
| `/server-actions` | Mutations with `"use server"`, useActionState, validation, revalidation
|
|
213
|
-
| `/caching` | Segment caching with memory or KV stores
|
|
214
|
-
| `/use-cache` | Function-level caching with `"use cache"` directive
|
|
215
|
-
| `/cache-guide` | When to use `cache()` vs `"use cache"` — differences and decision guide
|
|
216
|
-
| `/document-cache` | Edge caching with Cache-Control headers
|
|
217
|
-
| `/prerender` | Pre-render route segments at build time (Passthrough live fallback)
|
|
250
|
+
| Skill | Description |
|
|
251
|
+
| ----------------- | ------------------------------------------------------------------------------------------ |
|
|
252
|
+
| `/loader` | Data loaders with `createLoader()` and `revalidate()` |
|
|
253
|
+
| `/server-actions` | Mutations with `"use server"`, useActionState, validation, revalidation |
|
|
254
|
+
| `/caching` | Segment caching with memory or KV stores |
|
|
255
|
+
| `/use-cache` | Function-level caching with `"use cache"` directive |
|
|
256
|
+
| `/cache-guide` | When to use `cache()` vs `"use cache"` — differences and decision guide |
|
|
257
|
+
| `/document-cache` | Edge caching with Cache-Control headers |
|
|
258
|
+
| `/prerender` | Pre-render route segments at build time (Passthrough live fallback) |
|
|
259
|
+
| `/shell-manifest` | Replayed handles as cache metadata read by live loaders (frozen shell, batched live holes) |
|
|
218
260
|
|
|
219
261
|
**Client & presentation** — build the client-side UX:
|
|
220
262
|
|
|
@@ -239,6 +281,12 @@ Grouped by concern — read when you need to…
|
|
|
239
281
|
| `/bundle-analysis` | Audit your app's production bundle for server leaks and oversized chunks |
|
|
240
282
|
| `/debug-manifest` | Inspect route manifest structure |
|
|
241
283
|
|
|
284
|
+
**Deployment**:
|
|
285
|
+
|
|
286
|
+
| Skill | Description |
|
|
287
|
+
| --------- | ----------------------------------------------------------------------------------------- |
|
|
288
|
+
| `/vercel` | Deploy to Vercel Functions (`preset: "vercel"`), Runtime Cache, and `createVercelTracing` |
|
|
289
|
+
|
|
242
290
|
**Testing**:
|
|
243
291
|
|
|
244
292
|
| Skill | Description |
|
package/skills/route/SKILL.md
CHANGED
|
@@ -154,6 +154,13 @@ first. Use `ctx.set(key, value)` to share data with children, who read it
|
|
|
154
154
|
via `ctx.get(key)`. Caching wraps all segments together, so either all run
|
|
155
155
|
or none do.
|
|
156
156
|
|
|
157
|
+
This pattern is also safe under partial action revalidation: on an action,
|
|
158
|
+
the route entry re-runs as a unit by default — route segment, loaders, and
|
|
159
|
+
`belongsToRoute` children (orphan layouts, entry parallels) all seed
|
|
160
|
+
revalidate-true, with handler-first ordering preserved. Handler-set data
|
|
161
|
+
stays consistent with no configuration. See `/rango` → "Passing data down
|
|
162
|
+
the tree" for the safest-first ladder.
|
|
163
|
+
|
|
157
164
|
### Typed context variables with createVar
|
|
158
165
|
|
|
159
166
|
Use `createVar<T>()` to create a typed token for `ctx.set()`/`ctx.get()`.
|
|
@@ -240,9 +247,18 @@ Cacheable vars (the default) can be read freely inside cache scopes.
|
|
|
240
247
|
> decides hit/miss/ttl/swr independently and never reads `revalidate()`. See
|
|
241
248
|
> `/cache-guide` → "Two axes" and `/rango` → "The shape of rango".
|
|
242
249
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
250
|
+
With no `revalidate()` configured, an entry needs no contract: on an action
|
|
251
|
+
the route handler and its children re-run together by default, so handler
|
|
252
|
+
data stays consistent on its own. Contracts matter in two cases:
|
|
253
|
+
|
|
254
|
+
1. **You narrow the entry's revalidation** with a predicate that can return a
|
|
255
|
+
hard `false` (e.g. bare `ctx.isAction(X)`). A hard `false` on one side of a
|
|
256
|
+
producer/consumer pair desyncs it — the child re-runs by default and reads
|
|
257
|
+
`undefined`, or vice versa. Put the same named contract on the route and
|
|
258
|
+
its dependent children so they narrow together.
|
|
259
|
+
2. **The producer is an outer entry** (a standalone `layout()` above this
|
|
260
|
+
route). Outer entries skip action revalidation by default, so the shared
|
|
261
|
+
contract is mandatory — see `/layout` → "Revalidation Contracts".
|
|
246
262
|
|
|
247
263
|
```typescript
|
|
248
264
|
// revalidation-contracts.ts
|
|
@@ -505,6 +505,7 @@ const router = createRouter({
|
|
|
505
505
|
```typescript
|
|
506
506
|
// On Cloudflare Workers, swap the tracing factory for native custom spans
|
|
507
507
|
// (no @opentelemetry/api dependency); the telemetry slot is unchanged.
|
|
508
|
+
// On Vercel (Node runtime) use createVercelTracing() from @rangojs/router/vercel.
|
|
508
509
|
import { createCloudflareTracing } from "@rangojs/router/cloudflare";
|
|
509
510
|
|
|
510
511
|
const router = createRouter({
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: shell-manifest
|
|
3
|
+
description: Shell manifest pattern — replayed handles as cache metadata that live loaders read, e.g. a prerendered product list with batched live prices
|
|
4
|
+
argument-hint:
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Shell Manifest — cache metadata for live loaders
|
|
8
|
+
|
|
9
|
+
Use this when a cached or prerendered shell has dynamic holes, and the live
|
|
10
|
+
data layer needs to know **what the shell actually contains** — which
|
|
11
|
+
products, which slots, which keys. The frozen render describes itself
|
|
12
|
+
through a handle; loaders (always live) read that description and fetch
|
|
13
|
+
exactly the dynamic data the shell needs, in one batch.
|
|
14
|
+
|
|
15
|
+
Canonical case: a prerendered product list where prices must stay live.
|
|
16
|
+
|
|
17
|
+
## The problem this solves
|
|
18
|
+
|
|
19
|
+
Any cached-shell-plus-live-holes design has a coordination gap: how does the
|
|
20
|
+
live layer know what the holes need?
|
|
21
|
+
|
|
22
|
+
- **Per-hole fetching** (each `<Price>` component fetching for itself) is the
|
|
23
|
+
N+1 default — N visible products, N queries.
|
|
24
|
+
- **A loader that re-queries the list** ("current top products") drifts from
|
|
25
|
+
a stale shell — right prices attached to wrong products.
|
|
26
|
+
|
|
27
|
+
The shell manifest closes the gap with a consistency guarantee: the loader
|
|
28
|
+
reads the ids the shell _actually rendered_, replayed from the same stored
|
|
29
|
+
artifact, so the holes can never desync from the shell and the query is
|
|
30
|
+
batched.
|
|
31
|
+
|
|
32
|
+
## The mechanism (three features composed)
|
|
33
|
+
|
|
34
|
+
1. **Handles record data at render time.** The handler pushes to a handle
|
|
35
|
+
(`ctx.use(Handle)`) while it renders — at build time for `Prerender`, on
|
|
36
|
+
the cache miss for `cache()`.
|
|
37
|
+
2. **Replay on every hit.** Handle data is stored with the Flight payload
|
|
38
|
+
and replayed into the handle store on cache/prerender hits — handler code
|
|
39
|
+
does not re-run, but its pushes do.
|
|
40
|
+
3. **Loaders read after the render barrier.** A DSL loader can
|
|
41
|
+
`await ctx.rendered()` (waits for all non-loader segments to settle —
|
|
42
|
+
fresh render or replay alike), then `ctx.use(Handle)` returns the
|
|
43
|
+
**collected** handle data.
|
|
44
|
+
|
|
45
|
+
Loaders are live by default, so the read happens on every request even when
|
|
46
|
+
the shell is a hit.
|
|
47
|
+
|
|
48
|
+
## Canonical example: prerendered list, live prices
|
|
49
|
+
|
|
50
|
+
```tsx
|
|
51
|
+
// handles/rendered-products.ts
|
|
52
|
+
import { createHandle } from "@rangojs/router";
|
|
53
|
+
|
|
54
|
+
// TData = string (one push per product id), collected to a flat string[]
|
|
55
|
+
export const RenderedProducts = createHandle<string, string[]>((segments) =>
|
|
56
|
+
segments.flat(),
|
|
57
|
+
);
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
// routes/products.tsx — the list is baked at build time; prices are not
|
|
62
|
+
import { Prerender } from "@rangojs/router";
|
|
63
|
+
import { RenderedProducts } from "../handles/rendered-products";
|
|
64
|
+
import { Price } from "../components/price";
|
|
65
|
+
|
|
66
|
+
export const ProductList = Prerender(
|
|
67
|
+
async () => [{ category: "espresso" }, { category: "filter" }],
|
|
68
|
+
async (ctx) => {
|
|
69
|
+
const products = await db.productsByCategory(ctx.params.category);
|
|
70
|
+
const track = ctx.use(RenderedProducts);
|
|
71
|
+
for (const p of products) track(p.id);
|
|
72
|
+
return (
|
|
73
|
+
<ul>
|
|
74
|
+
{products.map((p) => (
|
|
75
|
+
<li key={p.id}>
|
|
76
|
+
{p.name} <Price id={p.id} />
|
|
77
|
+
</li>
|
|
78
|
+
))}
|
|
79
|
+
</ul>
|
|
80
|
+
);
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
// loaders/prices.ts — one batched query for exactly the rendered products
|
|
87
|
+
import { createLoader } from "@rangojs/router";
|
|
88
|
+
import { RenderedProducts } from "../handles/rendered-products";
|
|
89
|
+
|
|
90
|
+
export const PriceLoader = createLoader(async (ctx) => {
|
|
91
|
+
"use server";
|
|
92
|
+
await ctx.rendered();
|
|
93
|
+
const ids = ctx.use(RenderedProducts);
|
|
94
|
+
return db.pricesFor(ids); // Map<string, number> keyed by product id
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```tsx
|
|
99
|
+
// urls.tsx — wire the route and register the loader
|
|
100
|
+
path("/products/:category", ProductList, { name: "products" }, () => [
|
|
101
|
+
loader(PriceLoader),
|
|
102
|
+
]);
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
```tsx
|
|
106
|
+
// components/price.tsx — live hole in the frozen shell
|
|
107
|
+
"use client";
|
|
108
|
+
import { useLoader } from "@rangojs/router/client";
|
|
109
|
+
import { PriceLoader } from "../loaders/prices";
|
|
110
|
+
|
|
111
|
+
export function Price({ id }: { id: string }) {
|
|
112
|
+
const { data } = useLoader(PriceLoader);
|
|
113
|
+
return <span>{formatPrice(data[id])}</span>;
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Request flow on a hit: stored payload replays (handler never runs) → handle
|
|
118
|
+
data lands in the store → render barrier resolves → `PriceLoader` reads the
|
|
119
|
+
replayed ids → one query → prices stream into `<Price>` components.
|
|
120
|
+
|
|
121
|
+
## Works with runtime cache() too
|
|
122
|
+
|
|
123
|
+
`Prerender` is build-time caching; the replay mechanism is identical for the
|
|
124
|
+
runtime segment cache. Wrap the route in `cache()` instead and the handler
|
|
125
|
+
pushes on the miss, replays on every hit:
|
|
126
|
+
|
|
127
|
+
```tsx
|
|
128
|
+
cache({ ttl: 600, tags: ["products"] }, () => [
|
|
129
|
+
path("/products/:category", ProductList, { name: "products" }, () => [
|
|
130
|
+
loader(PriceLoader),
|
|
131
|
+
]),
|
|
132
|
+
]);
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Contract and gotchas
|
|
136
|
+
|
|
137
|
+
- **The manifest is exactly as fresh as the shell.** Replayed handle data is
|
|
138
|
+
frozen with the payload. To change _which_ products render, invalidate the
|
|
139
|
+
shell (`updateTag("products")`, TTL expiry, rebuild) — never treat the
|
|
140
|
+
loader as the refresh path for the list itself. This is the point:
|
|
141
|
+
shell and holes cannot desync because they share one artifact.
|
|
142
|
+
- **No request-scoped data in a manifest handle.** The handle data is baked
|
|
143
|
+
into a shared artifact — the same cross-user rule as any cached content.
|
|
144
|
+
Ids, slugs, slot names, variant keys: yes. Anything derived from
|
|
145
|
+
`cookies()`/`headers()`: no.
|
|
146
|
+
- **`ctx.rendered()` is experimental and DSL-loaders-only.** It throws in
|
|
147
|
+
fetchable/standalone loader calls that run outside a route render.
|
|
148
|
+
- **The reading loader serializes after the shell.** `await ctx.rendered()`
|
|
149
|
+
deliberately gives up loader/render parallelism — on a miss the loader
|
|
150
|
+
waits for segment resolution; on a hit (the common case for a cached
|
|
151
|
+
shell) replay is immediate and the wait is negligible. A
|
|
152
|
+
`debugPerformance` waterfall shows this loader after the render bar; for
|
|
153
|
+
this pattern that is the contract, not a regression.
|
|
154
|
+
- **`ctx.use(Handle)` before `await ctx.rendered()` throws** in a loader,
|
|
155
|
+
with an error saying to await the barrier first.
|
|
156
|
+
- **Deferred handle values are resolved before storage** (resolve-by-default),
|
|
157
|
+
so the manifest read always sees plain values, never promises.
|
|
158
|
+
|
|
159
|
+
## Testing
|
|
160
|
+
|
|
161
|
+
`runLoader` seeds the barrier and the collected handle value directly —
|
|
162
|
+
matched by handle reference (the same seeding style as loader deps):
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
import { runLoader } from "@rangojs/router/testing";
|
|
166
|
+
|
|
167
|
+
const prices = await runLoader(PriceLoader, {
|
|
168
|
+
rendered: true,
|
|
169
|
+
handles: [[RenderedProducts, ["widget-a", "widget-b"]]],
|
|
170
|
+
env: { DB: fakeDb },
|
|
171
|
+
});
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
This tests the loader's post-barrier logic. The real
|
|
175
|
+
push → store → replay → barrier wiring is covered at the e2e tier (dev +
|
|
176
|
+
production), like every cache-path behavior.
|
|
177
|
+
|
|
178
|
+
## Related
|
|
179
|
+
|
|
180
|
+
- `/prerender` — `Prerender`/`Passthrough`, build flow, passthrough fallback
|
|
181
|
+
- `/caching` — segment `cache()`, stores, tags
|
|
182
|
+
- `/loader` — loader context, `ctx.rendered()`, streaming
|
|
183
|
+
- `/hooks` — `useHandle` for reading handle data in client components
|
|
184
|
+
- `/rango` → "Passing data down the tree" — this pattern is the frozen→live
|
|
185
|
+
counterpart of that ladder
|