@rangojs/router 0.0.0-experimental.103 → 0.0.0-experimental.104

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 CHANGED
@@ -943,9 +943,9 @@ import { createHostRouter } from "@rangojs/router/host";
943
943
 
944
944
  const hostRouter = createHostRouter();
945
945
 
946
- hostRouter.host(["*.localhost"]).map(() => import("./apps/admin/handler.js"));
947
- hostRouter.host(["localhost"]).map(() => import("./apps/site/handler.js"));
948
- hostRouter.fallback().map(() => import("./apps/site/handler.js"));
946
+ hostRouter.host(["*.localhost"]).lazy(() => import("./apps/admin/handler.js"));
947
+ hostRouter.host(["localhost"]).lazy(() => import("./apps/site/handler.js"));
948
+ hostRouter.fallback().lazy(() => import("./apps/site/handler.js"));
949
949
 
950
950
  export default {
951
951
  async fetch(request, env, ctx) {
@@ -954,7 +954,7 @@ export default {
954
954
  };
955
955
  ```
956
956
 
957
- Each sub-app has its own `createRouter()` and `urls()`. The host router lazily imports the matched app's handler. Patterns are matched in registration order — register more specific patterns (subdomains) before catch-alls.
957
+ Use `.lazy(() => import("./sub-app"))` to mount a lazily-imported sub-app (a module whose `default` export is a handler or nested host router), and `.map((request) => Response)` for an inline request handler. Only `.lazy()` mounts are imported during build-time discovery; `.map(() => import(...))` is a type error. Each sub-app has its own `createRouter()` and `urls()`. Patterns are matched in registration order — register more specific patterns (subdomains) before catch-alls.
958
958
 
959
959
  ## Meta Tags
960
960
 
@@ -2040,7 +2040,7 @@ import { resolve } from "node:path";
2040
2040
  // package.json
2041
2041
  var package_default = {
2042
2042
  name: "@rangojs/router",
2043
- version: "0.0.0-experimental.103",
2043
+ version: "0.0.0-experimental.104",
2044
2044
  description: "Django-inspired RSC router with composable URL patterns",
2045
2045
  keywords: [
2046
2046
  "react",
@@ -2205,7 +2205,8 @@ var package_default = {
2205
2205
  peerDependencies: {
2206
2206
  "@cloudflare/vite-plugin": "^1.25.0",
2207
2207
  "@vitejs/plugin-rsc": "^0.5.23",
2208
- react: "^18.0.0 || ^19.0.0",
2208
+ react: ">=19.2.6 <20",
2209
+ "react-dom": ">=19.2.6 <20",
2209
2210
  vite: "^7.3.0"
2210
2211
  },
2211
2212
  peerDependenciesMeta: {
@@ -3777,6 +3778,8 @@ function createDiscoveryState(entryPath, opts) {
3777
3778
  projectRoot: "",
3778
3779
  isBuildMode: false,
3779
3780
  userResolveAlias: void 0,
3781
+ userRunnerConfig: void 0,
3782
+ userResolvePlugins: [],
3780
3783
  scanFilter: void 0,
3781
3784
  cachedRouterFiles: void 0,
3782
3785
  opts,
@@ -4433,6 +4436,80 @@ async function renderStaticHandlers(state, rscEnv, registry) {
4433
4436
  );
4434
4437
  }
4435
4438
 
4439
+ // src/vite/discovery/discovery-errors.ts
4440
+ function indent(text, pad) {
4441
+ return text.split("\n").map((line) => line.length > 0 ? pad + line : line).join("\n");
4442
+ }
4443
+ async function invokeLazyMount(loader, context, errors) {
4444
+ try {
4445
+ await loader();
4446
+ } catch (error) {
4447
+ errors.push({ context, error });
4448
+ }
4449
+ }
4450
+ function isLazyMount(route) {
4451
+ return !!route && route.kind === "lazy" && typeof route.handler === "function";
4452
+ }
4453
+ async function resolveHostRouterHandlers(hostRegistry) {
4454
+ const errors = [];
4455
+ for (const [hostId, entry] of hostRegistry) {
4456
+ for (const route of entry.routes) {
4457
+ if (isLazyMount(route)) {
4458
+ await invokeLazyMount(
4459
+ route.handler,
4460
+ `host "${hostId}" route handler`,
4461
+ errors
4462
+ );
4463
+ }
4464
+ }
4465
+ if (isLazyMount(entry.fallback)) {
4466
+ await invokeLazyMount(
4467
+ entry.fallback.handler,
4468
+ `host "${hostId}" fallback handler`,
4469
+ errors
4470
+ );
4471
+ }
4472
+ }
4473
+ return errors;
4474
+ }
4475
+ function formatNoRoutersError(entryPath, errors) {
4476
+ const base = `[rsc-router] No routers found in registry after importing ${entryPath}`;
4477
+ if (errors.length === 0) {
4478
+ return base;
4479
+ }
4480
+ const formatted = errors.map(({ context, error }) => {
4481
+ const err = error instanceof Error ? error : new Error(String(error));
4482
+ const detail = err.stack ?? err.message;
4483
+ return ` - while resolving ${context}:
4484
+ ${indent(detail, " ")}`;
4485
+ }).join("\n");
4486
+ return `${base}
4487
+
4488
+ ${errors.length} error(s) were caught during host-router discovery and likely explain why no routers were registered:
4489
+ ${formatted}`;
4490
+ }
4491
+ function toCause(errors) {
4492
+ if (errors.length === 0) return void 0;
4493
+ if (errors.length === 1) return errors[0].error;
4494
+ return new AggregateError(
4495
+ errors.map((e) => e.error),
4496
+ "Multiple host-router handlers failed during discovery"
4497
+ );
4498
+ }
4499
+ var DiscoveryError = class _DiscoveryError extends Error {
4500
+ constructor(entryPath, caught) {
4501
+ super(formatNoRoutersError(entryPath, caught));
4502
+ const cause = toCause(caught);
4503
+ if (cause !== void 0) {
4504
+ this.cause = cause;
4505
+ }
4506
+ this.name = "DiscoveryError";
4507
+ this.entryPath = entryPath;
4508
+ this.caught = caught;
4509
+ Object.setPrototypeOf(this, _DiscoveryError.prototype);
4510
+ }
4511
+ };
4512
+
4436
4513
  // src/vite/discovery/discover-routers.ts
4437
4514
  var debug10 = createRangoDebugger(NS.discovery);
4438
4515
  async function discoverRouters(state, rscEnv) {
@@ -4449,27 +4526,17 @@ async function discoverRouters(state, rscEnv) {
4449
4526
  );
4450
4527
  let registry = serverMod.RouterRegistry;
4451
4528
  if (!registry || registry.size === 0) {
4529
+ const discoveryErrors = [];
4452
4530
  try {
4453
4531
  const hostRegistry = serverMod.HostRouterRegistry;
4454
4532
  if (hostRegistry && hostRegistry.size > 0) {
4455
4533
  console.log(
4456
4534
  `[rsc-router] Found ${hostRegistry.size} host router(s), resolving lazy handlers...`
4457
4535
  );
4458
- for (const [, entry] of hostRegistry) {
4459
- for (const route of entry.routes) {
4460
- if (typeof route.handler === "function") {
4461
- try {
4462
- await route.handler();
4463
- } catch {
4464
- }
4465
- }
4466
- }
4467
- if (entry.fallback && typeof entry.fallback.handler === "function") {
4468
- try {
4469
- await entry.fallback.handler();
4470
- } catch {
4471
- }
4472
- }
4536
+ const handlerErrors = await resolveHostRouterHandlers(hostRegistry);
4537
+ discoveryErrors.push(...handlerErrors);
4538
+ for (const { context, error } of handlerErrors) {
4539
+ debug10?.("caught error while resolving %s: %O", context, error);
4473
4540
  }
4474
4541
  const freshServerMod = await rscEnv.runner.import(
4475
4542
  "@rangojs/router/server"
@@ -4480,12 +4547,11 @@ async function discoverRouters(state, rscEnv) {
4480
4547
  registry = freshRegistry;
4481
4548
  }
4482
4549
  }
4483
- } catch {
4550
+ } catch (error) {
4551
+ discoveryErrors.push({ context: "host-router discovery", error });
4484
4552
  }
4485
4553
  if (!registry || registry.size === 0) {
4486
- throw new Error(
4487
- `[rsc-router] No routers found in registry after importing ${state.resolvedEntryPath}`
4488
- );
4554
+ throw new DiscoveryError(state.resolvedEntryPath, discoveryErrors);
4489
4555
  }
4490
4556
  }
4491
4557
  const buildMod = await timed(
@@ -5162,6 +5228,52 @@ function createDiscoveryGate(s, debug11) {
5162
5228
  };
5163
5229
  }
5164
5230
 
5231
+ // src/vite/utils/forward-user-plugins.ts
5232
+ function isDenied(name) {
5233
+ return name.startsWith("vite:") || name === "rsc" || name.startsWith("rsc:") || name.startsWith("@rangojs/router") || name.startsWith("@cloudflare/vite-plugin") || name.startsWith("vite-plugin-cloudflare");
5234
+ }
5235
+ function hasResolutionHooks(p) {
5236
+ return Boolean(p.resolveId || p.load);
5237
+ }
5238
+ function stripToResolutionHooks(p) {
5239
+ const stripped = { name: p.name };
5240
+ if (p.enforce) stripped.enforce = p.enforce;
5241
+ if (p.applyToEnvironment)
5242
+ stripped.applyToEnvironment = p.applyToEnvironment;
5243
+ if (p.resolveId) stripped.resolveId = p.resolveId;
5244
+ if (p.load) stripped.load = p.load;
5245
+ return stripped;
5246
+ }
5247
+ function selectForwardableResolvePlugins(plugins) {
5248
+ if (!plugins) return [];
5249
+ const forwarded = [];
5250
+ for (const p of plugins) {
5251
+ const name = p?.name;
5252
+ if (!name || isDenied(name)) continue;
5253
+ if (!hasResolutionHooks(p)) continue;
5254
+ forwarded.push(stripToResolutionHooks(p));
5255
+ }
5256
+ return forwarded;
5257
+ }
5258
+ function pickForwardedRunnerConfig(config) {
5259
+ const r = config.resolve ?? {};
5260
+ const resolve10 = {};
5261
+ if (r.alias !== void 0) resolve10.alias = r.alias;
5262
+ if (r.dedupe !== void 0) resolve10.dedupe = r.dedupe;
5263
+ if (r.conditions !== void 0) resolve10.conditions = r.conditions;
5264
+ if (r.mainFields !== void 0) resolve10.mainFields = r.mainFields;
5265
+ if (r.extensions !== void 0) resolve10.extensions = r.extensions;
5266
+ if (r.preserveSymlinks !== void 0)
5267
+ resolve10.preserveSymlinks = r.preserveSymlinks;
5268
+ const userEsbuild = config.esbuild;
5269
+ const esbuild = userEsbuild && typeof userEsbuild === "object" ? { ...userEsbuild, jsx: "automatic", jsxImportSource: "react" } : { jsx: "automatic", jsxImportSource: "react" };
5270
+ return {
5271
+ resolve: resolve10,
5272
+ define: config.define,
5273
+ esbuild
5274
+ };
5275
+ }
5276
+
5165
5277
  // src/vite/router-discovery.ts
5166
5278
  var debugDiscovery = createRangoDebugger(NS.discovery);
5167
5279
  var debugRoutes = createRangoDebugger(NS.routes);
@@ -5184,14 +5296,23 @@ function ensureCloudflareProtocolLoaderRegistered() {
5184
5296
  async function createTempRscServer(state, options = {}) {
5185
5297
  ensureCloudflareProtocolLoaderRegistered();
5186
5298
  const { default: rsc } = await import("@vitejs/plugin-rsc");
5299
+ const runnerConfig = state.userRunnerConfig;
5300
+ const resolveConfig = runnerConfig?.resolve ?? {
5301
+ alias: state.userResolveAlias
5302
+ };
5303
+ const esbuildConfig = runnerConfig?.esbuild ?? {
5304
+ jsx: "automatic",
5305
+ jsxImportSource: "react"
5306
+ };
5187
5307
  return createViteServer({
5188
5308
  root: state.projectRoot,
5189
5309
  configFile: false,
5190
5310
  server: { middlewareMode: true },
5191
5311
  appType: "custom",
5192
5312
  logLevel: "silent",
5193
- resolve: { alias: state.userResolveAlias },
5194
- esbuild: { jsx: "automatic", jsxImportSource: "react" },
5313
+ resolve: resolveConfig,
5314
+ ...runnerConfig?.define ? { define: runnerConfig.define } : {},
5315
+ esbuild: esbuildConfig,
5195
5316
  ...options.cacheDir && { cacheDir: options.cacheDir },
5196
5317
  plugins: [
5197
5318
  rsc({
@@ -5209,7 +5330,11 @@ async function createTempRscServer(state, options = {}) {
5209
5330
  // Dev prerender must use dev-mode IDs (path-based) to match the workerd
5210
5331
  // runtime. forceBuild produces hashed IDs for production bundle consistency.
5211
5332
  exposeInternalIds(options.forceBuild ? { forceBuild: true } : void 0),
5212
- exposeRouterId()
5333
+ exposeRouterId(),
5334
+ // Forwarded user resolution plugins (e.g. vite-tsconfig-paths). Stripped
5335
+ // to resolveId/load and placed last so framework resolution runs first;
5336
+ // Vite re-sorts by `enforce`, so `enforce: "pre"` resolvers still lead.
5337
+ ...state.userResolvePlugins
5213
5338
  ]
5214
5339
  });
5215
5340
  }
@@ -5292,6 +5417,10 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
5292
5417
  viteCommand = config.command;
5293
5418
  viteMode = config.mode;
5294
5419
  s.userResolveAlias = config.resolve.alias;
5420
+ s.userRunnerConfig = pickForwardedRunnerConfig(config);
5421
+ s.userResolvePlugins = selectForwardableResolvePlugins(
5422
+ config.plugins
5423
+ );
5295
5424
  if (!s.resolvedEntryPath && opts?.routerPathRef?.path) {
5296
5425
  s.resolvedEntryPath = opts.routerPathRef.path;
5297
5426
  }
@@ -5987,7 +6116,8 @@ ${err.stack}` : null
5987
6116
  ].filter(Boolean).join("\n");
5988
6117
  throw new Error(
5989
6118
  `[rsc-router] Build-time router discovery failed:
5990
- ${details}`
6119
+ ${details}`,
6120
+ { cause: err }
5991
6121
  );
5992
6122
  } finally {
5993
6123
  delete globalThis.__rscRouterDiscoveryActive;
@@ -6213,7 +6343,15 @@ async function rango(options) {
6213
6343
  esbuildOptions: sharedEsbuildOptions
6214
6344
  },
6215
6345
  resolve: {
6216
- alias: rangoAliases
6346
+ alias: rangoAliases,
6347
+ // Force a single React/React-DOM copy across all three RSC
6348
+ // environments. RSC requires exactly one react/react-dom instance
6349
+ // per environment runtime; consumer install topologies (pnpm
6350
+ // strict layout, experimental React pins, third-party "use client"
6351
+ // packages) can otherwise resolve duplicate copies, causing
6352
+ // "Invalid hook call" / lost context. Child environments inherit
6353
+ // this root dedupe, and Vite merges it with any consumer dedupe.
6354
+ dedupe: ["react", "react-dom"]
6217
6355
  },
6218
6356
  build: {
6219
6357
  rollupOptions: { onwarn }
@@ -6240,10 +6378,6 @@ async function rango(options) {
6240
6378
  build: {
6241
6379
  outDir: "./dist/rsc/ssr"
6242
6380
  },
6243
- resolve: {
6244
- // Ensure single React instance in SSR child environment
6245
- dedupe: ["react", "react-dom"]
6246
- },
6247
6381
  // Pre-bundle SSR entry and React for proper module linking with childEnvironments
6248
6382
  // All deps must be listed to avoid late discovery triggering ERR_OUTDATED_OPTIMIZED_DEP
6249
6383
  optimizeDeps: {
@@ -6339,7 +6473,15 @@ ${list}`);
6339
6473
  rollupOptions: { onwarn }
6340
6474
  },
