@rangojs/router 0.0.0-experimental.102 → 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.102",
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(
@@ -4861,7 +4927,7 @@ function generateRoutesManifestModule(state) {
4861
4927
  }
4862
4928
  }
4863
4929
  const lines = [
4864
- `import { setCachedManifest, setPrecomputedEntries, setRouteTrie, setRouterManifest, registerRouterManifestLoader, clearAllRouterData } from "@rangojs/router/server";`,
4930
+ `import { setCachedManifest, setRouterManifest, registerRouterManifestLoader, clearAllRouterData } from "@rangojs/router/server";`,
4865
4931
  ...genFileImports,
4866
4932
  // Clear stale per-router cached data (manifest, trie, precomputed entries)
4867
4933
  // before re-populating. In Cloudflare dev mode, program reloads re-evaluate
@@ -4897,18 +4963,6 @@ function generateRoutesManifestModule(state) {
4897
4963
  );
4898
4964
  }
4899
4965
  }
4900
- if (state.isBuildMode) {
4901
- if (state.mergedPrecomputedEntries && state.mergedPrecomputedEntries.length > 0) {
4902
- lines.push(
4903
- `setPrecomputedEntries(${jsonParseExpression(state.mergedPrecomputedEntries)});`
4904
- );
4905
- }
4906
- if (state.mergedRouteTrie) {
4907
- lines.push(
4908
- `setRouteTrie(${jsonParseExpression(state.mergedRouteTrie)});`
4909
- );
4910
- }
4911
- }
4912
4966
  for (const routerId of state.perRouterManifestDataMap.keys()) {
4913
4967
  lines.push(
4914
4968
  `registerRouterManifestLoader(${JSON.stringify(routerId)}, () => import(${JSON.stringify(VIRTUAL_ROUTES_MANIFEST_ID + "/" + routerId)}));`
@@ -5174,6 +5228,52 @@ function createDiscoveryGate(s, debug11) {
5174
5228
  };
5175
5229
  }
5176
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
+
5177
5277
  // src/vite/router-discovery.ts
5178
5278
  var debugDiscovery = createRangoDebugger(NS.discovery);
5179
5279
  var debugRoutes = createRangoDebugger(NS.routes);
@@ -5196,14 +5296,23 @@ function ensureCloudflareProtocolLoaderRegistered() {
5196
5296
  async function createTempRscServer(state, options = {}) {
5197
5297
  ensureCloudflareProtocolLoaderRegistered();
5198
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
+ };
5199
5307
  return createViteServer({
5200
5308
  root: state.projectRoot,
5201
5309
  configFile: false,
5202
5310
  server: { middlewareMode: true },
5203
5311
  appType: "custom",
5204
5312
  logLevel: "silent",
5205
- resolve: { alias: state.userResolveAlias },
5206
- esbuild: { jsx: "automatic", jsxImportSource: "react" },
5313
+ resolve: resolveConfig,
5314
+ ...runnerConfig?.define ? { define: runnerConfig.define } : {},
5315
+ esbuild: esbuildConfig,
5207
5316
  ...options.cacheDir && { cacheDir: options.cacheDir },
5208
5317
  plugins: [
5209
5318
  rsc({
@@ -5221,7 +5330,11 @@ async function createTempRscServer(state, options = {}) {
5221
5330
  // Dev prerender must use dev-mode IDs (path-based) to match the workerd
5222
5331
  // runtime. forceBuild produces hashed IDs for production bundle consistency.
5223
5332
  exposeInternalIds(options.forceBuild ? { forceBuild: true } : void 0),
5224
- 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
5225
5338
  ]
5226
5339
  });
5227
5340
  }
@@ -5304,6 +5417,10 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
5304
5417
  viteCommand = config.command;
5305
5418
  viteMode = config.mode;
5306
5419
  s.userResolveAlias = config.resolve.alias;
5420
+ s.userRunnerConfig = pickForwardedRunnerConfig(config);
5421
+ s.userResolvePlugins = selectForwardableResolvePlugins(
5422
+ config.plugins
5423
+ );
5307
5424
  if (!s.resolvedEntryPath && opts?.routerPathRef?.path) {
5308
5425
  s.resolvedEntryPath = opts.routerPathRef.path;
5309
5426
  }
@@ -5999,7 +6116,8 @@ ${err.stack}` : null
5999
6116
  ].filter(Boolean).join("\n");
6000
6117
  throw new Error(
6001
6118
  `[rsc-router] Build-time router discovery failed:
6002
- ${details}`
6119
+ ${details}`,
6120
+ { cause: err }
6003
6121
  );
6004
6122
  } finally {
6005
6123
  delete globalThis.__rscRouterDiscoveryActive;
@@ -6225,7 +6343,15 @@ async function rango(options) {
6225
6343
  esbuildOptions: sharedEsbuildOptions
6226
6344
  },
6227
6345
  resolve: {
6228
- 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"]
6229
6355
  },
6230
6356
  build: {
6231
6357
  rollupOptions: { onwarn }
@@ -6252,10 +6378,6 @@ async function rango(options) {
6252
6378
  build: {
6253
6379
  outDir: "./dist/rsc/ssr"
6254
6380
  },
6255
- resolve: {
6256
- // Ensure single React instance in SSR child environment
6257
- dedupe: ["react", "react-dom"]
6258
- },
6259
6381
  // Pre-bundle SSR entry and React for proper module linking with childEnvironments
6260
6382
  // All deps must be listed to avoid late discovery triggering ERR_OUTDATED_OPTIMIZED_DEP
6261
6383
  optimizeDeps: {
@@ -6351,7 +6473,15 @@ ${list}`);
6351
6473
  rollupOptions: { onwarn }
6352
6474
  },
6353
6475
  resolve: {
6354
- 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"]
6355
6485
  },
6356
6486
  environments: {
6357
6487
  client: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.102",
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": {
@@ -0,0 +1,159 @@
1
+ ---
2
+ name: bundle-analysis
3
+ description: Audit a Rango app's production bundle for server-side code leaking into the client, dev/prod React duplication, oversized chunks, and inefficient client-reference grouping. Use when investigating bundle size growth, before a production deploy, or when the client/SSR/RSC output suddenly balloons.
4
+ argument-hint: "<app-dir>"
5
+ ---
6
+
7
+ # Bundle Analysis
8
+
9
+ Use this when you want **proof** that your Rango app is shipping the bundles you expect: small client, no server leaks, no doubled React, reasonable RSC worker size.
10
+
11
+ ## What this checks
12
+
13
+ Your app builds in three Vite environments — `client`, `ssr`, and `rsc` — and each ships its own bundle. The most common bundle bugs in a Rango app are:
14
+
15
+ 1. **Server code leaking into the client.** A file that imports `node:fs`, calls a database, or contains action logic ends up in the client bundle because a client component pulled it in transitively. Symptom: your client bundle is much larger than expected, sometimes with imports that fail at runtime.
16
+ 2. **Both dev and prod React in the SSR/RSC bundle.** When `process.env.NODE_ENV` isn't folded at build time, React's CJS files ship both `.development.js` _and_ `.production.js` variants — doubling React's footprint. The Cloudflare vite plugin folds NODE_ENV automatically; vanilla `vite build` does it for client but not always for SSR/RSC.
17
+ 3. **An oversized routes-manifest in your RSC worker.** The `virtual:rsc-router/routes-manifest/<routerId>` chunk holds your route trie and precomputed entries — large only in proportion to your route count. If it's surprisingly big, you may have unintentionally generated routes (e.g., parametrized fixtures) that bloated the trie.
18
+ 4. **Inefficient client-reference grouping.** Each `"use client"` boundary becomes a chunk. Too many small client components = many tiny chunks; one giant client component = one giant chunk that defeats code-splitting.
19
+
20
+ Tree-shaking does _not_ catch (1) generated data inlined as string literals or (2) data-dependent conditionals like React's. You need a visualizer.
21
+
22
+ ## Step 1: Install the visualizer
23
+
24
+ In your app's directory:
25
+
26
+ ```bash
27
+ pnpm add -D rollup-plugin-visualizer
28
+ # or: npm install --save-dev rollup-plugin-visualizer
29
+ # or: yarn add -D rollup-plugin-visualizer
30
+ ```
31
+
32
+ ## Step 2: Wire it into your `vite.config.ts`
33
+
34
+ Add a small helper that registers one visualizer instance **per Vite environment** (not just one global). The plugin caches its options after the first call, so a single instance can't handle multi-environment builds — you'll get a report for one environment and silence for the others.
35
+
36
+ ```ts
37
+ // vite.config.ts
38
+ import { defineConfig, type PluginOption, type Plugin } from "vite";
39
+ import { visualizer } from "rollup-plugin-visualizer";
40
+ import { join } from "node:path";
41
+ // ... your other imports ...
42
+
43
+ function analyze(): PluginOption[] {
44
+ if (!process.env.ANALYZE) return [];
45
+ return (["client", "ssr", "rsc"] as const).map((envName) => {
46
+ const inner = visualizer({
47
+ filename: join("bundle-stats", `${envName}.html`),
48
+ template: "treemap",
49
+ gzipSize: true,
50
+ brotliSize: true,
51
+ }) as Plugin;
52
+ return {
53
+ ...inner,
54
+ name: `analyze-${envName}`,
55
+ applyToEnvironment(env) {
56
+ return env.name === envName;
57
+ },
58
+ } as Plugin;
59
+ });
60
+ }
61
+
62
+ export default defineConfig(({ command }) => ({
63
+ plugins: [
64
+ // your existing plugins...
65
+ ...analyze(),
66
+ ],
67
+ // For non-Cloudflare apps, fold NODE_ENV explicitly so React's CJS files
68
+ // emit only the .production.js variants in SSR/RSC. Skip if your build
69
+ // setup already does this (the Cloudflare vite plugin does).
70
+ define:
71
+ command === "build"
72
+ ? { "process.env.NODE_ENV": JSON.stringify("production") }
73
+ : undefined,
74
+ }));
75
+ ```
76
+
77
+ Add `bundle-stats/` to your `.gitignore`.
78
+
79
+ ## Step 3: Build with the analyzer enabled
80
+
81
+ ```bash
82
+ ANALYZE=1 pnpm exec vite build
83
+ ```
84
+
85
+ You'll get three HTML reports in `bundle-stats/`:
86
+
87
+ - `bundle-stats/client.html` — what runs in the browser
88
+ - `bundle-stats/ssr.html` — what runs during HTML stream
89
+ - `bundle-stats/rsc.html` — what runs in your RSC server (Worker / Node)
90
+
91
+ Open them with a quick local server (file:// has CORS issues with the embedded scripts):
92
+
93
+ ```bash
94
+ pnpm dlx serve -l 5050 .
95
+ # then visit http://localhost:5050/bundle-stats/client.html
96
+ ```
97
+
98
+ ## Step 4: Triage the reports
99
+
100
+ ### Open `client.html` first
101
+
102
+ The treemap shows nested boxes; box area = uncompressed size. Hover for gzip/brotli numbers.
103
+
104
+ **Look for:**
105
+
106
+ - **Your server code.** Any of your own files that contain database queries, secret keys, server actions implementation (not the action _reference_), or `node:` imports. If they appear in the client treemap with non-zero bytes, they leaked. Common causes:
107
+ - A shared module that mixes client and server code without a `"use client"` or `"use server"` directive.
108
+ - A barrel file (`index.ts`) that re-exports both client and server symbols. Tree-shaking should help, but `JSON.parse('{...}')` data and side-effecting top-level statements survive.
109
+ - Client component imports a server-only utility through an indirect path (e.g., shared types file that pulls server modules).
110
+ - **Multiple copies of the same package.** Look for two boxes with the same package name but different version paths. Usually means a transitive dep pinned a different version.
111
+ - **The `@rangojs/router` chunk** should be roughly **50 KB gzip** (74 files). If significantly larger, you might be importing client-incompatible APIs from the wrong subpath.
112
+ - **Per-route client-reference chunks** (named like `chunk-<hash>.js`). Each `"use client"` boundary can become its own chunk. If you have hundreds of tiny chunks, you may have over-split (every leaf component as a client component); if you have one massive 200 KB chunk, you've under-split (a wide client tree behind one boundary).
113
+
114
+ ### Now check `ssr.html`
115
+
116
+ **Look for:**
117
+
118
+ - **`react-dom-server.edge.development-*.js`** (or any `*.development*.js` chunk). This is the dev/prod React doubling. Fix: add `define: { "process.env.NODE_ENV": '"production"' }` to your vite config (see Step 2).
119
+ - **Your client components** appearing in SSR. They're _expected_ here — SSR hydration needs to produce HTML for them. The same components show up in `client.html` too because the browser hydrates them. This is not a leak.
120
+ - **Total SSR size**: a reasonable Rango SSR is ~140 KB gzip plus your app code. If it's >300 KB, almost always (1) dev/prod React duplication or (2) a giant data structure being inlined.
121
+
122
+ ### Now check `rsc.html`
123
+
124
+ **Look for:**
125
+
126
+ - **`virtual:rsc-router/routes-manifest`** should be **tiny** (< 1 KB). If it's > 100 KB, you're on an old version of `@rangojs/router` that inlined the trie eagerly — upgrade to a release that includes commit `d10a2470`.
127
+ - **`virtual:rsc-router/routes-manifest/<hash>`** is the lazy per-router chunk. Its size is proportional to your route count. For a typical app: 5–50 KB gzip. For a stress-test app with thousands of routes: hundreds of KB. If yours is unexpectedly huge, check whether you're generating routes you don't need.
128
+ - **`<your-router>.named-routes.gen.ts`** — generated route map. Should match your route count.
129
+ - **Your action and loader implementations** — these run server-side. Expected to be here, not in client.
130
+ - **Worker-incompatible code** (Node-only imports like `node:fs` that Cloudflare doesn't support). The build will usually fail before the analyzer runs, but if you're seeing runtime errors at the edge, the RSC treemap shows what made it in.
131
+
132
+ ## Step 5: Fix what you find
133
+
134
+ | Finding | Fix |
135
+ | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
136
+ | Your server code in `client.html` (non-zero bytes) | Audit the import chain. Add `"use server"` to server-only files. Move shared data out of barrel files. Use the `@rangojs/router/server` subpath for explicitly server APIs. |
137
+ | Your server code in `client.html` listed but 0 bytes | Tree-shaking already eliminated it. Cosmetic. Leave it. |
138
+ | `react-dom-server.edge.development-*.js` in SSR or RSC | Add the `define` block from Step 2 to your vite config. |
139
+ | Routes-manifest > 100 KB gzip in RSC eager chunk | Update `@rangojs/router` to a release that includes the lazy-only manifest fix. |
140
+ | Same package version present twice | Run `pnpm dedupe` (or `npm dedupe`). If the duplication persists, a transitive dep pins an incompatible version — open a PR upstream or pin the resolution. |
141
+ | Client chunk > 500 KB gzip with a single dominant module | That module is your largest client component. Consider lazy-loading via dynamic `import()` or moving non-interactive parts to server components. |
142
+ | Hundreds of tiny client chunks | You've sprinkled `"use client"` too liberally. Hoist directives to higher boundaries so React groups them. |
143
+
144
+ ## When to re-run
145
+
146
+ - Before every production deploy, especially after adding new dependencies.
147
+ - After upgrading `@rangojs/router`, React, or `@vitejs/plugin-rsc`.
148
+ - After adding routes that scale with data (e.g., one route per item from a content directory) — the manifest may have grown.
149
+ - When CI starts reporting larger artifact sizes.
150
+
151
+ ## Reporting Rango regressions
152
+
153
+ If a finding looks like a `@rangojs/router` regression (the framework is shipping more than it should, not your app), open an issue at the [@rangojs/router GitHub](https://github.com/ivogt/vite-rsc/issues) and include:
154
+
155
+ - The output of `client.html` / `rsc.html` (screenshots or the JSON `data = {...}` block from the HTML).
156
+ - The `@rangojs/router` version (`pnpm why @rangojs/router`).
157
+ - Your `vite.config.ts`.
158
+
159
+ The framework maintainers run a similar audit internally — the methodology in this skill mirrors what they use to validate every release.
@@ -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
  ```