@rangojs/router 0.0.0-experimental.138 → 0.0.0-experimental.139

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.
@@ -125,6 +125,78 @@ export default function handler(request, env) {
125
125
  `.trim();
126
126
  }
127
127
 
128
+ export function getVirtualEntryRSCHost(hostEntryPath: string): string {
129
+ return `
130
+ import * as __hostEntry from "${hostEntryPath}";
131
+ import { isNoRouteMatchError } from "@rangojs/router/host";
132
+
133
+ // Register every sub-app's fetchable loaders + route manifests at startup, same
134
+ // as the single-router entry. Discovery's host fallback populates these for all
135
+ // mounted sub-apps, so the aggregate manifests cover the whole host tree.
136
+ import "virtual:rsc-router/loader-manifest";
137
+ import "virtual:rsc-router/routes-manifest";
138
+
139
+ // The host entry module must export the HostRouter instance (createHostRouter()),
140
+ // as a default export or a named \`hostRouter\`/\`router\` export. A Cloudflare-style
141
+ // \`export default { fetch }\` object is not a HostRouter and is rejected (on first
142
+ // request; see the lazy resolution below).
143
+ // We require BOTH .match() and .host(): a regular createRouter() also exposes
144
+ // .match(), so matching on .match() alone would accept an ordinary router and then
145
+ // return its MatchResult (not a Response) at runtime. .host() is unique to a
146
+ // HostRouter, so it disambiguates a mistaken \`hostRouter\` path.
147
+ // Exports are read dynamically (m[name]) so Rollup does not emit IMPORT_IS_UNDEFINED
148
+ // warnings for the named exports a default-only host module legitimately omits.
149
+ const __resolveHostRouter = (m) => {
150
+ for (const name of ["default", "hostRouter", "router"]) {
151
+ const candidate = m[name];
152
+ if (
153
+ candidate &&
154
+ typeof candidate.match === "function" &&
155
+ typeof candidate.host === "function"
156
+ )
157
+ return candidate;
158
+ }
159
+ return undefined;
160
+ };
161
+
162
+ // Resolve + validate the HostRouter lazily on first request, mirroring the
163
+ // single-router entry's \`_handler\`. During HMR the host module can re-evaluate
164
+ // before its createHostRouter() export has resolved; validating at module-
165
+ // evaluation time would then throw a spurious "must export a HostRouter" error
166
+ // overlay for a perfectly valid app. Resolving on first request lets the live
167
+ // binding settle first.
168
+ let _hostRouter;
169
+
170
+ // input = { env, ctx } from the launcher / node server. The host router threads
171
+ // it unchanged to each matched sub-app's handler and cache factory.
172
+ // On node/vercel rango owns this entry, so there is no user worker to translate
173
+ // an unmatched host into a response: catch NoRouteMatchError and return 404
174
+ // (parity with the documented Cloudflare catch). Other errors propagate.
175
+ export default async function handler(request, input) {
176
+ if (!_hostRouter) {
177
+ _hostRouter = __resolveHostRouter(__hostEntry);
178
+ if (!_hostRouter) {
179
+ throw new Error(
180
+ "[rango] The host entry (${hostEntryPath}) must export a HostRouter instance (createHostRouter()) for the node/vercel preset: a default export, or a named 'hostRouter'/'router' export. An ordinary createRouter() is not a host router (it has no .host()), and a Cloudflare-style 'export default { fetch }' object is not supported on this preset."
181
+ );
182
+ }
183
+ }
184
+ try {
185
+ return await _hostRouter.match(request, input);
186
+ } catch (err) {
187
+ // isNoRouteMatchError also matches by name: a workspace with a duplicated
188
+ // @rangojs/router copy can throw a NoRouteMatchError whose prototype differs
189
+ // from this module's import, so a bare instanceof would turn an
190
+ // unmatched-host 404 into a 500.
191
+ if (isNoRouteMatchError(err)) {
192
+ return new Response("Not Found", { status: 404 });
193
+ }
194
+ throw err;
195
+ }
196
+ }
197
+ `.trim();
198
+ }
199
+
128
200
  export const VIRTUAL_IDS = {
129
201
  browser: "virtual:rsc-router/entry.browser.js",
130
202
  ssr: "virtual:rsc-router/entry.ssr.js",
package/src/vite/rango.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type PluginOption } from "vite";
2
- import { readFileSync } from "node:fs";
2
+ import { readFileSync, existsSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
  import { exposeActionId } from "./plugins/expose-action-id.js";
5
5
  import {
@@ -15,11 +15,15 @@ import {
15
15
  getPublishedPackageName,
16
16
  getVendorAliases,
17
17
  } from "./utils/package-resolution.js";
18
- import { findRouterFiles } from "../build/generate-route-types.js";
18
+ import {
19
+ findRouterFiles,
20
+ findHostRouterFiles,
21
+ } from "../build/generate-route-types.js";
19
22
  import { createVersionPlugin } from "./plugins/version-plugin.js";
20
23
  import {
21
24
  sharedRolldownOptions,
22
25
  createVirtualEntriesPlugin,
26
+ normalizeHostRouterEntry,
23
27
  onwarn,
24
28
  getManualChunks,
25
29
  } from "./utils/shared-utils.js";
@@ -27,7 +31,12 @@ import {
27
31
  resolveClientChunks,
28
32
  type ClientChunkContext,
29
33
  } from "./utils/client-chunks.js";
30
- import type { RangoOptions } from "./plugin-types.js";
34
+ import type {
35
+ RangoOptions,
36
+ RangoNodeOptions,
37
+ RangoVercelOptions,
38
+ } from "./plugin-types.js";
39
+ import { createVercelOutputPlugin } from "./plugins/vercel-output.js";
31
40
  import { printBanner, rangoVersion } from "./utils/banner.js";
32
41
  import { createVersionInjectorPlugin } from "./plugins/version-injector.js";
33
42
  import { createCjsToEsmPlugin } from "./plugins/cjs-to-esm.js";
@@ -110,10 +119,20 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
110
119
  const pkg = getPublishedPackageName();
111
120
  const nested = (spec: string) => `${pkg} > ${spec}`;
112
121
 
113
- // Mutable ref for router path (node preset only).
114
- // Set immediately when user-specified, or populated by the auto-discover
115
- // config() hook using Vite's resolved root.
116
- const routerRef: { path: string | undefined } = { path: undefined };
122
+ // Mutable ref for the served entry path (node/vercel presets only). Populated
123
+ // by the auto-discover config() hook using Vite's resolved root. `kind` selects
124
+ // the RSC entry template: "router" wraps a single createRouter() app in
125
+ // createRSCHandler; "host" wraps a createHostRouter() instance and serves it
126
+ // via hostRouter.match().
127
+ const routerRef: { path: string | undefined; kind: "router" | "host" } = {
128
+ path: undefined,
129
+ kind: "router",
130
+ };
131
+ // Explicit host-router entry (node/vercel `hostRouter` option), root-relative.
132
+ const explicitHostRouter =
133
+ preset !== "cloudflare"
134
+ ? (resolvedOptions as RangoNodeOptions | RangoVercelOptions).hostRouter
135
+ : undefined;
117
136
 
118
137
  // Build-time prerendering is enabled for both presets.
119
138
  // Collection runs in-process via the RSC dev environment runner during discoverRouters().
@@ -238,20 +257,65 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
238
257
  const root = userConfig.root
239
258
  ? resolve(process.cwd(), userConfig.root)
240
259
  : process.cwd();
260
+ const toRootRelative = (abs: string) =>
261
+ (abs.startsWith(root)
262
+ ? "./" + abs.slice(root.length + 1)
263
+ : abs
264
+ ).replaceAll("\\", "/");
265
+ const bulletList = (files: string[]) =>
266
+ files.map((f) => " - " + toRootRelative(f)).join("\n");
267
+
268
+ // 1. Explicit host entry wins: serve the createHostRouter() instance.
269
+ if (explicitHostRouter) {
270
+ routerRef.path = normalizeHostRouterEntry(
271
+ explicitHostRouter,
272
+ root,
273
+ existsSync,
274
+ );
275
+ routerRef.kind = "host";
276
+ return;
277
+ }
278
+
279
+ // 2. A createHostRouter() file means this is a multi-app host deploy:
280
+ // serve it via the host entry. Checked BEFORE single-router discovery,
281
+ // because a host app may compose just one createRouter() sub-app plus
282
+ // host middleware / domain rules / inline .map() routes -- and a lone
283
+ // sub-app must not shadow the host entry (it would bypass
284
+ // hostRouter.match()).
285
+ const hostCandidates = findHostRouterFiles(root);
286
+ if (hostCandidates.length === 1) {
287
+ const hostPath = toRootRelative(hostCandidates[0]);
288
+ // Auto-detection preempts single-router discovery, so name the winning
289
+ // file: a stale prototype still containing `createHostRouter(` would
290
+ // otherwise silently become the served entry in place of the app.
291
+ // eslint-disable-next-line no-console
292
+ console.info(
293
+ `[rango] Serving host router entry ${hostPath} (auto-detected). ` +
294
+ `Set the \`hostRouter\` option to override.`,
295
+ );
296
+ routerRef.path = hostPath;
297
+ routerRef.kind = "host";
298
+ return;
299
+ }
300
+ if (hostCandidates.length > 1) {
301
+ throw new Error(
302
+ `[rango] Multiple host routers found:\n${bulletList(hostCandidates)}\n\n` +
303
+ `Set the \`hostRouter\` option to the entry to serve, e.g. rango({ preset: "${preset}", hostRouter: "./src/worker.rsc.tsx" }).`,
304
+ );
305
+ }
306
+
307
+ // 3. No host entry: single createRouter() app.
241
308
  const candidates = findRouterFiles(root);