6341
6475
  resolve: {
6342
- alias: rangoAliases
6476
+ alias: rangoAliases,
6477
+ // Force a single React/React-DOM copy across all three RSC
6478
+ // environments. RSC requires exactly one react/react-dom instance
6479
+ // per environment runtime; consumer install topologies (pnpm
6480
+ // strict layout, experimental React pins, third-party "use client"
6481
+ // packages) can otherwise resolve duplicate copies, causing
6482
+ // "Invalid hook call" / lost context. Child environments inherit
6483
+ // this root dedupe, and Vite merges it with any consumer dedupe.
6484
+ dedupe: ["react", "react-dom"]
6343
6485
  },
6344
6486
  environments: {
6345
6487
  client: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.103",
3
+ "version": "0.0.0-experimental.104",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -165,7 +165,8 @@
165
165
  "peerDependencies": {
166
166
  "@cloudflare/vite-plugin": "^1.25.0",
167
167
  "@vitejs/plugin-rsc": "^0.5.23",
168
- "react": "^18.0.0 || ^19.0.0",
168
+ "react": ">=19.2.6 <20",
169
+ "react-dom": ">=19.2.6 <20",
169
170
  "vite": "^7.3.0"
170
171
  },
171
172
  "peerDependenciesMeta": {
@@ -22,9 +22,9 @@ import { createHostRouter } from "@rangojs/router/host";
22
22
 
23
23
  const router = createHostRouter();
24
24
 
25
- router.host(["."]).map(() => import("./apps/main"));
26
- router.host(["admin.*"]).map(() => import("./apps/admin"));
27
- router.host(["api.*"]).map(() => import("./apps/api"));
25
+ router.host(["."]).lazy(() => import("./apps/main"));
26
+ router.host(["admin.*"]).lazy(() => import("./apps/admin"));
27
+ router.host(["api.*"]).lazy(() => import("./apps/api"));
28
28
 
29
29
  export default {
30
30
  fetch(request: Request, env: Env, ctx: ExecutionContext) {
@@ -33,7 +33,31 @@ export default {
33
33
  };
34
34
  ```
35
35
 
36
- Each `.map()` receives either a direct handler `(request, input) => Response` or a lazy import `() => import(...)`. Lazy imports resolve a module with a `default` export that is either a handler function or another `HostRouter` (for nesting).
36
+ ## Inline handlers (`.map`) vs lazy mounts (`.lazy`)
37
+
38
+ A host pattern maps to one of two things, and you pick the method by intent:
39
+
40
+ | Method | Argument | Use for |
41
+ | ------- | ------------------------------ | ------------------------------------------------------------ |
42
+ | `.map` | `(request, input) => Response` | An inline request handler that produces a response directly. |
43
+ | `.lazy` | `() => import("./sub-app")` | A lazily-imported handler or nested host router (a sub-app). |
44
+
45
+ ```typescript
46
+ // Lazy mount: the module's default export is a handler or a HostRouter.
47
+ router.host(["admin.*"]).lazy(() => import("./apps/admin"));
48
+
49
+ // Inline handler: returns a Response itself (sync or async).
50
+ router.host(["health.*"]).map(() => new Response("ok"));
51
+ router
52
+ .host(["echo.*"])
53
+ .map((request) => new Response(new URL(request.url).pathname));
54
+ ```
55
+
56
+ Why two methods instead of one overloaded `.map()`:
57
+
58
+ - **Build-time discovery** invokes only `.lazy()` mounts (to trigger each sub-app's `createRouter()` registration). Inline `.map()` handlers are never invoked during discovery, so they can't crash it or pollute its errors.
59
+ - `.map(() => import("./sub-app"))` is a **type error** — a lazy import resolves to a module, not a `Response`. Use `.lazy()` for imports. (If the types are bypassed, e.g. from JS, a `.map()` handler that resolves to a module throws a clear `HostRouterError` at request time instead of returning the module.)
60
+ - A lazy loader may declare an ignored parameter (`.lazy((_request?) => import("./x"))`); `.lazy()` accepts it because intent is explicit, not inferred from the signature.
37
61
 
38
62
  ## Pattern Syntax
39
63
 
@@ -65,8 +89,8 @@ const hosts = defineHosts({
65
89
  app: [".", "www.*"],
66
90
  });
67
91
 
68
- router.host(hosts.admin).map(() => import("./apps/admin"));
69
- router.host(hosts.app).map(() => import("./apps/main"));
92
+ router.host(hosts.admin).lazy(() => import("./apps/admin"));
93
+ router.host(hosts.app).lazy(() => import("./apps/main"));
70
94
  ```
71
95
 
72
96
  Returns a frozen object — keys are autocompleted by TypeScript.
@@ -88,7 +112,7 @@ router.use(async (request, input, next) => {
88
112
  router
89
113
  .host(["admin.*"])
90
114
  .use(requireAuth)
91
- .map(() => import("./apps/admin"));
115
+ .lazy(() => import("./apps/admin"));
92
116
  ```
93
117
 
94
118
  Middleware signature: `(request: Request, input: RouterRequestInput, next: () => Promise<Response>) => Promise<Response>`
@@ -179,40 +203,41 @@ const request = createTestRequest({
179
203
  });
180
204
 
181
205
  // Test which route would match (without executing)
182
- router.test("admin.example.com"); // { pattern, handler } | null
206
+ router.test("admin.example.com"); // { pattern, handler, kind } | null
183
207
  ```
184
208
 
185
209
  ## Error Types
186
210
 
187
211
  All errors extend `HostRouterError`:
188
212
 
189
- | Error | When |
190
- | ----------------------------- | ------------------------------------------- |
191
- | `InvalidPatternError` | Pattern is empty, non-string, or has spaces |
192
- | `HostOverrideNotAllowedError` | Cookie override from disallowed host |
193
- | `InvalidHostnameError` | Cookie value isn't a valid hostname |
194
- | `HostValidationError` | Custom `validate` function threw |
195
- | `NoRouteMatchError` | No host pattern matched the request |
196
- | `InvalidHandlerError` | Handler is not a function |
213
+ | Error | When |
214
+ | ----------------------------- | ------------------------------------------------------------------------------------------------- |
215
+ | `InvalidPatternError` | Pattern is empty, non-string, or has spaces |
216
+ | `HostOverrideNotAllowedError` | Cookie override from disallowed host |
217
+ | `InvalidHostnameError` | Cookie value isn't a valid hostname |
218
+ | `HostValidationError` | Custom `validate` function threw |
219
+ | `NoRouteMatchError` | No host pattern matched the request |
220
+ | `InvalidHandlerError` | Handler is not a function, or a lazy mount resolved to a module without a usable `default` export |
221
+ | `HostRouterError` | A `.map()` inline handler resolved to a module namespace (a misused lazy import — use `.lazy()`) |
197
222
 
198
223
  See the fallback section above for a `NoRouteMatchError` catch example.
199
224
 
200
225
  ## Nesting Host Routers
201
226
 
202
- A lazy handler can resolve to another `HostRouter`:
227
+ A lazy mount can resolve to another `HostRouter`:
203
228
 
204
229
  ```typescript
205
230
  // apps/regional.ts
206
231
  import { createHostRouter } from "@rangojs/router/host";
207
232
 
208
233
  const regional = createHostRouter();
209
- regional.host(["us.*"]).map(() => import("./regions/us"));
210
- regional.host(["eu.*"]).map(() => import("./regions/eu"));
234
+ regional.host(["us.*"]).lazy(() => import("./regions/us"));
235
+ regional.host(["eu.*"]).lazy(() => import("./regions/eu"));
211
236
 
212
237
  export default regional;
213
238
  ```
214
239
 
215
240
  ```typescript
216
241
  // host-router.ts
217
- router.host(["**.regional.example.com"]).map(() => import("./apps/regional"));
242
+ router.host(["**.regional.example.com"]).lazy(() => import("./apps/regional"));
218
243
  ```
package/src/host/index.ts CHANGED
@@ -11,8 +11,8 @@
11
11
  *
12
12
  * const router = createHostRouter();
13
13
  *
14
- * router.host(['.']).map(() => import('./apps/main'));
15
- * router.host(['admin.*']).map(() => import('./apps/admin'));
14
+ * router.host(['.']).lazy(() => import('./apps/main'));
15
+ * router.host(['admin.*']).lazy(() => import('./apps/admin'));
16
16
  *
17
17
  * export default {
18
18
  * fetch(request) {