@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.
Files changed (66) hide show
  1. package/README.md +50 -20
  2. package/dist/vite/index.js +353 -49
  3. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  4. package/package.json +5 -3
  5. package/skills/breadcrumbs/SKILL.md +3 -1
  6. package/skills/hooks/SKILL.md +28 -20
  7. package/skills/links/SKILL.md +88 -16
  8. package/skills/loader/SKILL.md +35 -2
  9. package/skills/migrate-react-router/SKILL.md +1 -0
  10. package/skills/response-routes/SKILL.md +8 -0
  11. package/skills/streams-and-websockets/SKILL.md +283 -0
  12. package/skills/typesafety/SKILL.md +3 -1
  13. package/src/browser/app-shell.ts +52 -0
  14. package/src/browser/navigation-bridge.ts +51 -2
  15. package/src/browser/navigation-client.ts +33 -10
  16. package/src/browser/navigation-store.ts +25 -1
  17. package/src/browser/partial-update.ts +20 -1
  18. package/src/browser/prefetch/cache.ts +124 -26
  19. package/src/browser/prefetch/fetch.ts +114 -38
  20. package/src/browser/prefetch/queue.ts +36 -5
  21. package/src/browser/rango-state.ts +53 -13
  22. package/src/browser/react/Link.tsx +18 -13
  23. package/src/browser/react/NavigationProvider.tsx +50 -11
  24. package/src/browser/react/use-navigation.ts +30 -11
  25. package/src/browser/react/use-params.ts +11 -1
  26. package/src/browser/react/use-router.ts +8 -1
  27. package/src/browser/rsc-router.tsx +34 -6
  28. package/src/browser/types.ts +13 -0
  29. package/src/cache/cf/cf-cache-store.ts +5 -7
  30. package/src/index.rsc.ts +3 -0
  31. package/src/index.ts +3 -0
  32. package/src/outlet-context.ts +1 -1
  33. package/src/response-utils.ts +28 -0
  34. package/src/reverse.ts +3 -2
  35. package/src/route-definition/dsl-helpers.ts +16 -3
  36. package/src/route-definition/resolve-handler-use.ts +6 -0
  37. package/src/router/handler-context.ts +20 -3
  38. package/src/router/lazy-includes.ts +1 -1
  39. package/src/router/loader-resolution.ts +3 -0
  40. package/src/router/match-api.ts +3 -3
  41. package/src/router/middleware-types.ts +2 -22
  42. package/src/router/middleware.ts +32 -4
  43. package/src/router/pattern-matching.ts +60 -9
  44. package/src/router/trie-matching.ts +10 -4
  45. package/src/router/url-params.ts +49 -0
  46. package/src/router.ts +1 -2
  47. package/src/rsc/handler.ts +8 -4
  48. package/src/rsc/helpers.ts +69 -41
  49. package/src/rsc/progressive-enhancement.ts +2 -0
  50. package/src/rsc/response-route-handler.ts +14 -1
  51. package/src/rsc/rsc-rendering.ts +7 -0
  52. package/src/rsc/server-action.ts +2 -0
  53. package/src/server/request-context.ts +10 -42
  54. package/src/types/handler-context.ts +2 -34
  55. package/src/types/loader-types.ts +5 -6
  56. package/src/types/request-scope.ts +126 -0
  57. package/src/urls/response-types.ts +2 -10
  58. package/src/vite/debug.ts +86 -0
  59. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  60. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  61. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  62. package/src/vite/plugins/performance-tracks.ts +4 -6
  63. package/src/vite/rango.ts +49 -14
  64. package/src/vite/router-discovery.ts +161 -23
  65. package/src/vite/utils/banner.ts +1 -1
  66. package/src/vite/utils/package-resolution.ts +41 -1
