@rangojs/router 0.0.0-experimental.ede38110 → 0.0.0-experimental.f2d1a2f1
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 +50 -20
- package/dist/vite/index.js +353 -49
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +5 -3
- package/skills/breadcrumbs/SKILL.md +3 -1
- package/skills/hooks/SKILL.md +28 -20
- package/skills/links/SKILL.md +88 -16
- package/skills/loader/SKILL.md +35 -2
- package/skills/migrate-react-router/SKILL.md +1 -0
- package/skills/response-routes/SKILL.md +8 -0
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/typesafety/SKILL.md +3 -1
- package/src/browser/app-shell.ts +52 -0
- package/src/browser/navigation-bridge.ts +51 -2
- package/src/browser/navigation-client.ts +33 -10
- package/src/browser/navigation-store.ts +25 -1
- package/src/browser/partial-update.ts +20 -1
- package/src/browser/prefetch/cache.ts +124 -26
- package/src/browser/prefetch/fetch.ts +114 -38
- package/src/browser/prefetch/queue.ts +36 -5
- package/src/browser/rango-state.ts +53 -13
- package/src/browser/react/Link.tsx +18 -13
- package/src/browser/react/NavigationProvider.tsx +50 -11
- package/src/browser/react/use-navigation.ts +30 -11
- package/src/browser/react/use-params.ts +11 -1
- package/src/browser/react/use-router.ts +8 -1
- package/src/browser/rsc-router.tsx +34 -6
- package/src/browser/types.ts +13 -0
- package/src/cache/cf/cf-cache-store.ts +5 -7
- package/src/index.rsc.ts +3 -0
- package/src/index.ts +3 -0
- package/src/outlet-context.ts +1 -1
- package/src/response-utils.ts +28 -0
- package/src/reverse.ts +3 -2
- package/src/route-definition/dsl-helpers.ts +16 -3
- package/src/route-definition/resolve-handler-use.ts +6 -0
- package/src/router/handler-context.ts +20 -3
- package/src/router/lazy-includes.ts +1 -1
- package/src/router/loader-resolution.ts +3 -0
- package/src/router/match-api.ts +3 -3
- package/src/router/middleware-types.ts +2 -22
- package/src/router/middleware.ts +32 -4
- package/src/router/pattern-matching.ts +60 -9
- package/src/router/trie-matching.ts +10 -4
- package/src/router/url-params.ts +49 -0
- package/src/router.ts +1 -2
- package/src/rsc/handler.ts +8 -4
- package/src/rsc/helpers.ts +69 -41
- package/src/rsc/progressive-enhancement.ts +2 -0
- package/src/rsc/response-route-handler.ts +14 -1
- package/src/rsc/rsc-rendering.ts +7 -0
- package/src/rsc/server-action.ts +2 -0
- package/src/server/request-context.ts +10 -42
- package/src/types/handler-context.ts +2 -34
- package/src/types/loader-types.ts +5 -6
- package/src/types/request-scope.ts +126 -0
- package/src/urls/response-types.ts +2 -10
- package/src/vite/debug.ts +86 -0
- package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
- package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
- package/src/vite/plugins/performance-tracks.ts +4 -6
- package/src/vite/rango.ts +49 -14
- package/src/vite/router-discovery.ts +161 -23
- package/src/vite/utils/banner.ts +1 -1
- package/src/vite/utils/package-resolution.ts +41 -1
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: streams-and-websockets
|
|
3
|
+
description: Long-lived Response handlers — Server-Sent Events (SSE) via path.stream and WebSocket upgrades via path.any on Cloudflare Workers, including middleware interaction and runtime caveats.
|
|
4
|
+
argument-hint: "[sse | websocket | agents]"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Streams and WebSockets
|
|
8
|
+
|
|
9
|
+
Response routes can return long-lived responses — SSE streams and WebSocket
|
|
10
|
+
upgrades. Both require a `Response` that the router must forward through the
|
|
11
|
+
middleware chain without reconstruction.
|
|
12
|
+
|
|
13
|
+
## When each fits
|
|
14
|
+
|
|
15
|
+
| Shape | Tag | Status | Body | Runtime |
|
|
16
|
+
| ----------- | --------------- | ------ | ------------------------------- | -------------------------------- |
|
|
17
|
+
| Server-Sent | `path.stream()` | 200 | `ReadableStream` (event-stream) | any runtime (Node, workerd, bun) |
|
|
18
|
+
| WebSocket | `path.any()` | 101 | `null` + `webSocket` property | Cloudflare Workers (workerd) |
|
|
19
|
+
|
|
20
|
+
- **SSE** is a regular 200 response with `content-type: text/event-stream`
|
|
21
|
+
and a `ReadableStream` body. Works everywhere, flows through middleware
|
|
22
|
+
normally.
|
|
23
|
+
- **WebSocket upgrades** produce a status-101 response with a non-standard
|
|
24
|
+
`webSocket` property (Cloudflare). The router detects these and forwards
|
|
25
|
+
them without reconstruction; `Vary` and `Server-Timing` are skipped, and
|
|
26
|
+
stub headers are merged in place on a best-effort basis.
|
|
27
|
+
|
|
28
|
+
## Server-Sent Events (SSE)
|
|
29
|
+
|
|
30
|
+
Use `path.stream()` (or `path.any()` if you need full control) to return a
|
|
31
|
+
`ReadableStream`. Each chunk is an `event-stream` frame:
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import { urls } from "@rangojs/router";
|
|
35
|
+
|
|
36
|
+
export const urlpatterns = urls(({ path }) => [
|
|
37
|
+
path.stream(
|
|
38
|
+
"/events/ticks",
|
|
39
|
+
(ctx) => {
|
|
40
|
+
const encoder = new TextEncoder();
|
|
41
|
+
|
|
42
|
+
const stream = new ReadableStream({
|
|
43
|
+
async start(controller) {
|
|
44
|
+
let count = 0;
|
|
45
|
+
const interval = setInterval(() => {
|
|
46
|
+
controller.enqueue(
|
|
47
|
+
encoder.encode(`event: tick\ndata: ${++count}\n\n`),
|
|
48
|
+
);
|
|
49
|
+
}, 1000);
|
|
50
|
+
|
|
51
|
+
// Honor client disconnect — signal comes from ctx.request.signal
|
|
52
|
+
ctx.request.signal.addEventListener("abort", () => {
|
|
53
|
+
clearInterval(interval);
|
|
54
|
+
controller.close();
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return new Response(stream, {
|
|
60
|
+
headers: {
|
|
61
|
+
"content-type": "text/event-stream",
|
|
62
|
+
"cache-control": "no-store",
|
|
63
|
+
// Disable proxy buffering on Nginx/Traefik deployments
|
|
64
|
+
"x-accel-buffering": "no",
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
{ name: "ticks" },
|
|
69
|
+
),
|
|
70
|
+
]);
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Client
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
"use client";
|
|
77
|
+
const source = new EventSource("/events/ticks");
|
|
78
|
+
source.addEventListener("tick", (e) => console.log("tick", e.data));
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### SSE caveats
|
|
82
|
+
|
|
83
|
+
- **Never wrap SSE routes in `cache()`** — a cached `ReadableStream` is read
|
|
84
|
+
once and would replay an empty body on the next hit. `path.stream` is
|
|
85
|
+
already excluded from response-route caching, but don't layer a custom
|
|
86
|
+
cache() middleware on top.
|
|
87
|
+
- **Middleware is fine.** Global/route middleware rewraps the SSE `Response`
|
|
88
|
+
as `new Response(response.body, { status, headers })` to merge stub headers.
|
|
89
|
+
The `ReadableStream` body is passed by reference, not consumed, so the
|
|
90
|
+
client sees the stream unchanged. (WebSocket upgrades are the exception —
|
|
91
|
+
those bypass rewrap entirely; see below.)
|
|
92
|
+
- **Honor `ctx.request.signal`.** Without wiring abort to your source
|
|
93
|
+
(timer, DB cursor, upstream fetch), the stream leaks when the client
|
|
94
|
+
disconnects.
|
|
95
|
+
- **Disable Nginx/CDN buffering** via `x-accel-buffering: no` and ensure
|
|
96
|
+
no intermediate proxy rebuffers. On Cloudflare Workers this is a non-issue.
|
|
97
|
+
|
|
98
|
+
## WebSockets (Cloudflare Workers)
|
|
99
|
+
|
|
100
|
+
WebSocket upgrades on workerd produce a response with `status: 101` and a
|
|
101
|
+
non-standard `webSocket` property. The router detects this shape and forwards
|
|
102
|
+
the `Response` without reconstruction — the 101 status and the `webSocket`
|
|
103
|
+
property are preserved. `Vary` and `Server-Timing` writes are skipped, and
|
|
104
|
+
stub-header merging (cookies/custom headers set via `ctx.header()` or
|
|
105
|
+
`cookies().set()`) is best-effort: the router attempts to apply them in
|
|
106
|
+
place, but silently skips any write rejected by a runtime that exposes
|
|
107
|
+
immutable upgrade headers.
|
|
108
|
+
|
|
109
|
+
### Minimal upgrade handler
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
import { urls } from "@rangojs/router";
|
|
113
|
+
|
|
114
|
+
export const urlpatterns = urls(({ path }) => [
|
|
115
|
+
path.any(
|
|
116
|
+
"/ws",
|
|
117
|
+
(ctx) => {
|
|
118
|
+
// Manual WebSocketPair on workerd
|
|
119
|
+
const upgrade = ctx.request.headers.get("upgrade");
|
|
120
|
+
if (upgrade !== "websocket") {
|
|
121
|
+
return new Response("expected upgrade: websocket", { status: 426 });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const { 0: client, 1: server } = new WebSocketPair();
|
|
125
|
+
server.accept();
|
|
126
|
+
server.addEventListener("message", (e) => {
|
|
127
|
+
server.send(`echo: ${e.data}`);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return new Response(null, {
|
|
131
|
+
status: 101,
|
|
132
|
+
webSocket: client,
|
|
133
|
+
} as ResponseInit);
|
|
134
|
+
},
|
|
135
|
+
{ name: "ws" },
|
|
136
|
+
),
|
|
137
|
+
]);
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Durable Object pattern
|
|
141
|
+
|
|
142
|
+
Route into a Durable Object that owns the connection:
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
export const urlpatterns = urls(({ path }) => [
|
|
146
|
+
path.any(
|
|
147
|
+
"/rooms/:roomId",
|
|
148
|
+
async (ctx) => {
|
|
149
|
+
const id = ctx.env.ROOMS.idFromName(ctx.params.roomId);
|
|
150
|
+
const stub = ctx.env.ROOMS.get(id);
|
|
151
|
+
// The DO's fetch handler calls handleWebSocketUpgrade(request)
|
|
152
|
+
// and returns the 101 Response. We forward it unchanged.
|
|
153
|
+
return stub.fetch(ctx.request);
|
|
154
|
+
},
|
|
155
|
+
{ name: "room" },
|
|
156
|
+
),
|
|
157
|
+
]);
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Using the `agents` library
|
|
161
|
+
|
|
162
|
+
`routeAgentRequest` from `agents` returns a 101 `Response` targeted at a
|
|
163
|
+
Durable Object. Return it directly from `path.any()`:
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
import { routeAgentRequest } from "agents";
|
|
167
|
+
import { urls } from "@rangojs/router";
|
|
168
|
+
|
|
169
|
+
export const urlpatterns = urls(({ path }) => [
|
|
170
|
+
path.any("/agents/*", async (ctx) => {
|
|
171
|
+
const response = await routeAgentRequest(ctx.request, ctx.env);
|
|
172
|
+
if (!response) {
|
|
173
|
+
return new Response("not found", { status: 404 });
|
|
174
|
+
}
|
|
175
|
+
return response;
|
|
176
|
+
}),
|
|
177
|
+
]);
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Middleware interaction
|
|
181
|
+
|
|
182
|
+
### Forwarded, not reconstructed
|
|
183
|
+
|
|
184
|
+
When a middleware is matched for the upgrade URL, the middleware still runs
|
|
185
|
+
**before** `next()` — but the Response from `next()` is forwarded as-is
|
|
186
|
+
rather than re-wrapped. This preserves:
|
|
187
|
+
|
|
188
|
+
- The 101 status (which would otherwise throw `RangeError: Responses may
|
|
189
|
+
only be constructed with status codes in the range 200 to 599, inclusive`
|
|
190
|
+
on standards-compliant runtimes).
|
|
191
|
+
- The Cloudflare `webSocket` property (which would otherwise be silently
|
|
192
|
+
dropped by `new Response(body, ...)` on workerd).
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
// This works — logger runs, but the 101 flows through unchanged.
|
|
196
|
+
router.use(async (ctx, next) => {
|
|
197
|
+
console.log("ws request", ctx.url.pathname);
|
|
198
|
+
return next();
|
|
199
|
+
});
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Don't try to set cookies on an upgrade
|
|
203
|
+
|
|
204
|
+
Stub cookie/header writes made before `await next()` are applied to the
|
|
205
|
+
upgrade response on a best-effort basis — the router attempts an in-place
|
|
206
|
+
merge and skips any write rejected by runtimes that expose immutable 101
|
|
207
|
+
headers. Either way, a browser completing a WS handshake never reads them.
|
|
208
|
+
Do not rely on this for auth or state propagation: set cookies via a prior
|
|
209
|
+
HTTP request instead (e.g. during login), then read them at upgrade time
|
|
210
|
+
via `ctx.request.headers.get("cookie")`.
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
// Avoid: this cookie may not land on the upgrade response, and the client
|
|
214
|
+
// never reads it during the handshake regardless.
|
|
215
|
+
router.use(async (ctx, next) => {
|
|
216
|
+
cookies().set("last-ws-at", Date.now().toString());
|
|
217
|
+
return next();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// Prefer: authenticate by reading a cookie set on a prior HTTP request.
|
|
221
|
+
path.any("/ws", (ctx) => {
|
|
222
|
+
const session = parseCookie(ctx.request.headers.get("cookie"))?.session;
|
|
223
|
+
if (!verify(session)) return new Response("unauthorized", { status: 401 });
|
|
224
|
+
// ...upgrade
|
|
225
|
+
});
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Short-circuit before upgrade
|
|
229
|
+
|
|
230
|
+
Middleware can return a non-101 Response to deny the upgrade outright:
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
router.use(async (ctx, next) => {
|
|
234
|
+
if (!isAllowed(ctx.request)) {
|
|
235
|
+
return new Response("forbidden", { status: 403 });
|
|
236
|
+
}
|
|
237
|
+
return next();
|
|
238
|
+
});
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Caching
|
|
242
|
+
|
|
243
|
+
- **SSE** — do not combine with `cache()` (streams can't be replayed).
|
|
244
|
+
- **WebSocket** — `cache()` is inert because only `status === 200` is cacheable.
|
|
245
|
+
|
|
246
|
+
## Runtime caveats
|
|
247
|
+
|
|
248
|
+
| Runtime | SSE | WebSocket upgrade (101) |
|
|
249
|
+
| -------------------------------------- | --- | ---------------------------------------------------- |
|
|
250
|
+
| Cloudflare Workers (workerd) | OK | OK (native `WebSocketPair`, DO, `agents`) |
|
|
251
|
+
| Node (undici fetch) | OK | N/A — Node's HTTP server must upgrade |
|
|
252
|
+
| Bun | OK | Bun's native `upgrade()` — not a Response-based path |
|
|
253
|
+
| Dev (Vite + `@cloudflare/vite-plugin`) | OK | OK via workerd emulation |
|
|
254
|
+
|
|
255
|
+
When running in pure Node without workerd, a `status: 101` Response cannot
|
|
256
|
+
even be constructed (`new Response(null, { status: 101 })` throws). For
|
|
257
|
+
tests, fabricate upgrade-style responses by overriding `.status` on a real
|
|
258
|
+
Response instance:
|
|
259
|
+
|
|
260
|
+
```typescript
|
|
261
|
+
const upgrade = new Response(null, { status: 200 });
|
|
262
|
+
Object.defineProperty(upgrade, "status", { value: 101, configurable: true });
|
|
263
|
+
// optional: attach a webSocket stub
|
|
264
|
+
Object.defineProperty(upgrade, "webSocket", {
|
|
265
|
+
value: { stub: "ws" },
|
|
266
|
+
configurable: true,
|
|
267
|
+
enumerable: true,
|
|
268
|
+
});
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## Testing
|
|
272
|
+
|
|
273
|
+
- Unit tests: `isWebSocketUpgradeResponse` and `executeMiddleware` passthrough
|
|
274
|
+
cases live in `src/rsc/__tests__/helpers.test.ts` and
|
|
275
|
+
`src/router/middleware.test.ts`.
|
|
276
|
+
- E2E: cover both dev and production modes against a workerd target. SSE
|
|
277
|
+
can be tested on any runtime; WS upgrades need workerd (use
|
|
278
|
+
`@cloudflare/vite-plugin` or `wrangler dev`).
|
|
279
|
+
|
|
280
|
+
## See also
|
|
281
|
+
|
|
282
|
+
- `response-routes` — the parent skill for `path.json/text/html/stream/any`.
|
|
283
|
+
- `middleware` — how global and route-level middleware compose with handlers.
|
|
@@ -462,9 +462,11 @@ export const ProductLoader = createLoader(async (ctx) => {
|
|
|
462
462
|
});
|
|
463
463
|
|
|
464
464
|
// Built-in Breadcrumbs — or any custom handle created with createHandle()
|
|
465
|
+
```
|
|
465
466
|
|
|
467
|
+
```tsx
|
|
466
468
|
// Client component — typeof infers all generics
|
|
467
|
-
|
|
469
|
+
"use client";
|
|
468
470
|
import { useLoader, useHandle, type Breadcrumbs } from "@rangojs/router/client";
|
|
469
471
|
import type { ProductLoader } from "../loaders";
|
|
470
472
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* App-shell metadata: the set of per-router fields that describe the
|
|
5
|
+
* "envelope" around the current app's segment tree. These fields are set
|
|
6
|
+
* from the initial RSC payload and must be replaced atomically when the
|
|
7
|
+
* client navigates into a different router (app switch).
|
|
8
|
+
*
|
|
9
|
+
* Intentionally NOT part of the shell (all document-lifetime):
|
|
10
|
+
* - themeConfig / initialTheme: ThemeProvider is mounted above the segment
|
|
11
|
+
* tree and must not remount on smooth transitions.
|
|
12
|
+
* - warmupEnabled: attached to the NavigationProvider's lifetime effect;
|
|
13
|
+
* toggling it mid-session would tear down and restart idle listeners.
|
|
14
|
+
* Also not serialized on every full-render path (e.g. the not-found
|
|
15
|
+
* fallback), so carrying it here would be unreliable.
|
|
16
|
+
* - prefetchCacheTTL: the not-found full-render payload does not serialize
|
|
17
|
+
* it, so a cross-app nav into a 404 would silently erase the setting.
|
|
18
|
+
* Mutable shell fields must be serialized on EVERY full-render path,
|
|
19
|
+
* otherwise absent fields are indistinguishable from "new app has no
|
|
20
|
+
* value" and the old app's value is dropped.
|
|
21
|
+
*
|
|
22
|
+
* A new document navigation (hard reload) applies these fields from the
|
|
23
|
+
* target app's initial payload.
|
|
24
|
+
*/
|
|
25
|
+
export interface AppShell {
|
|
26
|
+
/** Router identity. Used to namespace per-app client state (e.g. the
|
|
27
|
+
* rango-state localStorage key) so sibling apps on the same origin
|
|
28
|
+
* cannot observe each other's cache invalidations. */
|
|
29
|
+
routerId?: string;
|
|
30
|
+
rootLayout?: ComponentType<{ children: ReactNode }>;
|
|
31
|
+
basename?: string;
|
|
32
|
+
version?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Mutable container for the active app shell. Read-through via `get()` so
|
|
37
|
+
* closures capture the ref, not the shell, and pick up updates at call time.
|
|
38
|
+
*/
|
|
39
|
+
export interface AppShellRef {
|
|
40
|
+
get(): AppShell;
|
|
41
|
+
update(next: AppShell): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createAppShellRef(initial: AppShell): AppShellRef {
|
|
45
|
+
let current = initial;
|
|
46
|
+
return {
|
|
47
|
+
get: () => current,
|
|
48
|
+
update: (next) => {
|
|
49
|
+
current = next;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -5,6 +5,8 @@ import type {
|
|
|
5
5
|
ResolvedSegment,
|
|
6
6
|
} from "./types.js";
|
|
7
7
|
import { setAppVersion } from "./app-version.js";
|
|
8
|
+
import { setRangoStateLocal } from "./rango-state.js";
|
|
9
|
+
import type { AppShell, AppShellRef } from "./app-shell.js";
|
|
8
10
|
import * as React from "react";
|
|
9
11
|
import { startTransition } from "react";
|
|
10
12
|
import {
|
|
@@ -48,8 +50,13 @@ export { createNavigationTransaction };
|
|
|
48
50
|
*/
|
|
49
51
|
export interface NavigationBridgeConfigWithController extends NavigationBridgeConfig {
|
|
50
52
|
eventController: EventController;
|
|
51
|
-
/** RSC version from initial payload metadata */
|
|
53
|
+
/** RSC version from initial payload metadata (fallback when appShellRef is not provided) */
|
|
52
54
|
version?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Live app-shell ref. When supplied, the bridge reads version/basename
|
|
57
|
+
* from this ref so cross-app navigations propagate correctly.
|
|
58
|
+
*/
|
|
59
|
+
appShellRef?: AppShellRef;
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
/**
|
|
@@ -68,9 +75,46 @@ export interface NavigationBridgeConfigWithController extends NavigationBridgeCo
|
|
|
68
75
|
export function createNavigationBridge(
|
|
69
76
|
config: NavigationBridgeConfigWithController,
|
|
70
77
|
): NavigationBridge {
|
|
71
|
-
const {
|
|
78
|
+
const {
|
|
79
|
+
store,
|
|
80
|
+
client,
|
|
81
|
+
eventController,
|
|
82
|
+
onUpdate,
|
|
83
|
+
renderSegments,
|
|
84
|
+
appShellRef,
|
|
85
|
+
} = config;
|
|
72
86
|
let version = config.version;
|
|
73
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Replace the active app-shell snapshot atomically. Called by the partial
|
|
90
|
+
* updater when a response's routerId indicates the navigation crossed
|
|
91
|
+
* into a different app. Runs the local-only side-effects tied to
|
|
92
|
+
* app-shell fields (app version, rango-state namespace) so the new app
|
|
93
|
+
* owns them after the swap. Theme, warmup, and prefetch TTL are
|
|
94
|
+
* document-lifetime and are NOT touched here.
|
|
95
|
+
*/
|
|
96
|
+
function applyAppShell(next: AppShell): void {
|
|
97
|
+
if (appShellRef) {
|
|
98
|
+
appShellRef.update(next);
|
|
99
|
+
}
|
|
100
|
+
if (next.version !== undefined) {
|
|
101
|
+
version = next.version;
|
|
102
|
+
setAppVersion(next.version);
|
|
103
|
+
// Use the local-only setter — initRangoState writes the shared
|
|
104
|
+
// localStorage key and fires a storage event in other tabs still in
|
|
105
|
+
// the old app. setRangoStateLocal only mutates this tab's in-memory
|
|
106
|
+
// cache and rebinds it to the target app's routerId-scoped key,
|
|
107
|
+
// preserving the "local-only, no broadcast/rotation" contract for
|
|
108
|
+
// smooth app-switch transitions.
|
|
109
|
+
setRangoStateLocal(next.version, next.routerId);
|
|
110
|
+
}
|
|
111
|
+
// Cross-app: prior cache entries belong to a different app's segments.
|
|
112
|
+
// Drop them locally only — do NOT broadcast invalidation or rotate the
|
|
113
|
+
// shared X-Rango-State token, since other tabs still in the old app are
|
|
114
|
+
// unaffected by this tab's transition.
|
|
115
|
+
store.clearHistoryCacheLocal();
|
|
116
|
+
}
|
|
117
|
+
|
|
74
118
|
// Create shared partial updater
|
|
75
119
|
const fetchPartialUpdate = createPartialUpdater({
|
|
76
120
|
store,
|
|
@@ -78,6 +122,7 @@ export function createNavigationBridge(
|
|
|
78
122
|
onUpdate,
|
|
79
123
|
renderSegments,
|
|
80
124
|
getVersion: () => version,
|
|
125
|
+
applyAppShell,
|
|
81
126
|
});
|
|
82
127
|
|
|
83
128
|
return {
|
|
@@ -664,6 +709,10 @@ export function createNavigationBridge(
|
|
|
664
709
|
setAppVersion(newVersion);
|
|
665
710
|
store.clearHistoryCache();
|
|
666
711
|
},
|
|
712
|
+
|
|
713
|
+
updateAppShell(next: AppShell): void {
|
|
714
|
+
applyAppShell(next);
|
|
715
|
+
},
|
|
667
716
|
};
|
|
668
717
|
}
|
|
669
718
|
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from "./response-adapter.js";
|
|
20
20
|
import {
|
|
21
21
|
buildPrefetchKey,
|
|
22
|
+
buildSourceKey,
|
|
22
23
|
consumeInflightPrefetch,
|
|
23
24
|
consumePrefetch,
|
|
24
25
|
} from "./prefetch/cache.js";
|
|
@@ -30,8 +31,10 @@ import {
|
|
|
30
31
|
* deserializing the response using the RSC runtime.
|
|
31
32
|
*
|
|
32
33
|
* Checks the in-memory prefetch cache before making a network request.
|
|
33
|
-
*
|
|
34
|
-
*
|
|
34
|
+
* Tries the source-scoped key first (populated when the server tagged
|
|
35
|
+
* the response as source-sensitive via `X-RSC-Prefetch-Scope: source`)
|
|
36
|
+
* and falls back to the Rango-state-keyed wildcard slot used for the
|
|
37
|
+
* common source-agnostic case.
|
|
35
38
|
*
|
|
36
39
|
* @param deps - RSC browser dependencies (createFromFetch)
|
|
37
40
|
* @returns NavigationClient instance
|
|
@@ -93,17 +96,19 @@ export function createNavigationClient(
|
|
|
93
96
|
fetchUrl.searchParams.set("_rsc_rid", routerId);
|
|
94
97
|
}
|
|
95
98
|
|
|
96
|
-
// Check completed in-memory prefetch cache before making a network
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
+
// Check completed in-memory prefetch cache before making a network
|
|
100
|
+
// request. Try the source-scoped key first (populated when the server
|
|
101
|
+
// tagged the prefetch response as source-sensitive, e.g. intercepts,
|
|
102
|
+
// or when a Link opted in with `prefetchKey=":source"`), then fall
|
|
103
|
+
// back to the wildcard slot shared across source pages.
|
|
104
|
+
// Both keys embed the Rango state, so state rotation (deploy or
|
|
105
|
+
// server-action invalidation) auto-invalidates both scopes.
|
|
99
106
|
// Skip cache for stale revalidation (needs fresh data), HMR (needs
|
|
100
107
|
// fresh modules), and intercept contexts (source-dependent responses).
|
|
101
|
-
//
|
|
102
108
|
const canUsePrefetch = !staleRevalidation && !hmr && !interceptSourceUrl;
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const wildcardKey = "*\0" + fetchUrl.pathname + fetchUrl.search;
|
|
109
|
+
const rangoState = getRangoState();
|
|
110
|
+
const wildcardKey = buildPrefetchKey(rangoState, fetchUrl);
|
|
111
|
+
const cacheKey = buildSourceKey(rangoState, previousUrl, fetchUrl);
|
|
107
112
|
|
|
108
113
|
let cachedResponse: Response | null = null;
|
|
109
114
|
let hitKey: string | null = null;
|
|
@@ -244,6 +249,7 @@ export function createNavigationClient(
|
|
|
244
249
|
wildcard: hitKey === wildcardKey,
|
|
245
250
|
});
|
|
246
251
|
}
|
|
252
|
+
const adoptedViaWildcard = hitKey === wildcardKey;
|
|
247
253
|
responsePromise = inflightResponsePromise.then(async (response) => {
|
|
248
254
|
if (!response) {
|
|
249
255
|
if (tx) {
|
|
@@ -252,6 +258,23 @@ export function createNavigationClient(
|
|
|
252
258
|
return doFreshFetch();
|
|
253
259
|
}
|
|
254
260
|
|
|
261
|
+
// Cross-source safety: an inflight promise adopted via the
|
|
262
|
+
// wildcard key may turn out to be source-scoped (server emitted
|
|
263
|
+
// `X-RSC-Prefetch-Scope: source`), which means it was built for
|
|
264
|
+
// a different source page. Discard and refetch.
|
|
265
|
+
if (
|
|
266
|
+
adoptedViaWildcard &&
|
|
267
|
+
response.headers.get("x-rsc-prefetch-scope") === "source"
|
|
268
|
+
) {
|
|
269
|
+
if (tx) {
|
|
270
|
+
browserDebugLog(
|
|
271
|
+
tx,
|
|
272
|
+
"wildcard inflight turned out source-scoped, refetching",
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
return doFreshFetch();
|
|
276
|
+
}
|
|
277
|
+
|
|
255
278
|
const validated = validateRscHeaders(response, "inflight prefetch");
|
|
256
279
|
if (validated instanceof Promise) return validated;
|
|
257
280
|
|
|
@@ -12,7 +12,10 @@ import type {
|
|
|
12
12
|
ActionStateListener,
|
|
13
13
|
HandleData,
|
|
14
14
|
} from "./types.js";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
clearPrefetchCache,
|
|
17
|
+
clearPrefetchCacheLocal,
|
|
18
|
+
} from "./prefetch/cache.js";
|
|
16
19
|
|
|
17
20
|
/**
|
|
18
21
|
* Default action state (idle with no payload)
|
|
@@ -335,6 +338,18 @@ export function createNavigationStore(
|
|
|
335
338
|
clearPrefetchCache();
|
|
336
339
|
}
|
|
337
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Drop this tab's navigation + prefetch caches without broadcasting or
|
|
343
|
+
* rotating shared state. Used when the local session changes in a way that
|
|
344
|
+
* doesn't affect other tabs — e.g. this tab crosses into a different app
|
|
345
|
+
* via a cross-router navigation. Other tabs in the old app keep their
|
|
346
|
+
* caches and their X-Rango-State token.
|
|
347
|
+
*/
|
|
348
|
+
function clearCacheInternalLocal(): void {
|
|
349
|
+
historyCache.length = 0;
|
|
350
|
+
clearPrefetchCacheLocal();
|
|
351
|
+
}
|
|
352
|
+
|
|
338
353
|
/**
|
|
339
354
|
* Mark all cache entries as stale (internal - does not broadcast)
|
|
340
355
|
*/
|
|
@@ -668,6 +683,15 @@ export function createNavigationStore(
|
|
|
668
683
|
clearCacheAndBroadcast();
|
|
669
684
|
},
|
|
670
685
|
|
|
686
|
+
/**
|
|
687
|
+
* Drop this tab's navigation + prefetch caches locally without
|
|
688
|
+
* broadcasting or rotating shared state. Intended for cross-app
|
|
689
|
+
* transitions where the session state diverges for this tab only.
|
|
690
|
+
*/
|
|
691
|
+
clearHistoryCacheLocal(): void {
|
|
692
|
+
clearCacheInternalLocal();
|
|
693
|
+
},
|
|
694
|
+
|
|
671
695
|
/**
|
|
672
696
|
* Mark cache as stale and broadcast to other tabs
|
|
673
697
|
* Called after server actions - allows SWR pattern for popstate
|
|
@@ -41,6 +41,13 @@ export interface PartialUpdateConfig {
|
|
|
41
41
|
) => Promise<ReactNode> | ReactNode;
|
|
42
42
|
/** RSC version getter — returns the current version (may change after HMR) */
|
|
43
43
|
getVersion?: () => string | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* Replace the active app-shell when a cross-app navigation is detected.
|
|
46
|
+
* Called before the full-update tree replacement renders, so the new
|
|
47
|
+
* payload's rootLayout, basename, and version are picked up. Theme,
|
|
48
|
+
* warmup, and prefetch TTL are not part of the shell — see AppShell.
|
|
49
|
+
*/
|
|
50
|
+
applyAppShell?: (next: import("./app-shell.js").AppShell) => void;
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
/**
|
|
@@ -110,6 +117,7 @@ export function createPartialUpdater(
|
|
|
110
117
|
onUpdate,
|
|
111
118
|
renderSegments,
|
|
112
119
|
getVersion = () => undefined,
|
|
120
|
+
applyAppShell,
|
|
113
121
|
} = config;
|
|
114
122
|
|
|
115
123
|
/**
|
|
@@ -228,7 +236,12 @@ export function createPartialUpdater(
|
|
|
228
236
|
// Detect app switch: if routerId changed, the navigation crossed into
|
|
229
237
|
// a different router (e.g., via host router path mount). Downgrade
|
|
230
238
|
// partial to full so the entire tree is replaced without reconciliation
|
|
231
|
-
// against stale segments from the previous app
|
|
239
|
+
// against stale segments from the previous app, and replace the app
|
|
240
|
+
// shell (rootLayout, basename, version) so the target app's document
|
|
241
|
+
// and router config take effect instead of remaining captured from the
|
|
242
|
+
// initial load. Theme, warmup, and prefetch TTL are intentionally
|
|
243
|
+
// document-lifetime (see AppShell doc); a new document navigation
|
|
244
|
+
// applies them.
|
|
232
245
|
if (payload.metadata?.routerId) {
|
|
233
246
|
const prevRouterId = store.getRouterId?.();
|
|
234
247
|
if (prevRouterId && prevRouterId !== payload.metadata.routerId) {
|
|
@@ -236,6 +249,12 @@ export function createPartialUpdater(
|
|
|
236
249
|
`[Browser] App switch detected (${prevRouterId} → ${payload.metadata.routerId}), forcing full update`,
|
|
237
250
|
);
|
|
238
251
|
payload.metadata.isPartial = false;
|
|
252
|
+
applyAppShell?.({
|
|
253
|
+
routerId: payload.metadata.routerId,
|
|
254
|
+
rootLayout: payload.metadata.rootLayout,
|
|
255
|
+
basename: payload.metadata.basename,
|
|
256
|
+
version: payload.metadata.version,
|
|
257
|
+
});
|
|
239
258
|
}
|
|
240
259
|
store.setRouterId?.(payload.metadata.routerId);
|
|
241
260
|
}
|