@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 +4 -4
- package/dist/vite/index.js +174 -44
- package/package.json +3 -2
- package/skills/bundle-analysis/SKILL.md +159 -0
- package/skills/host-router/SKILL.md +45 -20
- package/skills/rango/SKILL.md +1 -0
- package/src/host/index.ts +2 -2
- package/src/host/router.ts +129 -57
- package/src/host/types.ts +31 -2
- package/src/host/utils.ts +1 -1
- package/src/index.ts +8 -3
- package/src/vite/discovery/discover-routers.ts +20 -22
- package/src/vite/discovery/discovery-errors.ts +194 -0
- package/src/vite/discovery/state.ts +17 -0
- package/src/vite/discovery/virtual-module-codegen.ts +13 -23
- package/src/vite/rango.ts +16 -4
- package/src/vite/router-discovery.ts +34 -2
- package/src/vite/utils/forward-user-plugins.ts +164 -0
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"]).
|
|
947
|
-
hostRouter.host(["localhost"]).
|
|
948
|
-
hostRouter.fallback().
|
|
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
|
-
|
|
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
|
|
package/dist/vite/index.js
CHANGED
|
@@ -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.
|
|
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: "
|
|
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
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
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
|
|
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,
|
|
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:
|
|
5206
|
-
|
|
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.
|
|
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": "
|
|
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(["."]).
|
|
26
|
-
router.host(["admin.*"]).
|
|
27
|
-
router.host(["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
|
-
|
|
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).
|
|
69
|
-
router.host(hosts.app).
|
|
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
|
-
.
|
|
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
|
|
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.*"]).
|
|
210
|
-
regional.host(["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"]).
|
|
242
|
+
router.host(["**.regional.example.com"]).lazy(() => import("./apps/regional"));
|
|
218
243
|
```
|