@@ -0,0 +1,214 @@
1
+ import type { Plugin } from "vite";
2
+
3
+ const VIRTUAL_PREFIX = "virtual:rango-cloudflare-stub-";
4
+ const NULL_PREFIX = "\0" + VIRTUAL_PREFIX;
5
+ const CF_PREFIX = "cloudflare:";
6
+
7
+ /**
8
+ * `globalThis` key the `cloudflare:workers` stub reads to populate its
9
+ * `env` export. Router discovery sets this to the resolved `buildEnv`
10
+ * proxy (from `wrangler.getPlatformProxy()` when `buildEnv: "auto"` is
11
+ * configured, or a user-supplied object otherwise) before importing the
12
+ * worker entry, and clears it after discovery disposes the proxy. When
13
+ * unset, the stub's `env` falls back to `{}`.
14
+ *
15
+ * Using `globalThis` is the only cross-module bridge that works here:
16
+ * the stub's `load` hook returns source text, not a live closure, but
17
+ * the stub module is evaluated in the same Node process as the
18
+ * discovery plugin — so reading a global at module-evaluation time
19
+ * reaches whatever the plugin assigned there. A symbol key would be
20
+ * cleaner in-process but awkward to name from the stub source.
21
+ *
22
+ * @internal
23
+ */
24
+ export const BUILD_ENV_GLOBAL_KEY = "__rango_build_env__";
25
+
26
+ const SOURCE_EXT_RE = /\.[mc]?[jt]sx?$/;
27
+
28
+ const IMPORT_NODE_TYPES = new Set([
29
+ "ImportDeclaration",
30
+ "ImportExpression",
31
+ "ExportNamedDeclaration",
32
+ "ExportAllDeclaration",
33
+ ]);
34
+
35
+ // Keep in sync with `STUBS` in cloudflare-protocol-loader-hook.mjs —
36
+ // both paths (Vite transform and Node loader) need to hand out the same
37
+ // classes. Unknown `cloudflare:*` modules fall back to an empty default
38
+ // export so third-party packages (e.g. the Cloudflare Agents SDK) can
39
+ // pull them into the graph without crashing discovery. Discovery only
40
+ // evaluates module top-level code — no handlers run — so missing named
41
+ // exports only fail if something does `class X extends Missing {}` at
42
+ // module scope, which is rare outside the already-stubbed classes.
43
+ const STUBS: Record<string, string> = {
44
+ "cloudflare:workers": `
45
+ export class DurableObject { constructor(_ctx, _env) {} }
46
+ export class WorkerEntrypoint { constructor(_ctx, _env) {} }
47
+ export class WorkflowEntrypoint { constructor(_ctx, _env) {} }
48
+ export class RpcTarget {}
49
+ export const env = globalThis[${JSON.stringify(BUILD_ENV_GLOBAL_KEY)}] ?? {};
50
+ export default {};
51
+ `,
52
+ "cloudflare:email": `
53
+ export class EmailMessage { constructor(_from, _to, _raw) {} }
54
+ export default {};
55
+ `,
56
+ "cloudflare:sockets": `
57
+ export function connect() { return {}; }
58
+ export default {};
59
+ `,
60
+ "cloudflare:workflows": `
61
+ export class NonRetryableError extends Error {
62
+ constructor(message, name) { super(message); this.name = name ?? "NonRetryableError"; }
63
+ }
64
+ export default {};
65
+ `,
66
+ };
67
+
68
+ // Policy: unknown `cloudflare:*` specifiers resolve permissively (empty
69
+ // default export) rather than throwing. We prioritize dependency-graph
70
+ // resilience over strict validation of user imports because third-party
71
+ // packages can pull `cloudflare:*` modules we haven't curated, and
72
+ // discovery should not fail just because those modules appear in the graph.
73
+ // Tradeoff: unsupported user-authored `cloudflare:*` imports may fail later
74
+ // with a generic JS/module error instead of a tailored rango-branded hint.
75
+ // The test below pins this behavior so dependency compatibility is not
76
+ // regressed accidentally.
77
+ const FALLBACK_STUB = `export default {};\n`;
78
+
79
+ interface AstNode {
80
+ type: string;
81
+ start?: number;
82
+ end?: number;
83
+ source?: AstNode | null;
84
+ value?: unknown;
85
+ [key: string]: unknown;
86
+ }
87
+
88
+ /**
89
+ * Stubs `cloudflare:*` imports for the discovery-time Node Vite server.
90
+ *
91
+ * Discovery only evaluates user module top-level code — it never invokes
92
+ * DurableObject / WorkerEntrypoint / Workflow handlers — so empty base
93
+ * classes are enough for `class X extends DurableObject {}` declarations
94
+ * to load in Node, where `cloudflare:*` is otherwise unresolvable.
95
+ *
96
+ * Interception point: a transform hook parses source with Rollup's
97
+ * plugin-context parser (`this.parse`) and rewrites only real import
98
+ * specifier spans (`import ... from "cloudflare:xxx"`,
99
+ * `import("cloudflare:xxx")`, `export ... from "cloudflare:xxx"`) to a
100
+ * plain virtual module name (`virtual:rango-cloudflare-stub-xxx`).
101
+ * This must be done in transform because Vite's module runner routes
102
+ * URL-scheme specifiers straight to Node's native ESM loader without
103
+ * consulting plugin `resolveId` hooks. Using the AST (instead of a
104
+ * text regex or a permissive lexer) guarantees that strings,
105
+ * comments, and template literals that merely contain import-like
106
+ * text are never mutated — the walker only looks at the four import
107
+ * node types.
108
+ *
109
+ * The transform runs on user source AND on compiled node_modules
110
+ * output: real-world CF packages (e.g. the Cloudflare Agents SDK)
111
+ * ship compiled JS that contains `import ... from "cloudflare:email"`
112
+ * and similar, so excluding node_modules would leave those imports
113
+ * unrewritten. Cost is small because the early exit (`code.includes`)
114
+ * skips files with no cloudflare: mention.
115
+ *
116
+ * The plugin intentionally runs at Vite's default ordering (no
117
+ * `enforce: "pre"`) so TS/JSX has already been compiled to plain JS
118
+ * by the time `this.parse` runs — acorn doesn't understand
119
+ * non-standard syntax.
120
+ *
121
+ * `cloudflare:workers`, `cloudflare:email`, `cloudflare:sockets`, and
122
+ * `cloudflare:workflows` each get curated stubs with the well-known
123
+ * symbols that appear in top-level `extends` positions. Any other
124
+ * `cloudflare:*` specifier falls back to an empty default export —
125
+ * discovery never executes the handlers, so an empty module is safe
126
+ * for anything the graph pulls in transitively.
127
+ *
128
+ * Only registered in the discovery temp server, not the user's runtime
129
+ * config.
130
+ * @internal
131
+ */
132
+ export function createCloudflareProtocolStubPlugin(): Plugin {
133
+ return {
134
+ name: "@rangojs/router:cloudflare-protocol-stub",
135
+ transform(code, id) {
136
+ const cleanId = id.split("?")[0] ?? id;
137
+ if (!SOURCE_EXT_RE.test(cleanId)) return null;
138
+ if (!code.includes(CF_PREFIX)) return null;
139
+
140
+ let ast: AstNode;
141
+ try {
142
+ ast = this.parse(code) as unknown as AstNode;
143
+ } catch {
144
+ // Malformed source — let a downstream plugin surface the parse error.
145
+ return null;
146
+ }
147
+
148
+ const hits: Array<{ start: number; end: number; value: string }> = [];
149
+ walk(ast, (node) => {
150
+ if (!IMPORT_NODE_TYPES.has(node.type)) return;
151
+ const source = node.source;
152
+ if (!source || source.type !== "Literal") return;
153
+ if (typeof source.value !== "string") return;
154
+ if (!source.value.startsWith(CF_PREFIX)) return;
155
+ if (typeof source.start !== "number" || typeof source.end !== "number")
156
+ return;
157
+ hits.push({
158
+ start: source.start,
159
+ end: source.end,
160
+ value: source.value,
161
+ });
162
+ });
163
+
164
+ if (hits.length === 0) return null;
165
+
166
+ // Rewrite from last to first so earlier offsets stay valid. `start`/
167
+ // `end` span the full literal including quotes, so we re-emit the
168
+ // same quote character around the new specifier.
169
+ hits.sort((a, b) => b.start - a.start);
170
+ let out = code;
171
+ for (const hit of hits) {
172
+ const submodule = hit.value.slice(CF_PREFIX.length);
173
+ const quote = code[hit.start] === "'" ? "'" : '"';
174
+ out =
175
+ out.slice(0, hit.start) +
176
+ quote +
177
+ VIRTUAL_PREFIX +
178
+ submodule +
179
+ quote +
180
+ out.slice(hit.end);
181
+ }
182
+ return { code: out, map: null };
183
+ },
184
+ resolveId(id) {
185
+ if (id.startsWith(VIRTUAL_PREFIX)) {
186
+ return "\0" + id;
187
+ }
188
+ return null;
189
+ },
190
+ load(id) {
191
+ if (!id.startsWith(NULL_PREFIX)) return null;
192
+ const submodule = id.slice(NULL_PREFIX.length);
193
+ const specifier = CF_PREFIX + submodule;
194
+ return STUBS[specifier] ?? FALLBACK_STUB;
195
+ },
196
+ };
197
+ }
198
+
199
+ function walk(node: unknown, visit: (n: AstNode) => void): void {
200
+ if (!node || typeof node !== "object") return;
201
+ if (Array.isArray(node)) {
202
+ for (const child of node) walk(child, visit);
203
+ return;
204
+ }
205
+ const n = node as AstNode;
206
+ if (typeof n.type !== "string") return;
207
+ visit(n);
208
+ for (const key in n) {
209
+ if (key === "loc" || key === "start" || key === "end" || key === "range") {
210
+ continue;
211
+ }
212
+ walk(n[key], visit);
213
+ }
214
+ }
@@ -14,6 +14,9 @@
14
14
 
