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

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 (38) hide show
  1. package/dist/vite/index.js +23 -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 +34 -10
  11. package/skills/rango/SKILL.md +10 -0
  12. package/skills/use-cache/SKILL.md +12 -2
  13. package/src/browser/partial-update.ts +7 -0
  14. package/src/cache/cache-key-utils.ts +29 -0
  15. package/src/cache/cache-scope.ts +2 -17
  16. package/src/cache/cache-tag.ts +60 -14
  17. package/src/cache/cf/cf-cache-store.ts +54 -20
  18. package/src/cache/document-cache.ts +17 -11
  19. package/src/cache/vercel/vercel-cache-store.ts +9 -19
  20. package/src/redirect-origin.ts +14 -0
  21. package/src/route-map-builder.ts +17 -3
  22. package/src/router/lazy-includes.ts +8 -2
  23. package/src/router/loader-resolution.ts +14 -2
  24. package/src/router/match-handlers.ts +11 -6
  25. package/src/router/middleware.ts +4 -1
  26. package/src/router/telemetry.ts +9 -1
  27. package/src/router.ts +7 -8
  28. package/src/rsc/handler.ts +9 -2
  29. package/src/rsc/redirect-guard.ts +2 -1
  30. package/src/rsc/rsc-rendering.ts +35 -2
  31. package/src/rsc/shell-capture.ts +93 -20
  32. package/src/server/context.ts +47 -9
  33. package/src/server/cookie-store.ts +26 -5
  34. package/src/server/request-context.ts +22 -0
  35. package/src/ssr/index.tsx +145 -107
  36. package/src/testing/dispatch.ts +7 -0
  37. package/src/vite/inject-client-debug.ts +64 -12
  38. package/src/vite/router-discovery.ts +9 -1
