@rangojs/router 0.0.0-experimental.88a3b2f7 → 0.0.0-experimental.8bcfea43

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 (102) hide show
  1. package/README.md +50 -20
  2. package/dist/vite/index.js +647 -176
  3. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  4. package/package.json +7 -5
  5. package/skills/breadcrumbs/SKILL.md +3 -1
  6. package/skills/handler-use/SKILL.md +362 -0
  7. package/skills/hooks/SKILL.md +28 -20
  8. package/skills/intercept/SKILL.md +20 -0
  9. package/skills/layout/SKILL.md +22 -0
  10. package/skills/links/SKILL.md +88 -16
  11. package/skills/loader/SKILL.md +35 -2
  12. package/skills/middleware/SKILL.md +32 -3
  13. package/skills/migrate-nextjs/SKILL.md +560 -0
  14. package/skills/migrate-react-router/SKILL.md +765 -0
  15. package/skills/parallel/SKILL.md +59 -0
  16. package/skills/rango/SKILL.md +24 -22
  17. package/skills/response-routes/SKILL.md +8 -0
  18. package/skills/route/SKILL.md +24 -0
  19. package/skills/streams-and-websockets/SKILL.md +283 -0
  20. package/skills/typesafety/SKILL.md +3 -1
  21. package/src/browser/app-shell.ts +52 -0
  22. package/src/browser/navigation-bridge.ts +72 -4
  23. package/src/browser/navigation-client.ts +64 -13
  24. package/src/browser/navigation-store.ts +25 -1
  25. package/src/browser/partial-update.ts +34 -3
  26. package/src/browser/prefetch/cache.ts +129 -21
  27. package/src/browser/prefetch/fetch.ts +148 -16
  28. package/src/browser/prefetch/queue.ts +36 -5
  29. package/src/browser/rango-state.ts +53 -13
  30. package/src/browser/react/Link.tsx +30 -2
  31. package/src/browser/react/NavigationProvider.tsx +50 -11
  32. package/src/browser/react/use-navigation.ts +22 -2
  33. package/src/browser/react/use-params.ts +11 -1
  34. package/src/browser/react/use-router.ts +8 -1
  35. package/src/browser/rsc-router.tsx +34 -6
  36. package/src/browser/segment-reconciler.ts +36 -14
  37. package/src/browser/types.ts +13 -0
  38. package/src/build/route-trie.ts +50 -24
  39. package/src/cache/cf/cf-cache-store.ts +5 -7
  40. package/src/client.tsx +84 -230
  41. package/src/index.rsc.ts +3 -0
  42. package/src/index.ts +44 -9
  43. package/src/outlet-context.ts +1 -1
  44. package/src/response-utils.ts +28 -0
  45. package/src/reverse.ts +7 -3
  46. package/src/route-definition/dsl-helpers.ts +180 -24
  47. package/src/route-definition/helpers-types.ts +61 -14
  48. package/src/route-definition/resolve-handler-use.ts +6 -0
  49. package/src/route-types.ts +7 -0
  50. package/src/router/handler-context.ts +24 -4
  51. package/src/router/lazy-includes.ts +6 -6
  52. package/src/router/loader-resolution.ts +73 -46
  53. package/src/router/manifest.ts +22 -13
  54. package/src/router/match-api.ts +3 -3
  55. package/src/router/match-middleware/cache-lookup.ts +10 -5
  56. package/src/router/match-middleware/segment-resolution.ts +1 -1
  57. package/src/router/match-result.ts +82 -4
  58. package/src/router/middleware-types.ts +2 -22
  59. package/src/router/middleware.ts +32 -4
  60. package/src/router/pattern-matching.ts +60 -9
  61. package/src/router/segment-resolution/fresh.ts +52 -0
  62. package/src/router/segment-resolution/revalidation.ts +69 -1
  63. package/src/router/trie-matching.ts +10 -4
  64. package/src/router/url-params.ts +49 -0
  65. package/src/router.ts +1 -2
  66. package/src/rsc/handler.ts +21 -9
  67. package/src/rsc/helpers.ts +69 -41
  68. package/src/rsc/loader-fetch.ts +23 -3
  69. package/src/rsc/progressive-enhancement.ts +12 -2
  70. package/src/rsc/response-route-handler.ts +14 -1
  71. package/src/rsc/rsc-rendering.ts +12 -1
  72. package/src/rsc/server-action.ts +8 -0
  73. package/src/rsc/types.ts +1 -0
  74. package/src/segment-content-promise.ts +67 -0
  75. package/src/segment-loader-promise.ts +122 -0
  76. package/src/segment-system.tsx +11 -61
  77. package/src/server/context.ts +26 -3
  78. package/src/server/handle-store.ts +19 -0
  79. package/src/server/request-context.ts +64 -56
  80. package/src/types/handler-context.ts +2 -34
  81. package/src/types/loader-types.ts +5 -6
  82. package/src/types/request-scope.ts +126 -0
  83. package/src/types/route-entry.ts +11 -0
  84. package/src/types/segments.ts +1 -1
  85. package/src/urls/include-helper.ts +24 -14
  86. package/src/urls/path-helper-types.ts +34 -5
  87. package/src/urls/response-types.ts +2 -10
  88. package/src/use-loader.tsx +77 -5
  89. package/src/vite/debug.ts +55 -0
  90. package/src/vite/discovery/prerender-collection.ts +124 -83
  91. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  92. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  93. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  94. package/src/vite/plugins/expose-id-utils.ts +12 -0
  95. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  96. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  97. package/src/vite/plugins/performance-tracks.ts +4 -6
  98. package/src/vite/rango.ts +49 -14
  99. package/src/vite/router-discovery.ts +186 -26
  100. package/src/vite/utils/banner.ts +1 -1
  101. package/src/vite/utils/package-resolution.ts +41 -1
  102. package/src/vite/utils/prerender-utils.ts +20 -6