15
15
  import type { Plugin } from "vite";
16
16
  import { readFile } from "node:fs/promises";
17
+ import { createRangoDebugger } from "../debug.js";
18
+
19
+ const debug = createRangoDebugger("rango:transform");
17
20
 
18
21
  const RSDW_PATCH_RE =
19
22
  /((?:var|let|const)\s+\w+\s*=\s*root\._children\s*,\s*(\w+)\s*=\s*root\._debugInfo\s*[;,])/;
@@ -76,12 +79,7 @@ export function performanceTracksPlugin(): Plugin {
76
79
  if (!id.includes("react-server-dom") || !id.includes("client")) return;
77
80
  const patched = patchRsdwClientDebugInfoRecovery(code);
78
81
  if (!patched.debugInfoVar) return;
79
- if (process.env.INTERNAL_RANGO_DEBUG)
80
- console.log(
81
- "[perf-tracks] patched RSDW client (var:",
82
- patched.debugInfoVar,
83
- ")",
84
- );
82
+ debug?.("patched RSDW client (var: %s)", patched.debugInfoVar);
85
83
  return patched.code;
86
84
  },
87
85
  };
package/src/vite/rango.ts CHANGED
@@ -12,6 +12,8 @@ import { VIRTUAL_IDS } from "./plugins/virtual-entries.js";
12
12
  import {
13
13
  getExcludeDeps,
14
14
  getPackageAliases,
15
+ getPublishedPackageName,
16
+ getVendorAliases,
15
17
  } from "./utils/package-resolution.js";