package/src/ssr/index.tsx CHANGED
@@ -247,11 +247,22 @@ async function readStreamToUint8Array(
247
247
  const reader = stream.getReader();
248
248
  const chunks: Uint8Array[] = [];
249
249
  let total = 0;
250
- while (true) {
251
- const { done, value } = await reader.read();
252
- if (done) break;
253
- chunks.push(value);
254
- total += value.length;
250
+ try {
251
+ while (true) {
252
+ const { done, value } = await reader.read();
253
+ if (done) break;
254
+ chunks.push(value);
255
+ total += value.length;
256
+ }
257
+ } catch (error) {
258
+ // Mid-read abort path (documented): the prelude stream errors with our
259
+ // abort reason while we are still reading it. Cancel the source before
260
+ // rethrowing so it is not left uncancelled; releaseLock always runs in
261
+ // finally. Mirrors src/rsc/rsc-rendering.ts's serve-side reader cleanup.
262
+ reader.cancel(error).catch(() => {});
263
+ throw error;
264
+ } finally {
265
+ reader.releaseLock();
255
266
  }
256
267
  const out = new Uint8Array(total);
257
268
  let offset = 0;
@@ -434,113 +445,140 @@ export function createShellCaptureHandler<TEnv = unknown>(
434
445
  ): Promise<ShellCaptureResult | null> {
435
446
  const maxWaitMs = opts.maxWaitMs ?? DEFAULT_SHELL_CAPTURE_MAX_WAIT_MS;
436
447
 
437
- // No nonce (nonce'd requests never reach capture); no formState.
438
- const SsrRoot = createSsrRootComponent({
439
- createFromReadableStream,
440
- rscStream,
441
- });
442
-
443
- const bootstrapScriptContent = await loadBootstrapScriptContent();
444
-
445
- // Start prerender first, then run the abort schedule concurrently. When
446
- // holes are pending, prerender's promise settles only after abort(); when
447
- // the shell completes with no holes it settles on its own and the later
448
- // abort() is a harmless no-op (the DATA variant).
449
- const controller = new AbortController();
450
- const prerenderPromise = prerender(<SsrRoot />, {
451
- signal: controller.signal,
452
- bootstrapScriptContent,
453
- // Abort is how capture WORKS: once the shell is quiet we abort() to freeze
454
- // the prelude and let the still-pending holes postpone. React reports the
455
- // abort reason for each pending boundary through onError. Without an onError
456
- // here React falls back to console.error, so every capture that still has a
457
- // live hole at abort time (the normal case, and every cold-module capture
458
- // where the shell is not yet done) dumps a DOMException [AbortError] stack —
459
- // once per pending boundary. That is EXPECTED degradation, not a failure, so
460
- // swallow the abort here. Genuine shell render errors (a component throwing)
461
- // are NOT the abort and still surface through the deps.onError channel, the
462
- // same one renderHTML uses. See docs/design/ppr-shell-resume.md.
463
- onError: (error: unknown) => {
464
- if (
465
- controller.signal.aborted &&
466
- (error as { name?: string } | null)?.name === "AbortError"
467
- ) {
468
- return;
469
- }
470
- reportRenderError(onError, error);
471
- },
472
- });
473
- // Pre-attach a no-op catch: the real await sits AFTER quiesce + the
474
- // post-quiesce hops, so an early prerender rejection (e.g. a bake-lane
475
- // loader tripping the identity guard within milliseconds) would otherwise
476
- // spend several turns handler-less and crash the worker as an unhandled
477
- // rejection. The actual rejection handling still happens at the await
478
- // below; this parallel handler only keeps the gap crash-free.
479
- prerenderPromise.catch(() => {});
480
-
481
- // Wait for the caller's quiesce signal. By the time it resolves the Flight
482
- // input is byte-quiet and FROZEN by the capture gate (shell-capture.ts
483
- // gateFlightForCapture), so there is no wall-clock debounce here — maxWaitMs
484
- // is only the pathological guard for a shell that never goes quiet (a root
485
- // postpone / hung handle), and should never fire in tests.
486
- const timer = createCancelableTimeout(maxWaitMs);
487
- try {
488
- await Promise.race([opts.quiesce, timer.promise]);
489
- } finally {
490
- timer.cancel();
491
- }
492
- // Fixed task hops before the abort: give React's fizz worker turns to flush
493
- // the now-complete shell and mark the still-pending boundaries as POSTPONED
494
- // rather than errored. Deterministic (the byte set is already frozen), so a
495
- // fixed count of turns suffices — no wall-clock.
496
- for (let i = 0; i < POST_QUIESCE_TASK_HOPS; i++) {
497
- await macrotask();
498
- }
499
- controller.abort();
500
-
501
- // A hard prerender rejection (fatal shell error) propagates. Expected
502
- // degradation surfaces three ways and all return null: a trivial prelude
503
- // (sanity gate below), the prerender REJECTING with an AbortError, or the
504
- // prelude STREAM erroring with the abort reason mid-read — both abort
505
- // shapes happen when our own abort lands before the shell completed (seen
506
- // on dev cold paths, where module transform / first-render latency
507
- // outlasts flight quiesce; a later request re-captures against warm
508
- // modules and succeeds).
509
- let prelude: Uint8Array;
510
- let postponed: unknown;
448
+ // Arm the maxWaitMs deadline BEFORE the first await so it bounds the ENTIRE
449
+ // capture, the bootstrap-script load included. loadBootstrapScriptContent()
450
+ // used to run before the timer, so a hung/slow bootstrap load hung
451
+ // captureShellHTML with no upper bound and held the background capture task
452
+ // open. One deadline, shared by the bootstrap race below and the quiesce
453
+ // race, keeps the whole path "bounded by maxWaitMs like every quiesce input".
454
+ const deadline = createCancelableTimeout(maxWaitMs);
511
455
  try {
512
- const result = await prerenderPromise;
513
- prelude = await readStreamToUint8Array(result.prelude);
514
- postponed = result.postponed;
515
- } catch (error) {
516
- // Name-based match: the rejection is a DOMException on workerd/Node,
517
- // which is not an Error subclass there, so instanceof Error would let
518
- // the abort escape as a spurious reported error.
519
- if (
520
- controller.signal.aborted &&
521
- (error as { name?: string } | null)?.name === "AbortError"
522
- ) {
456
+ // No nonce (nonce'd requests never reach capture); no formState.
457
+ const SsrRoot = createSsrRootComponent({
458
+ createFromReadableStream,
459
+ rscStream,
460
+ });
461
+
462
+ // Bootstrap load raced against the deadline. A load that never resolves
463
+ // within maxWaitMs is the same bounded no-shell degrade as a shell that
464
+ // never goes quiet: return null, do not hang. A load that REJECTS is a
465
+ // genuine error and still propagates (it is not the deadline). `null` is
466
+ // the deadline sentinel — disjoint from the load's `Promise<string>`, so
467
+ // the race narrows to `string | null` with no wrapper. The no-op catch
468
+ // keeps a late rejection off the unhandledRejection path when the deadline
469
+ // already won; a rejection that lands first still propagates out.
470
+ const load = loadBootstrapScriptContent();
471
+ load.catch(() => {});
472
+ const bootstrapScriptContent = await Promise.race([
473
+ load,
474
+ deadline.promise.then(() => null),
475
+ ]);
476
+ if (bootstrapScriptContent === null) {
523
477
  return null;
524
478
  }
525
- throw error;
526
- }
527
479
 
528
- // Sanity gate: a prelude with no `<body` is the no-shell failure mode.
529
- // Return null and store nothing; the request falls back to axis 1 and a
530
- // later request re-captures. The dominant real-world cause is a loader
531
- // route WITHOUT a route-level loading() boundary: renderSegments' loading-
532
- // less branch awaits loader data at TREE-BUILD, so the masked loader pins
533
- // the whole tree above <body> (root postpone). Root-postponing layouts and
534
- // hung handles degrade the same way. shell-capture.ts logs a once-per-key
535
- // warning so the eternal-MISS shape is diagnosable.
536
- if (!new TextDecoder().decode(prelude).includes("<body")) {
537
- return null;
538
- }
480
+ // Start prerender first, then run the abort schedule concurrently. When
481
+ // holes are pending, prerender's promise settles only after abort(); when
482
+ // the shell completes with no holes it settles on its own and the later
483
+ // abort() is a harmless no-op (the DATA variant).
484
+ const controller = new AbortController();
485
+ // Private reason object: the deliberate abort is identified by object
486
+ // IDENTITY in both the onError below and the post-await catch. React
487
+ // propagates this EXACT object to onError for every still-pending boundary
488
+ // (verified identity-preserving), and rejects/errors the prelude with it.
489
+ const abortReason = { rangoShellCaptureAbort: true };
490
+ const prerenderPromise = prerender(<SsrRoot />, {
491
+ signal: controller.signal,
492
+ bootstrapScriptContent,
493
+ // Abort is how capture WORKS: once the shell is quiet we abort() to
494
+ // freeze the prelude and let the still-pending holes postpone. React
495
+ // reports the abort reason for each pending boundary through onError.
496
+ // Without an onError here React falls back to console.error, so every
497
+ // capture that still has a live hole at abort time (the normal case)
498
+ // dumps a stack once per pending boundary. That is EXPECTED degradation,
499
+ // so swallow OUR abort — matched by IDENTITY (error === abortReason).
500
+ // Discriminate by identity, NOT error.name: capture aborts before
501
+ // awaiting, so signal.aborted is unconditionally true and a name check
502
+ // swallowed genuine AbortError-named throws (a component's own
503
+ // fetch/AbortController cancellation) as if they were our abort. Genuine
504
+ // render errors are NOT our sentinel and still surface through
505
+ // deps.onError, the same channel renderHTML uses. See
506
+ // docs/design/ppr-shell-resume.md.
507
+ onError: (error: unknown) => {
508
+ if (error === abortReason) {
509
+ return;
510
+ }
511
+ reportRenderError(onError, error);
512
+ },
513
+ });
514
+ // Pre-attach a no-op catch: the real await sits AFTER quiesce + the
515
+ // post-quiesce hops, so an early prerender rejection (e.g. a bake-lane
516
+ // loader tripping the identity guard within milliseconds) would otherwise
517
+ // spend several turns handler-less and crash the worker as an unhandled
518
+ // rejection. The actual rejection handling still happens at the await
519
+ // below; this parallel handler only keeps the gap crash-free.
520
+ prerenderPromise.catch(() => {});
521
+
522
+ // Wait for the caller's quiesce signal, bounded by the SAME deadline. By
523
+ // the time it resolves the Flight input is byte-quiet and FROZEN by the
524
+ // capture gate (shell-capture.ts gateFlightForCapture), so there is no
525
+ // wall-clock debounce here — maxWaitMs is only the pathological guard for a
526
+ // shell that never goes quiet (a root postpone / hung handle).
527
+ await Promise.race([opts.quiesce, deadline.promise]);
528
+ // Fixed task hops before the abort: give React's fizz worker turns to flush
529
+ // the now-complete shell and mark the still-pending boundaries as POSTPONED
530
+ // rather than errored. Deterministic (the byte set is already frozen), so a
531
+ // fixed count of turns suffices — no wall-clock.
532
+ for (let i = 0; i < POST_QUIESCE_TASK_HOPS; i++) {
533
+ await macrotask();
534
+ }
535
+ controller.abort(abortReason);
536
+
537
+ // A hard prerender rejection (fatal shell error) propagates. Expected
538
+ // degradation surfaces three ways and all return null: a trivial prelude
539
+ // (sanity gate below), the prerender REJECTING with our abort reason, or
540
+ // the prelude STREAM erroring with our abort reason mid-read — both abort
541
+ // shapes happen when our own abort lands before the shell completed (seen
542
+ // on dev cold paths, where module transform / first-render latency
543
+ // outlasts flight quiesce; a later request re-captures against warm
544
+ // modules and succeeds).
545
+ let prelude: Uint8Array;
546
+ let postponed: unknown;
547
+ try {
548
+ const result = await prerenderPromise;
549
+ prelude = await readStreamToUint8Array(result.prelude);
550
+ postponed = result.postponed;
551
+ } catch (error) {
552
+ // Identity match: swallow ONLY our own deliberate abort
553
+ // (error === abortReason). Not error.name — capture aborts before this
554
+ // await, so signal.aborted is always true, and a name check let a
555
+ // genuine AbortError-named throw masquerade as our abort and degrade
556
+ // into a retryable no-shell, hiding real failures from reportCacheError.
557
+ if (error === abortReason) {
558
+ return null;
559
+ }
560
+ throw error;
561
+ }
562
+
563
+ // Sanity gate: a prelude with no `<body` is the no-shell failure mode.
564
+ // Return null and store nothing; the request falls back to axis 1 and a
565
+ // later request re-captures. The dominant real-world cause is a loader
566
+ // route WITHOUT a route-level loading() boundary: renderSegments' loading-
567
+ // less branch awaits loader data at TREE-BUILD, so the masked loader pins
568
+ // the whole tree above <body> (root postpone). Root-postponing layouts and
569
+ // hung handles degrade the same way. shell-capture.ts logs a once-per-key
570
+ // warning so the eternal-MISS shape is diagnosable.
571
+ if (!new TextDecoder().decode(prelude).includes("<body")) {
572
+ return null;
573
+ }
539
574
 
540
- return {
541
- prelude,
542
- postponed: postponed == null ? null : JSON.stringify(postponed),
543
- };
575
+ return {
576
+ prelude,
577
+ postponed: postponed == null ? null : JSON.stringify(postponed),
578
+ };
579
+ } finally {
580
+ deadline.cancel();
581
+ }
544
582
  };
545
583
  }
546
584
 
@@ -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), {
@@ -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;