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

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.
Files changed (51) hide show
  1. package/dist/vite/index.js +24 -6
  2. package/package.json +2 -2
  3. package/skills/cache-guide/SKILL.md +3 -1
  4. package/skills/caching/SKILL.md +23 -2
  5. package/skills/catalog.json +6 -0
  6. package/skills/defer-hydration/SKILL.md +235 -0
  7. package/skills/loader/SKILL.md +5 -0
  8. package/skills/migrate-nextjs/SKILL.md +4 -2
  9. package/skills/parallel/SKILL.md +2 -0
  10. package/skills/ppr/SKILL.md +63 -33
  11. package/skills/rango/SKILL.md +10 -0
  12. package/skills/use-cache/SKILL.md +12 -2
  13. package/src/browser/logging.ts +18 -0
  14. package/src/browser/partial-update.ts +7 -0
  15. package/src/browser/rsc-router.tsx +43 -0
  16. package/src/cache/cache-key-utils.ts +29 -0
  17. package/src/cache/cache-runtime.ts +41 -51
  18. package/src/cache/cache-scope.ts +2 -17
  19. package/src/cache/cache-tag.ts +60 -14
  20. package/src/cache/cf/cf-cache-store.ts +58 -20
  21. package/src/cache/document-cache.ts +17 -11
  22. package/src/cache/types.ts +18 -4
  23. package/src/cache/vercel/vercel-cache-store.ts +15 -20
  24. package/src/redirect-origin.ts +14 -0
  25. package/src/route-map-builder.ts +17 -3
  26. package/src/router/lazy-includes.ts +8 -2
  27. package/src/router/loader-resolution.ts +14 -2
  28. package/src/router/match-handlers.ts +11 -6
  29. package/src/router/middleware.ts +4 -1
  30. package/src/router/segment-resolution/loader-cache.ts +19 -3
  31. package/src/router/segment-resolution/loader-mask.ts +4 -11
  32. package/src/router/segment-resolution/loader-snapshot.ts +14 -6
  33. package/src/router/segment-resolution/mask-nested.ts +83 -0
  34. package/src/router/telemetry.ts +9 -1
  35. package/src/router.ts +7 -8
  36. package/src/rsc/handler.ts +9 -2
  37. package/src/rsc/redirect-guard.ts +2 -1
  38. package/src/rsc/rsc-rendering.ts +122 -18
  39. package/src/rsc/shell-capture.ts +125 -20
  40. package/src/rsc/shell-serve.ts +37 -6
  41. package/src/segment-loader-promise.ts +18 -0
  42. package/src/segment-system.tsx +90 -6
  43. package/src/server/context.ts +47 -9
  44. package/src/server/cookie-store.ts +26 -5
  45. package/src/server/request-context.ts +22 -0
  46. package/src/ssr/index.tsx +160 -113
  47. package/src/ssr/inject-rsc-eager.ts +167 -0
  48. package/src/testing/dispatch.ts +7 -0
  49. package/src/vite/index.ts +7 -0
  50. package/src/vite/inject-client-debug.ts +64 -12
  51. package/src/vite/router-discovery.ts +9 -1