16
18
  import { findRouterFiles } from "../build/generate-route-types.js";
17
19
  import { createVersionPlugin } from "./plugins/version-plugin.js";
@@ -27,6 +29,9 @@ import { createVersionInjectorPlugin } from "./plugins/version-injector.js";
27
29
  import { createCjsToEsmPlugin } from "./plugins/cjs-to-esm.js";
28
30
  import { createRouterDiscoveryPlugin } from "./router-discovery.js";
29
31
  import { performanceTracksPlugin } from "./plugins/performance-tracks.js";
32
+ import { createRangoDebugger } from "./debug.js";
33
+
34
+ const debugConfig = createRangoDebugger("rango:config");
30
35
 
31
36
  /**
32
37
  * Vite plugin for @rangojs/router.
@@ -53,25 +58,41 @@ import { performanceTracksPlugin } from "./plugins/performance-tracks.js";
53
58
  * ```
54
59
  */
55
60
  export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
61
+ const rangoStart = performance.now();
56
62
  const resolvedOptions: RangoOptions = options ?? { preset: "node" };
57
63
  const preset = resolvedOptions.preset ?? "node";
58
64
  const showBanner = resolvedOptions.banner ?? true;
65
+ debugConfig?.("rango(%s) setup start", preset);
59
66
 
60
67
  const plugins: PluginOption[] = [];
61
68
 
62
- // Get package resolution info (workspace vs npm install)
63
- const rangoAliases = getPackageAliases();
69
+ // Get package resolution info (workspace vs npm install).
70
+ // Vendor aliases redirect the bare plugin-rsc vendor specs (which plugin-rsc
71
+ // itself injects into optimizeDeps.include) to absolute paths resolved from
72
+ // this package — so strict-pnpm consumers don't hit "Failed to resolve
73
+ // dependency" warnings when those deps aren't hoisted to their app root.
74
+ const rangoAliases = { ...getPackageAliases(), ...getVendorAliases() };
64
75
  const excludeDeps = [
65
76
  ...getExcludeDeps(),
66
- // The public browser entry re-exports the RSDW browser client.
67
- // Excluding both keeps Vite from freezing the unpatched bundle into
68
- // .vite/deps before our source transforms run.
77
+ // plugin-rsc itself injects these into the client env's
78
+ // optimizeDeps.include, which overrides exclude for the dep's own
79
+ // pre-bundle entry. What exclude still controls is how *other*
80
+ // pre-bundled deps treat imports of these specs (external vs inlined)
81
+ // via esbuildCjsExternalPlugin. The cjs-to-esm transform in
82
+ // plugins/cjs-to-esm.ts is the fallback for strict-pnpm consumers,
83
+ // where client.browser's bare include fails to resolve and Vite ends up
84
+ // serving the raw CJS file at dev-serve time.
69
85
  "@vitejs/plugin-rsc/browser",
70
- // Keep the browser RSDW client out of Vite's dep optimizer so our
71
- // cjs-to-esm transform can patch the real file.
72
86
  "@vitejs/plugin-rsc/vendor/react-server-dom/client.browser",
73
87
  ];