242
309
  if (candidates.length === 1) {
243
- const abs = candidates[0];
244
- routerRef.path = (
245
- abs.startsWith(root) ? "./" + abs.slice(root.length + 1) : abs
246
- ).replaceAll("\\", "/");
247
- } else if (candidates.length > 1) {
248
- const list = candidates
249
- .map(
250
- (f) =>
251
- " - " + (f.startsWith(root) ? f.slice(root.length + 1) : f),
252
- )
253
- .join("\n");
254
- throw new Error(`[rango] Multiple routers found:\n${list}`);
310
+ routerRef.path = toRootRelative(candidates[0]);
311
+ routerRef.kind = "router";
312
+ return;
313
+ }
314
+ if (candidates.length > 1) {
315
+ throw new Error(
316
+ `[rango] Multiple routers found:\n${bulletList(candidates)}\n\n` +
317
+ `If this is a multi-app host router, export a createHostRouter() instance and set the \`hostRouter\` option (e.g. rango({ preset: "${preset}", hostRouter: "./src/worker.rsc.tsx" })), or use preset: "cloudflare" where you own the worker entry.`,
318
+ );
255
319
  }
256
320
  },
257
321
  });
@@ -270,8 +334,34 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
270
334
  name: "@rangojs/router:rsc-integration",