@@ -0,0 +1,167 @@
1
+ import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
2
+
3
+ /**
4
+ * Eager Flight-payload injector for the PPR resume path.
5
+ *
6
+ * rsc-html-stream's injectRSCPayload starts forwarding Flight chunks only from
7
+ * inside its first transform() callback — i.e. AFTER the first HTML chunk flows.
8
+ * That policy exists for the normal document path (a <script> must not precede
9
+ * the doctype). On a PPR shell HIT it parks the ENTIRE hydration payload: the
10
+ * resumed fizz render emits its first chunk only when the first hole's data
11
+ * resolves (live loaders — measured ~1.5s on SFCC-backed pages), while the
12
+ * Flight root row is ready within ~30ms of the tail render starting. The client
13
+ * cannot call hydrateRoot until that root row arrives, so the lazy start held
14
+ * hydration hostage to the slowest loader for no structural reason: the stored
15
+ * prelude (a complete document through </body></html>) is already on the wire
16
+ * before the tail, so every tail byte is foster-parented and a Flight <script>
17
+ * is valid as the FIRST tail byte.
18
+ *
19
+ * This injector starts pumping Flight chunks immediately in start(). Ordering
20
+ * safety is kept by serializing ALL writes through one promise chain: fizz
21
+ * chunks buffered within a tick flush as one atomic task (same batching idea as
22
+ * the stock injector — never inject between two partial HTML chunks), and each
23
+ * Flight script is its own task, so scripts land only between batches. The
24
+ * trailer is stripped from passing HTML and re-appended once, after both
25
+ * streams complete — identical to the stock contract.
26
+ *
27
+ * RESUME/DATA-VARIANT ONLY. The normal document path must keep the stock
28
+ * injector: there the first bytes are the document head, and an eager script
29
+ * would precede the doctype.
30
+ */
31
+
32
+ const encoder = new TextEncoder();
33
+ const TRAILER = "</body></html>";
34
+
35
+ // Escape closing script tags and HTML comments in JS content (ported from
36
+ // rsc-html-stream/server; escapes the "s" instead of the slash so a regexp
37
+ // literal like `0</script/` stays valid JS).
38
+ function escapeScript(script: string): string {
39
+ return script.replace(/<!--/g, "<\\!--").replace(/<\/(script)/gi, "</\\$1");
40
+ }
41
+
42
+ function writeScript(
43
+ controller: TransformStreamDefaultController<Uint8Array>,
44
+ jsExpr: string,
45
+ nonce: string | undefined,
46
+ ): void {
47
+ controller.enqueue(
48
+ encoder.encode(
49
+ `<script${nonce ? ` nonce="${nonce}"` : ""}>${escapeScript(
50
+ `(self.__FLIGHT_DATA||=[]).push(${jsExpr})`,
51
+ )}</script>`,
52
+ ),
53
+ );
54
+ }
55
+
56
+ export function injectRSCPayloadEager(
57
+ rscStream: ReadableStream<Uint8Array>,
58
+ options?: { nonce?: string },
59
+ ): TransformStream<Uint8Array, Uint8Array> {
60
+ const nonce = options?.nonce;
61
+ const htmlDecoder = new TextDecoder();
62
+ const t0 = INTERNAL_RANGO_DEBUG ? performance.now() : 0;
63
+ let loggedFirstFlight = false;
64
+ let loggedFirstHtml = false;
65
+
66
+ // All output goes through this chain: one task per Flight script, one task
67
+ // per buffered-HTML batch. A script can therefore never split a batch.
68
+ let queue: Promise<void> = Promise.resolve();
69
+ const enqueueTask = (fn: () => void): Promise<void> => {
70
+ queue = queue.then(fn);
71
+ return queue;
72
+ };
73
+
74
+ let buffered: Uint8Array[] = [];
75
+ let timeout: ReturnType<typeof setTimeout> | null = null;
76
+ let rscDone: Promise<void> = Promise.resolve();
77
+
78
+ function flushBufferedHTML(
79
+ controller: TransformStreamDefaultController<Uint8Array>,
80
+ ): void {
81
+ if (INTERNAL_RANGO_DEBUG && !loggedFirstHtml && buffered.length > 0) {
82
+ loggedFirstHtml = true;
83
+ console.log(
84
+ `[Server][ppr] eager-inject: first resumed HTML batch +${Math.round(performance.now() - t0)}ms`,
85
+ );
86
+ }
87
+ for (const chunk of buffered) {
88
+ let buf = htmlDecoder.decode(chunk, { stream: true });
89
+ if (buf.endsWith(TRAILER)) buf = buf.slice(0, -TRAILER.length);
90
+ controller.enqueue(encoder.encode(buf));
91
+ }
92
+ const remaining = htmlDecoder.decode();
93
+ if (remaining.length) {
94
+ const out = remaining.endsWith(TRAILER)
95
+ ? remaining.slice(0, -TRAILER.length)
96
+ : remaining;
97
+ controller.enqueue(encoder.encode(out));
98
+ }
99
+ buffered.length = 0;
100
+ timeout = null;
101
+ }
102
+
103
+ async function pumpRSC(
104
+ controller: TransformStreamDefaultController<Uint8Array>,
105
+ ): Promise<void> {
106
+ const rscDecoder = new TextDecoder("utf-8", { fatal: true });
107
+ const reader = rscStream.getReader();
108
+ for (;;) {
109
+ const { done, value } = await reader.read();
110
+ if (done) break;
111
+ // String when the chunk is valid unicode, base64 round-trip otherwise —
112
+ // same fallback the stock injector uses.
113
+ let jsExpr: string;
114
+ try {
115
+ jsExpr = JSON.stringify(rscDecoder.decode(value, { stream: true }));
116
+ } catch {
117
+ const base64 = JSON.stringify(
118
+ btoa(String.fromCodePoint(...(value as Uint8Array))),
119
+ );
120
+ jsExpr = `Uint8Array.from(atob(${base64}), m => m.codePointAt(0))`;
121
+ }
122
+ await enqueueTask(() => {
123
+ if (INTERNAL_RANGO_DEBUG && !loggedFirstFlight) {
124
+ loggedFirstFlight = true;
125
+ console.log(
126
+ `[Server][ppr] eager-inject: first flight script +${Math.round(performance.now() - t0)}ms`,
127
+ );
128
+ }
129
+ writeScript(controller, jsExpr, nonce);
130
+ });
131
+ }
132
+ const remaining = rscDecoder.decode();
133
+ if (remaining.length) {
134
+ await enqueueTask(() =>
135
+ writeScript(controller, JSON.stringify(remaining), nonce),
136
+ );
137
+ }
138
+ }
139
+
140
+ return new TransformStream<Uint8Array, Uint8Array>({
141
+ start(controller) {
142
+ // The eager part: pump Flight immediately, before any HTML arrives.
143
+ rscDone = pumpRSC(controller).catch((err) => {
144
+ try {
145
+ controller.error(err);
146
+ } catch {
147
+ // Stream already errored/closed; nothing to signal.
148
+ }
149
+ });
150
+ },
151
+ transform(chunk, controller) {
152
+ buffered.push(chunk);
153
+ if (timeout) return;
154
+ // Batch same-tick fizz chunks so a Flight script cannot land between two
155
+ // partial HTML chunks of one logical write (stock injector's invariant).
156
+ timeout = setTimeout(() => {
157
+ void enqueueTask(() => flushBufferedHTML(controller));
158
+ }, 0);
159
+ },
160
+ async flush(controller) {
161
+ await rscDone;
162
+ if (timeout) clearTimeout(timeout);
163
+ await enqueueTask(() => flushBufferedHTML(controller));
164
+ controller.enqueue(encoder.encode(TRAILER));
165
+ },
166
+ });
167
+ }
@@ -757,6 +757,10 @@ export async function dispatch<TEnv = any>(
757
757
  durationMs: performance.now() - telemetryStart,
758
758
  segmentCount: 0,
759
759
  cacheHit: false,
760
+ // dispatch's final response IS built before request.end (unlike
761
+ // match()/matchPartial(), whose Response is built after), so stamp its
762
+ // status — the same field a thrown-Response short-circuit carries.
763
+ status: finalResponse.status,
760
764
  });