74
88
 
89
+ // Vite supports a nested `A > B` syntax in optimizeDeps.include that resolves
90
+ // B from A's location. We anchor transitive deps (rsc-html-stream,
91
+ // @vitejs/plugin-rsc/vendor/*) to @rangojs/router so pnpm consumers — where
92
+ // these aren't visible at the app root — can still pre-bundle them.
93
+ const pkg = getPublishedPackageName();
94
+ const nested = (spec: string) => `${pkg} > ${spec}`;
95
+
75
96
  // Mutable ref for router path (node preset only).
76
97
  // Set immediately when user-specified, or populated by the auto-discover
77
98
  // config() hook using Vite's resolved root.
@@ -126,7 +147,7 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
126
147
  // Pre-bundle rsc-html-stream to prevent discovery during first request
127
148
  // Exclude rsc-router modules to ensure same Context instance
128
149
  optimizeDeps: {
129
- include: ["rsc-html-stream/client"],
150
+ include: [nested("rsc-html-stream/client")],
130
151
  exclude: excludeDeps,
131
152
  esbuildOptions: sharedEsbuildOptions,
132
153
  },
@@ -151,8 +172,10 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
151
172
  "react-dom/static.edge",
152
173
  "react/jsx-runtime",
153
174
  "react/jsx-dev-runtime",
154
- "rsc-html-stream/server",
155
- "@vitejs/plugin-rsc/vendor/react-server-dom/client.edge",
175
+ nested("rsc-html-stream/server"),
176
+ nested(
177
+ "@vitejs/plugin-rsc/vendor/react-server-dom/client.edge",
178
+ ),
156
179
  ],
157
180
  exclude: excludeDeps,
158
181
  esbuildOptions: sharedEsbuildOptions,
@@ -167,7 +190,9 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
167
190
  "react",
168
191
  "react/jsx-runtime",
169
192
  "react/jsx-dev-runtime",
170
- "@vitejs/plugin-rsc/vendor/react-server-dom/server.edge",
193
+ nested(
194
+ "@vitejs/plugin-rsc/vendor/react-server-dom/server.edge",
195
+ ),
171
196
  ],
172
197
  exclude: excludeDeps,
173
198
  esbuildOptions: sharedEsbuildOptions,
@@ -280,7 +305,7 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
280
305
  "react-dom",
281
306
  "react/jsx-runtime",
282
307
  "react/jsx-dev-runtime",
283
- "rsc-html-stream/client",
308
+ nested("rsc-html-stream/client"),
284
309
  ],
285
310
  exclude: excludeDeps,
286
311
  esbuildOptions: sharedEsbuildOptions,
@@ -297,7 +322,9 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
297
322
  "react-dom/static.edge",
298
323
  "react/jsx-runtime",
299
324
  "react/jsx-dev-runtime",
300
- "@vitejs/plugin-rsc/vendor/react-server-dom/client.edge",
325
+ nested(
326
+ "@vitejs/plugin-rsc/vendor/react-server-dom/client.edge",
327
+ ),
301
328
  ],
302
329
  exclude: excludeDeps,
303
330
  esbuildOptions: sharedEsbuildOptions,
@@ -310,7 +337,9 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
310
337
  "react",
311
338
  "react/jsx-runtime",
312
339
  "react/jsx-dev-runtime",
313
- "@vitejs/plugin-rsc/vendor/react-server-dom/server.edge",
340
+ nested(
341
+ "@vitejs/plugin-rsc/vendor/react-server-dom/server.edge",
342
+ ),
314
343
  ],
315
344
  esbuildOptions: sharedEsbuildOptions,
316
345
  },
@@ -458,5 +487,11 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
458
487
  }),
459
488
  );
460
489
 
490
+ debugConfig?.(
491
+ "rango(%s) setup done: %d plugin(s) (%sms)",
492
+ preset,
493
+ plugins.length,
494
+ (performance.now() - rangoStart).toFixed(1),
495
+ );
461
496
  return plugins;
462
497
  }