271
335
  enforce: "pre",
272
336
 
273
- config() {
337
+ config(_userConfig, configEnv) {
338
+ // Fold NODE_ENV for the vercel preset's build. The cloudflare plugin
339
+ // does this automatically and node apps do it themselves; vercel has no
340
+ // platform plugin, so without this React's CJS dev branch survives and
341
+ // doubles the SSR/RSC bundle (Bundle Hygiene rule #2). Only the exact
342
+ // `process.env.NODE_ENV` token is replaced.
343
+ const vercelDefine =
344
+ preset === "vercel" && configEnv.command === "build"
345
+ ? { "process.env.NODE_ENV": JSON.stringify("production") }
346
+ : undefined;
347
+ // The vercel preset's deployed function has no node_modules, so the
348
+ // server bundles must be fully self-contained. Bundle every dependency
349
+ // into the rsc + ssr builds instead of externalizing them (the node
350
+ // default, which only works because `vite preview` runs where
351
+ // node_modules exists). node: builtins stay external automatically.
352
+ //
353
+ // BUILD ONLY. In `vite dev` this must NOT apply: noExternal forces every
354
+ // server dependency through the RSC/SSR dev module runners, which cannot
355
+ // load many CJS packages (pg, mysql2, most SDKs, @vercel/functions) and
356
+ // crashes the dev server -- while the same app runs fine under
357
+ // preset: "node" and in the production build. Gating on `build` restores
358
+ // node-preset dev semantics (deps externalized to Node's require).
359
+ const vercelServerEnv =
360
+ preset === "vercel" && configEnv.command === "build"
361
+ ? { resolve: { noExternal: true as const } }
362
+ : undefined;
274
363
  return {
364
+ ...(vercelDefine ? { define: vercelDefine } : {}),
275
365
  optimizeDeps: {
276
366
  exclude: excludeDeps,
277
367
  rolldownOptions: sharedRolldownOptions,
@@ -314,6 +404,7 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
314
404
  },
315
405
  },
316
406
  ssr: {
407
+ ...(vercelServerEnv ?? {}),
317
408
  optimizeDeps: {
318
409
  entries: [VIRTUAL_IDS.ssr],
319
410
  include: [
@@ -332,6 +423,7 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
332
423
  },
333
424
  },
334
425
  rsc: {
426
+ ...(vercelServerEnv ?? {}),
335
427
  optimizeDeps: {
336
428
  entries: [VIRTUAL_IDS.rsc],
337
429
  include: [
@@ -363,7 +455,11 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
363
455
  ? "preview"
364
456
  : "dev"
365
457
  : "build";
366
- printBanner(mode, "node", rangoVersion);
458
+ printBanner(
459
+ mode,
460
+ preset === "vercel" ? "vercel" : "node",
461
+ rangoVersion,
462
+ );
367
463
  }
368
464
 
369
465
  const rscMinimalCount = config.plugins.filter(
@@ -443,6 +539,17 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
443
539
  }),
444
540
  );
445
541
 
542
+ // Vercel preset: assemble .vercel/output from dist/ after the build. Pushed
543
+ // last so its buildApp (order "post") hook runs after the discovery plugin's
544
+ // rsc-env postprocess. buildApp fires once after the whole multi-environment
545
+ // build, so dist/ is complete (closeBundle is unusable here -- it fires per
546
+ // environment, twice for ssr; see the plugin's own note).
547
+ if (preset === "vercel") {
548
+ plugins.push(
549
+ createVercelOutputPlugin(resolvedOptions as RangoVercelOptions),
550
+ );
551
+ }
552
+
446
553
  debugConfig?.(
447
554
  "rango(%s) setup done: %d plugin(s) (%sms)",
448
555
  preset,
@@ -1,11 +1,13 @@
1
1
  import type { Plugin, ResolvedConfig } from "vite";
2
2
  import * as Vite from "vite";
3
+ import { isAbsolute, resolve } from "node:path";
3
4
  import { getPublishedPackageName } from "./package-resolution.js";
4
5
  import { performanceTracksOptimizeDepsPlugin } from "../plugins/performance-tracks.js";
5
6
  import {
6
7
  VIRTUAL_ENTRY_BROWSER,
7
8
  VIRTUAL_ENTRY_SSR,
8
9
  getVirtualEntryRSC,
10
+ getVirtualEntryRSCHost,
9
11
  getVirtualVersionContent,
10
12
  VIRTUAL_IDS,
11
13
  } from "../plugins/virtual-entries.js";
@@ -54,13 +56,59 @@ export const sharedRolldownOptions: {
54
56
  plugins: [versionRolldownPlugin, performanceTracksOptimizeDepsPlugin()],
55
57
  };
56
58
 
59
+ /**
60
+ * Normalize an explicit `hostRouter` option into the specifier emitted as the
61
+ * host entry's `import ... from "<path>"`, and — for the forms that resolve
62
+ * unambiguously against the project root — verify the file exists (failing with
63
+ * a rango message instead of a downstream bundler "failed to resolve").
64
+ *
65
+ * Only a BARE specifier ("src/worker.rsc.tsx") that exists under the root is
66
+ * rewritten (to "./src/...") so it is not read as a package. A bare specifier
67
+ * that does NOT exist under the root is passed through VERBATIM: it may be a
68
+ * Vite alias ("@/worker.rsc.tsx"), an imports-map entry ("#app/worker"), or a
69
+ * workspace package the bundler resolves — rewriting or rejecting those was a
70
+ * regression (aliases worked before normalization existed). "./"/"../", a
71
+ * leading-slash (Vite root-relative), and a filesystem-absolute path are
72
+ * emitted unchanged — Vite resolves each. A leading-slash path and a
73
+ * filesystem-absolute path are indistinguishable on POSIX, so both are treated
74
+ * as "rooted": passed through WITHOUT an existence check (guessing between the
75
+ * two would wrongly reject a valid file — this was a regression the first time
76
+ * the check was added). Only the explicitly-relative forms, which resolve
77
+ * unambiguously against the root, fail fast with a rango message.
78
+ *
79
+ * `exists` is injected so the logic is unit-testable without a real filesystem.
80
+ */
81
+ export function normalizeHostRouterEntry(
82
+ rawInput: string,
83
+ root: string,
84
+ exists: (absPath: string) => boolean,
85
+ ): string {
86
+ const raw = rawInput.replaceAll("\\", "/");
87
+ const isRelative = raw.startsWith("./") || raw.startsWith("../");
88
+ const isRooted = raw.startsWith("/") || isAbsolute(rawInput);
89
+ if (isRooted) return raw;
90
+ if (isRelative) {
91
+ if (!exists(resolve(root, raw))) {
92
+ throw new Error(
93
+ `[rango] hostRouter entry not found: "${rawInput}" (resolved under ` +
94
+ `${root}). Point it at your createHostRouter() module, e.g. ` +
95
+ `rango({ hostRouter: "./src/worker.rsc.tsx" }).`,
96
+ );
97
+ }
98
+ return raw;
99
+ }
100
+ // Bare specifier: an existing root-relative file gets the explicit "./";
101
+ // anything else (alias, imports-map, package) is the bundler's to resolve.
102
+ return exists(resolve(root, raw)) ? "./" + raw : raw;
103
+ }
104
+
57
105
  /**
58
106
  * Create a virtual modules plugin for default entry files.
59
107
  * Provides virtual module content when entries use VIRTUAL_IDS (no custom entry configured).
60
108
  */
61
109
  export function createVirtualEntriesPlugin(
62
110
  entries: { client: string; ssr: string; rsc?: string },
63
- routerPathRef?: { path?: string },
111
+ routerPathRef?: { path?: string; kind?: "router" | "host" },
64
112
  ): Plugin {
65
113
  // Build virtual modules map based on which entries use virtual IDs
66
114
  const virtualModules: Record<string, string> = {};
@@ -108,7 +156,9 @@ export function createVirtualEntriesPlugin(
108
156
  : routerPathRef.path;
109
157
  // Normalize backslashes for Windows (path.join/slice preserve native separators)
110
158
  const absoluteRouterPath = raw.replaceAll("\\", "/");
111
- return getVirtualEntryRSC(absoluteRouterPath);
159
+ return routerPathRef.kind === "host"
160
+ ? getVirtualEntryRSCHost(absoluteRouterPath)
161
+ : getVirtualEntryRSC(absoluteRouterPath);
112
162
  }
113
163
  }
114
164
  return null;