761
765
  }
762
766
 
@@ -785,6 +789,9 @@ export async function dispatch<TEnv = any>(
785
789
  durationMs: performance.now() - telemetryStart,
786
790
  segmentCount: 0,
787
791
  cacheHit: false,
792
+ // Carry the short-circuit Response's status (parity with
793
+ // match-handlers.ts's thrown-Response request.end).
794
+ status: error.status,
788
795
  });
789
796
  } else {
790
797
  safeEmit(resolveSink(sink), {
package/src/vite/index.ts CHANGED
@@ -8,6 +8,13 @@
8
8
 
9
9
  export { rango } from "./rango.js";
10
10
  export { poke } from "./plugins/refresh-cmd.js";
11
+ // The built-in clientChunks strategy, exported so a custom `clientChunks`
12
+ // function can OVERLAY it (route a few modules to a dedicated chunk, delegate
13
+ // the rest) instead of replacing the whole route/marker grouping. Without this
14
+ // a consumer override silently loses app-fallback/route splitting for the
15
+ // entire app. Note: called without a ClientChunkContext the fallbackRefs-based
16
+ // `app-fallback` split is inactive — discovery wires it only for the built-in.
17
+ export { directoryClientChunks } from "./utils/client-chunks.js";
11
18
 
12
19
  export type {
13
20
  RangoNodeOptions,
@@ -1,3 +1,5 @@
1
+ import type { Connect } from "vite";
2
+
1
3
  /**
2
4
  * Bake the resolved INTERNAL_RANGO_DEBUG value into the router's `internal-debug`
3
5
  * module so the flag reaches the CLIENT debug logs by just setting the env var.
@@ -10,27 +12,77 @@
10
12
  * runs on the module regardless of how (or whether) the define is delivered, in
11
13
  * both dev and build and for every environment, so the discovery plugin uses this
12
14
  * to replace the module with the resolved literal.
13
- *
15
+ */
16
+
17
+ /**
18
+ * Scope to the router's own internal-debug module: the published package
19
+ * (`/@rangojs/router/`, incl. pnpm's nested layout) or the monorepo workspace
20
+ * (`/packages/rangojs-router/`). The package-anchored path avoids matching a
21
+ * consumer file that merely sits under a directory named `rangojs-router`.
22
+ * Accepts module ids and dev-server URLs (`/@fs/...internal-debug.ts?v=abc`).
23
+ */
24
+ export function isRouterInternalDebugId(id: string): boolean {
25
+ if (!id.includes("internal-debug")) return false;
26
+ const norm = id.replace(/\\/g, "/");
27
+ return (
28
+ /\/internal-debug\.[cm]?[jt]sx?(\?|$)/.test(norm) &&
29
+ (norm.includes("/@rangojs/router/") ||
30
+ norm.includes("/packages/rangojs-router/"))
31
+ );
32
+ }
33
+
34
+ /**
35
+ * Transform: replace the module with the resolved literal.
14
36
  * Returns null for any module that is not the router's internal-debug module.
15
37
  */
16
38
  export function injectClientDebugFlag(
17
39
  id: string,
18
40
  ): { code: string; map: null } | null {
19
41
  // Cheap early-out: this hook runs on every module in every environment.
20
- if (!id.includes("internal-debug")) return null;
21
- const norm = id.replace(/\\/g, "/");
22
- // Scope to the router's own internal-debug module: the published package
23
- // (`/@rangojs/router/`, incl. pnpm's nested layout) or the monorepo workspace
24
- // (`/packages/rangojs-router/`). The package-anchored path avoids matching a
25
- // consumer file that merely sits under a directory named `rangojs-router`.
26
- const isInternalDebug =
27
- /\/internal-debug\.[cm]?[jt]sx?(\?|$)/.test(norm) &&
28
- (norm.includes("/@rangojs/router/") ||
29
- norm.includes("/packages/rangojs-router/"));
30
- if (!isInternalDebug) return null;
42
+ if (!isRouterInternalDebugId(id)) return null;
31
43
  // Emit the whole module: internal-debug.ts has a single export, kept in sync.
32
44
  return {
33
45
  code: `export const INTERNAL_RANGO_DEBUG = ${!!process.env.INTERNAL_RANGO_DEBUG};\n`,
34
46
  map: null,
35
47
  };
36
48
  }
49
+
50
+ /**
51
+ * Dev middleware companion: serve the internal-debug module `no-cache` so the
52
+ * browser revalidates it (etag) instead of trusting an immutable cache entry.
53
+ *
54
+ * Why: the transform bakes the flag into module CONTENT, but a published
55
+ * consumer resolves the module into node_modules, where dev serves it as
56
+ * `internal-debug.ts?v=<hash>` with `Cache-Control: max-age=31536000,immutable`.
57
+ * That `?v=` hash does not vary with env vars (verified on Vite 8: getConfigHash
58
+ * hashes NODE_ENV, resolve, plugin names, optimizeDeps -- not arbitrary env
59
+ * state), so toggling the flag changed the content under an unchanged immutable
60
+ * URL: a browser that ever loaded the app with the flag off kept the
61
+ * baked-`false` module across dev-server restarts, and INTERNAL_RANGO_DEBUG
62
+ * never reached the FE logs while the server logs worked. The monorepo was
63
+ * immune -- workspace source is outside node_modules and served no-cache --
64
+ * which is why this bit only npm consumers.
65
+ *
66
+ * internal-debug.ts is the ONLY flag-varying module in the graph (its importers
67
+ * are byte-identical across flag states), so forcing revalidation for this one
68
+ * tiny module is sufficient and costs one conditional request per session. The
69
+ * middleware is unconditional (not gated on the flag) so an already-poisoned
70
+ * cache heals in both directions. Alternatives that do not work: a plugin
71
+ * `resolveId` appending a flag query never fires in Vite 8 dev for
72
+ * fs-resolvable relative imports, and the pre-#621 `define` no longer rotates
73
+ * the optimizer hash (define contents are not part of getConfigHash).
74
+ */
75
+ export function internalDebugNoCacheMiddleware(): Connect.NextHandleFunction {
76
+ return function rangoInternalDebugNoCache(req, res, next) {
77
+ if (req.url && isRouterInternalDebugId(req.url)) {
78
+ const setHeader = res.setHeader.bind(res);
79
+ res.setHeader = (name, value) => {
80
+ return setHeader(
81
+ name,
82
+ name.toLowerCase() === "cache-control" ? "no-cache" : value,
83
+ );
84
+ };
85
+ }
86
+ next();
87
+ };
88
+ }
@@ -19,7 +19,10 @@ import {
19
19
  createScanFilter,
20
20
  } from "../build/generate-route-types.js";
21
21
  import { firstCodeMatchIndex } from "../build/route-types/source-scan.js";
22
- import { injectClientDebugFlag } from "./inject-client-debug.js";
22
+ import {
23
+ injectClientDebugFlag,
24
+ internalDebugNoCacheMiddleware,
25
+ } from "./inject-client-debug.js";
23
26
  import { createVersionPlugin } from "./plugins/version-plugin.js";
24
27
  import { createVirtualStubPlugin } from "./plugins/virtual-stub-plugin.js";
25
28
  import {
@@ -392,6 +395,11 @@ export function createRouterDiscoveryPlugin(
392
395
  if ((globalThis as any).__rscRouterDiscoveryActive) return;
393
396
  s.devServer = server;
394
397
 
398
+ // Serve the internal-debug module no-cache: consumers resolve it into
399
+ // node_modules, where dev's immutable `?v=` caching pinned browsers to a
400
+ // stale baked INTERNAL_RANGO_DEBUG. See internalDebugNoCacheMiddleware.
401
+ server.middlewares.use(internalDebugNoCacheMiddleware());
402
+
395
403
  // Discovery promise that the handler can await if requests arrive
396
404
  // before discovery completes
397
405
  let resolveDiscovery: () => void;