@@ -10,7 +10,7 @@ import type { Plugin } from "vite";
10
10
  import { createServer as createViteServer } from "vite";
11
11
  import { resolve } from "node:path";
12
12
  import { readFileSync } from "node:fs";
13
- import { createRequire } from "node:module";
13
+ import { createRequire, register } from "node:module";
14
14
  import { pathToFileURL } from "node:url";
15
15
  import {
16
16
  formatNestedRouterConflictError,
@@ -19,6 +19,10 @@ import {
19
19
  } from "../build/generate-route-types.js";
20
20
  import { createVersionPlugin } from "./plugins/version-plugin.js";
21
21
  import { createVirtualStubPlugin } from "./plugins/virtual-stub-plugin.js";
22
+ import {
23
+ BUILD_ENV_GLOBAL_KEY,
24
+ createCloudflareProtocolStubPlugin,
25
+ } from "./plugins/cloudflare-protocol-stub.js";
22
26
  import {
23
27
  exposeInternalIds,
24
28
  exposeRouterId,
@@ -44,9 +48,56 @@ import {
44
48
  } from "./discovery/virtual-module-codegen.js";
45
49
  import { postprocessBundle } from "./discovery/bundle-postprocess.js";
46
50
  import { resetStagedBuildAssets } from "./utils/prerender-utils.js";
51
+ import { createRangoDebugger, timed } from "./debug.js";
52
+
53
+ const debugDiscovery = createRangoDebugger("rango:discovery");
54
+ const debugRoutes = createRangoDebugger("rango:routes");
47
55
 
48
56
  export { VIRTUAL_ROUTES_MANIFEST_ID };
49
57
 
58
+ // ============================================================================
59
+ // Node ESM Loader Hook Registration
60
+ // ============================================================================
61
+
62
+ /**
63
+ * Registers a Node ESM loader hook that resolves `cloudflare:*` specifiers
64
+ * to a data: URL stub. Defense-in-depth alongside the Vite transform in
65
+ * `cloudflare-protocol-stub.ts`:
66
+ *
67
+ * - The Vite transform catches `cloudflare:*` imports in modules that flow
68
+ * through Vite's plugin pipeline. That's the vast majority of cases.
69
+ * - The Node loader catches imports in modules that Vite/Rollup externalize
70
+ * (e.g. the `partyserver` package, which has a top-level
71
+ * `import { DurableObject, env } from "cloudflare:workers"` and ships
72
+ * shapes plugin-rsc marks as external). Externalized modules are loaded
73
+ * via Node's native ESM loader, which rejects URL schemes.
74
+ *
75
+ * Registration is process-global and one-shot. The hook only intercepts
76
+ * `cloudflare:*` specifiers; everything else passes through via
77
+ * `nextResolve()`. It runs in a separate worker thread (Node ESM loader
78
+ * architecture), so it can't read the `globalThis[BUILD_ENV_GLOBAL_KEY]`
79
+ * bridge that the Vite transform uses — the stubs served here always
80
+ * return `env = {}`. That's fine because externalized libraries don't
81
+ * typically access `env` at module top level; user source (where real
82
+ * `env` matters at build time) flows through the Vite transform.
83
+ */
84
+ let loaderHookRegistered = false;
85
+ function ensureCloudflareProtocolLoaderRegistered(): void {
86
+ if (loaderHookRegistered) return;
87
+ loaderHookRegistered = true;
88
+ try {
89
+ register(
90
+ new URL("./plugins/cloudflare-protocol-loader-hook.mjs", import.meta.url),
91
+ );
92
+ } catch (err: any) {
93
+ // register() requires Node 18.19+ / 20.6+. Older Node still has the
94
+ // Vite transform as primary defense.
95
+ console.warn(
96
+ `[rsc-router] Could not register Node ESM loader hook for cloudflare:* imports (${err?.message ?? err}). Falling back to Vite transform only.`,
97
+ );
98
+ }
99
+ }
100
+
50
101
  // ============================================================================
51
102
  // Temp Server Factory
52
103
  // ============================================================================
@@ -66,6 +117,11 @@ async function createTempRscServer(
66
117
  state: DiscoveryState,
67
118
  options: { forceBuild?: boolean; cacheDir?: string } = {},
68
119
  ) {
120
+ // Install the Node ESM loader hook before any module evaluation so
121
+ // `cloudflare:*` specifiers in externalized/loader-delegated modules
122
+ // (e.g. packages plugin-rsc marks as external) resolve to stubs
123
+ // instead of crashing Node's native loader.
124
+ ensureCloudflareProtocolLoaderRegistered();
69
125
  const { default: rsc } = await import("@vitejs/plugin-rsc");
70
126
  return createViteServer({
71
127
  root: state.projectRoot,
@@ -88,6 +144,7 @@ async function createTempRscServer(
88
144
  ...(options.forceBuild ? [hashClientRefs(state.projectRoot)] : []),
89
145
  createVersionPlugin(),
90
146
  createVirtualStubPlugin(),
147
+ createCloudflareProtocolStubPlugin(),
91
148
  // Dev prerender must use dev-mode IDs (path-based) to match the workerd
92
149
  // runtime. forceBuild produces hashed IDs for production bundle consistency.
93
150
  exposeInternalIds(options.forceBuild ? { forceBuild: true } : undefined),
@@ -177,6 +234,11 @@ async function acquireBuildEnv(
177
234
 
178
235
  s.resolvedBuildEnv = result.env;
179
236
  s.buildEnvDispose = result.dispose ?? null;
237
+ // Bridge the resolved env into `cloudflare:workers`'s stubbed `env`
238
+ // export so user code that does `import { env } from "cloudflare:workers"`
239
+ // sees the real bindings proxy during discovery + prerender instead of
240
+ // an empty object. The stub reads this global at module-evaluation time.
241
+ (globalThis as Record<string, unknown>)[BUILD_ENV_GLOBAL_KEY] = result.env;
180
242
  return true;
181
243
  }
182
244
 
@@ -193,6 +255,7 @@ async function releaseBuildEnv(s: DiscoveryState): Promise<void> {
193
255
  s.buildEnvDispose = null;
194
256
  }
195
257
  s.resolvedBuildEnv = undefined;
258
+ delete (globalThis as Record<string, unknown>)[BUILD_ENV_GLOBAL_KEY];
196
259
  }
197
260
 
198
261
  /**
@@ -344,23 +407,35 @@ export function createRouterDiscoveryPlugin(
344
407
  }
345
408
 
346
409
  const discover = async () => {
410
+ const discoverStart = performance.now();
347
411
  const rscEnv = (server.environments as any)?.rsc;
348
412
  if (!rscEnv?.runner) {
349
413
  // Cloudflare dev: no module runner available (workerd-based RSC env).
350
414
  // Set devServerOrigin so the virtual module can inject __PRERENDER_DEV_URL
351
415
  // for on-demand prerender via the /__rsc_prerender endpoint.
416
+ debugDiscovery?.("dev: no rsc runner (cloudflare path)");
352
417
  s.devServerOrigin = getDevServerOrigin();
353
418
 
354
419
  // Create a temp Node.js server to run runtime discovery and generate
355
420
  // named route types (static parser can't resolve factory calls).
356
421
  try {
357
422
  // Acquire build-time env bindings for dev prerender
358
- await acquireBuildEnv(s, viteCommand, viteMode);
423
+ await timed(debugDiscovery, "acquireBuildEnv", () =>
424
+ acquireBuildEnv(s, viteCommand, viteMode),
425
+ );
359
426
 
360
- const tempRscEnv = await getOrCreateTempServer();
427
+ const tempRscEnv = await timed(
428
+ debugDiscovery,
429
+ "getOrCreateTempServer",
430
+ () => getOrCreateTempServer(),
431
+ );
361
432
  if (tempRscEnv) {
362
- await discoverRouters(s, tempRscEnv);
363
- writeRouteTypesFiles(s);
433
+ await timed(debugDiscovery, "discoverRouters (cloudflare)", () =>
434
+ discoverRouters(s, tempRscEnv),
435
+ );
436
+ timed(debugDiscovery, "writeRouteTypesFiles", () =>
437
+ writeRouteTypesFiles(s),
438
+ );
364
439
  }
365
440
  } catch (err: any) {
366
441
  console.warn(
@@ -368,24 +443,35 @@ export function createRouterDiscoveryPlugin(
368
443
  );
369
444
  }
370
445
 
446
+ debugDiscovery?.(
447
+ "dev discovery done (%sms)",
448
+ (performance.now() - discoverStart).toFixed(1),
449
+ );
371
450
  resolveDiscovery!();
372
451
  return;
373
452
  }
374
453
 
375
454
  try {
376
455
  // Acquire build-time env bindings for dev prerender (Node.js path)
377
- await acquireBuildEnv(s, viteCommand, viteMode);
456
+ debugDiscovery?.("dev: node path start");
457
+ await timed(debugDiscovery, "acquireBuildEnv", () =>
458
+ acquireBuildEnv(s, viteCommand, viteMode),
459
+ );
378
460
 
379
461
  // Set the readiness gate BEFORE discovery so early requests
380
462
  // block until manifest is populated
381
- const serverMod = await rscEnv.runner.import(
382
- "@rangojs/router/server",
463
+ const serverMod = await timed(
464
+ debugDiscovery,
465
+ "import @rangojs/router/server",
466
+ () => rscEnv.runner.import("@rangojs/router/server"),
383
467
  );
384
468
  if (serverMod?.setManifestReadyPromise) {
385
469
  serverMod.setManifestReadyPromise(discoveryPromise);
386
470
  }
387
471
 
388
- await discoverRouters(s, rscEnv);
472
+ await timed(debugDiscovery, "discoverRouters", () =>
473
+ discoverRouters(s, rscEnv),
474
+ );
389
475
 
390
476
  // Store server origin for dev prerender endpoint (virtual module injection)
391
477
  s.devServerOrigin = getDevServerOrigin();
@@ -395,15 +481,23 @@ export function createRouterDiscoveryPlugin(
395
481
  // routes (e.g. Array.from loops) that the static parser cannot see.
396
482
  // writeRouteTypesFiles() only writes when content changes, so this
397
483
  // won't cause unnecessary HMR triggers.
398
- writeRouteTypesFiles(s);
484
+ timed(debugDiscovery, "writeRouteTypesFiles", () =>
485
+ writeRouteTypesFiles(s),
486
+ );
399
487
 
400
488
  // Populate the route map and per-router data in the RSC env
401
- await propagateDiscoveryState(rscEnv);
489
+ await timed(debugDiscovery, "propagateDiscoveryState", () =>
490
+ propagateDiscoveryState(rscEnv),
491
+ );
402
492
  } catch (err: any) {
403
493
  console.warn(
404
494
  `[rsc-router] Router discovery failed: ${err.message}\n${err.stack}`,
405
495
  );
406
496
  } finally {
497
+ debugDiscovery?.(
498
+ "dev discovery done (%sms)",
499
+ (performance.now() - discoverStart).toFixed(1),
500
+ );
407
501
  resolveDiscovery!();
408
502
  }
409
503
  };
@@ -480,9 +574,31 @@ export function createRouterDiscoveryPlugin(
480
574
  return;
481
575
  }
482
576
 
483
- // Prefer the main server's registry (Node.js preset: module runner available).
484
- // Fall back to a temp server for Cloudflare where the main RSC env uses workerd.
485
- let registry = mainRegistry;
577
+ // Import the user's entry module to force re-evaluation of any
578
+ // HMR-invalidated modules in the chain (entry router urls handlers).
579
+ // This ensures createRouter() re-runs with updated handler code before
580
+ // we read RouterRegistry. Without this, edits to prerender handler files
581
+ // produce stale content because the old router instance remains registered.
582
+ const rscEnv = (server.environments as any)?.rsc;
583
+ let registry: Map<string, any> | null = null;
584
+ if (rscEnv?.runner && s.resolvedEntryPath) {
585
+ try {
586
+ await rscEnv.runner.import(s.resolvedEntryPath);
587
+ const serverMod = await rscEnv.runner.import(
588
+ "@rangojs/router/server",
589
+ );
590
+ registry = serverMod.RouterRegistry ?? null;
591
+ } catch (err: any) {
592
+ console.warn(
593
+ `[rsc-router] Dev prerender module refresh failed: ${err.message}`,
594
+ );
595
+ res.statusCode = 500;
596
+ res.end(`Prerender handler error: ${err.message}`);
597
+ return;
598
+ }
599
+ } else {
600
+ registry = mainRegistry;
601
+ }
486
602
 
487
603
  if (!registry) {
488
604
  // No main registry: the RSC env has no module runner (Cloudflare dev).
@@ -591,15 +707,26 @@ export function createRouterDiscoveryPlugin(
591
707
  const rscEnv = (server.environments as any)?.rsc;
592
708
  if (!rscEnv?.runner || runtimeRediscoveryInProgress) return;
593
709
  runtimeRediscoveryInProgress = true;
710
+ const hmrStart = performance.now();
594
711
  try {
595
- await discoverRouters(s, rscEnv);
596
- writeRouteTypesFiles(s);
597
- await propagateDiscoveryState(rscEnv);
712
+ await timed(debugDiscovery, "hmr discoverRouters", () =>
713
+ discoverRouters(s, rscEnv),
714
+ );
715
+ timed(debugDiscovery, "hmr writeRouteTypesFiles", () =>
716
+ writeRouteTypesFiles(s),
717
+ );
718
+ await timed(debugDiscovery, "hmr propagateDiscoveryState", () =>
719
+ propagateDiscoveryState(rscEnv),
720
+ );
598
721
  } catch (err: any) {
599
722
  console.warn(
600
723
  `[rsc-router] Runtime re-discovery failed: ${err.message}`,
601
724
  );
602
725
  } finally {
726
+ debugDiscovery?.(
727
+ "hmr re-discovery done (%sms)",
728
+ (performance.now() - hmrStart).toFixed(1),
729
+ );
603
730
  runtimeRediscoveryInProgress = false;
604
731
  }
605
732
  };
@@ -697,12 +824,16 @@ export function createRouterDiscoveryPlugin(
697
824
  if (!s.isBuildMode) return;
698
825
  // Only run once across environment builds
699
826
  if (s.mergedRouteManifest !== null) return;
827
+ const buildStartTime = performance.now();
828
+ debugDiscovery?.("build: start");
700
829
  resetStagedBuildAssets(s.projectRoot);
701
830
  s.prerenderManifestEntries = null;
702
831
  s.staticManifestEntries = null;
703
832
 
704
833
  // Acquire build-time env bindings if configured
705
- await acquireBuildEnv(s, viteCommand, viteMode);
834
+ await timed(debugDiscovery, "build acquireBuildEnv", () =>
835
+ acquireBuildEnv(s, viteCommand, viteMode),
836
+ );
706
837
 
707
838
  let tempServer: any = null;
708
839
  // Signal to user-space code (e.g. reverse.ts) that build-time discovery
@@ -711,7 +842,11 @@ export function createRouterDiscoveryPlugin(
711
842
  // between the vite plugin and user code loaded via runner.import().
712
843
  (globalThis as any).__rscRouterDiscoveryActive = true;
713
844
  try {
714
- tempServer = await createTempRscServer(s, { forceBuild: true });
845
+ tempServer = await timed(
846
+ debugDiscovery,
847
+ "build createTempRscServer",
848
+ () => createTempRscServer(s, { forceBuild: true }),
849
+ );
715
850
 
716
851
  const rscEnv = (tempServer.environments as any)?.rsc;
717
852
  if (!rscEnv?.runner) {
@@ -731,11 +866,15 @@ export function createRouterDiscoveryPlugin(
731
866
  s.resolvedStaticModules = tempIdsPlugin.api.staticHandlerModules;
732
867
  }
733
868
 
734
- await discoverRouters(s, rscEnv);
869
+ await timed(debugDiscovery, "build discoverRouters", () =>
870
+ discoverRouters(s, rscEnv),
871
+ );
735
872
  // Update named-routes.gen.ts from runtime discovery.
736
873
  // The runtime manifest includes dynamically generated routes
737
874
  // that the static parser cannot extract from source code.
738
- writeRouteTypesFiles(s);
875
+ timed(debugDiscovery, "build writeRouteTypesFiles", () =>
876
+ writeRouteTypesFiles(s),
877
+ );
739
878
  } catch (err: any) {
740
879
  // Extract the user source file from the stack trace (skip internal frames)
741
880
  const sourceFile = err.stack
@@ -760,9 +899,15 @@ export function createRouterDiscoveryPlugin(
760
899
  } finally {
761
900
  delete (globalThis as any).__rscRouterDiscoveryActive;
762
901
  if (tempServer) {
763
- await tempServer.close();
902
+ await timed(debugDiscovery, "build tempServer.close", () =>
903
+ tempServer.close(),
904
+ );
764
905
  }
765
906
  await releaseBuildEnv(s);
907
+ debugDiscovery?.(
908
+ "build discovery done (%sms)",
909
+ (performance.now() - buildStartTime).toFixed(1),
910
+ );
766
911
  }
767
912
  },
768
913
 
@@ -786,19 +931,34 @@ export function createRouterDiscoveryPlugin(
786
931
  // This is critical for Cloudflare dev where the worker runs in a separate
787
932
  // Miniflare process and can only receive manifest data via the virtual module.
788
933
  if (s.discoveryDone) {
789
- await s.discoveryDone;
934
+ await timed(debugRoutes, "await discoveryDone (manifest)", () =>
935
+ Promise.resolve(s.discoveryDone),
936
+ );
790
937
  }
791
- return generateRoutesManifestModule(s);
938
+ const code = await timed(
939
+ debugRoutes,
940
+ "generateRoutesManifestModule",
941
+ () => generateRoutesManifestModule(s),
942
+ );
943
+ debugRoutes?.("manifest module emitted (%d bytes)", code?.length ?? 0);
944
+ return code;
792
945
  }
793
946
  // Per-router virtual modules: pure data exports (no side effects).
794
947
  // ensureRouterManifest() imports the module and stores the data.
795
948
  const perRouterPrefix = "\0" + VIRTUAL_ROUTES_MANIFEST_ID + "/";
796
949
  if (id.startsWith(perRouterPrefix)) {
797
950
  if (s.discoveryDone) {
798
- await s.discoveryDone;
951
+ await timed(debugRoutes, "await discoveryDone (per-router)", () =>
952
+ Promise.resolve(s.discoveryDone),
953
+ );
799
954
  }
800
955
  const routerId = id.slice(perRouterPrefix.length);
801
- return generatePerRouterModule(s, routerId);
956
+ const code = await timed(
957
+ debugRoutes,
958
+ `generatePerRouterModule ${routerId}`,
959
+ () => generatePerRouterModule(s, routerId),
960
+ );
961
+ return code;
802
962
  }
803
963
  // virtual:rsc-router/prerender-paths load handler removed
804
964
  return null;
@@ -1,4 +1,4 @@
1
- import packageJson from "../../../package.json" with { type: "json" };
1
+ import packageJson from "../../../package.json";
2
2
 
3
3
  export const rangoVersion: string = packageJson.version;
4
4
 
@@ -6,8 +6,11 @@
6
6
  */
7
7
 
8
8
  import { existsSync } from "node:fs";
9
+ import { createRequire } from "node:module";
9
10
  import { resolve } from "node:path";
10
- import packageJson from "../../../package.json" with { type: "json" };
11
+ import packageJson from "../../../package.json";
12
+
13
+ const require = createRequire(import.meta.url);
11
14
 
12
15
  /**
13
16
  * The canonical name used in virtual entries (without scope)
@@ -119,3 +122,40 @@ export function getPackageAliases(): Record<string, string> {
119
122
 
120
123
  return aliases;
121
124
  }
125
+
126
+ /**
127
+ * Plugin-rsc pushes bare specs like
128
+ * `@vitejs/plugin-rsc/vendor/react-server-dom/client.edge` into
129
+ * `optimizeDeps.include` for the ssr and rsc environments. In strict pnpm
130
+ * consumer apps, `@vitejs/plugin-rsc` is only reachable from @rangojs/router's
131
+ * node_modules, so Vite's optimizer — which resolves from the project root —
132
+ * can't find them and emits "Failed to resolve dependency" warnings.
133
+ *
134
+ * We resolve those specs from this plugin's location (where plugin-rsc is
135
+ * guaranteed to be installed as our dep) and expose them as `resolve.alias`
136
+ * entries. The optimizer's resolver honors aliases, so the bare specs map to
137
+ * absolute paths and resolve cleanly.
138
+ */
139
+ export function getVendorAliases(): Record<string, string> {
140
+ // client.browser is intentionally NOT aliased. plugin-rsc injects it into
141
+ // the client env's optimizeDeps.include; Vite's manual-include path resolves
142
+ // and pre-bundles regardless of optimizeDeps.exclude, so aliasing would
143
+ // trigger esbuild pre-bundling of the CJS vendor file and bypass the
144
+ // cjs-to-esm transform that patches `require('react'|'react-dom')` into
145
+ // real ESM imports. The consumer may still see a single "Failed to resolve"
146
+ // warning for client.browser; runtime resolution from plugin-rsc's own
147
+ // importer works because Vite resolves relative to the importer (not root).
148
+ const specs = [
149
+ "@vitejs/plugin-rsc/vendor/react-server-dom/client.edge",
150
+ "@vitejs/plugin-rsc/vendor/react-server-dom/server.edge",
151
+ ];
152
+ const aliases: Record<string, string> = {};
153
+ for (const spec of specs) {
154
+ try {
155
+ aliases[spec] = require.resolve(spec);
156
+ } catch {
157
+ // Spec unresolvable (unexpected but non-fatal — Vite will warn as before).
158
+ }
159
+ }
160
+ return aliases;
161
+ }
@@ -41,14 +41,28 @@ export function substituteRouteParams(
41
41
  let result = pattern;
42
42
  let hadOmittedOptional = false;
43
43
 
44
- // First pass: substitute provided params
44
+ // First pass: substitute provided params.
45
+ // Empty string on an optional placeholder is treated as omitted (the trie
46
+ // matcher fills unmatched optionals with "" — letting the second pass
47
+ // strip them keeps slash cleanup consistent). Empty string on required
48
+ // `:key` or wildcard `*key` still substitutes, matching prior behaviour.
45
49
  for (const [key, value] of Object.entries(params)) {
46
50
  const escaped = escapeRegExp(key);
47
- result = result.replace(
48
- new RegExp(`:${escaped}(\\([^)]*\\))?\\??`),
49
- encode(value),
50
- );
51
- result = result.replace(`*${key}`, encode(value));
51
+ if (value === "") {
52
+ // Only replace required placeholders (negative lookahead for `?`);
53
+ // leave `:key?` for the second pass.
54
+ result = result.replace(
55
+ new RegExp(`:${escaped}(\\([^)]*\\))?(?!\\?)`),
56
+ "",
57
+ );
58
+ result = result.replace(`*${key}`, "");
59
+ } else {
60
+ result = result.replace(
61
+ new RegExp(`:${escaped}(\\([^)]*\\))?\\??`),
62
+ encode(value),
63
+ );
64
+ result = result.replace(`*${key}`, encode(value));
65
+ }
52
66
  }
53
67
 
54
68
  // Second pass: strip remaining optional param placeholders not in params