@rangojs/router 0.11.0 → 0.12.1

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.
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import type { Plugin } from "vite";
20
+ import type { ModuleExportMeta } from "@vitejs/plugin-rsc/transforms";
20
21
  import path from "node:path";
21
22
  import MagicString from "magic-string";
22
23
  import { normalizePath, hashId } from "./expose-id-utils.js";
@@ -89,6 +90,13 @@ export function useCacheTransform(): Plugin {
89
90
  return;
90
91
  }
91
92
 
93
+ // plugin-rsc 0.5.34 `matchDirective` does `stmt.directive.match(...)`
94
+ // after `"directive" in node`. Vite/oxc parseAst now emits
95
+ // `directive: null` on ordinary ExpressionStatements, so a file that
96
+ // mixes a `"use cache"` function with a sibling handler whose first
97
+ // statement is an expression throws and the wrap is dropped.
98
+ stripNullDirectiveFields(ast);
99
+
92
100
  const filePath = normalizePath(path.relative(projectRoot, id));
93
101
  const isLayoutOrTemplate = LAYOUT_TEMPLATE_PATTERN.test(id);
94
102
 
@@ -101,6 +109,7 @@ export function useCacheTransform(): Plugin {
101
109
  isBuild,
102
110
  isLayoutOrTemplate,
103
111
  transformWrapExport,
112
+ hasDirective,
104
113
  );
105
114
  }
106
115
 
@@ -131,6 +140,7 @@ function transformFileLevelUseCache(
131
140
  isBuild: boolean,
132
141
  isLayoutOrTemplate: boolean,
133
142
  transformWrapExport: (typeof import("@vitejs/plugin-rsc/transforms"))["transformWrapExport"],
143
+ hasDirective: (typeof import("@vitejs/plugin-rsc/transforms"))["hasDirective"],
134
144
  ) {
135
145
  const unconfirmedExports: string[] = [];
136
146
 
@@ -140,8 +150,18 @@ function transformFileLevelUseCache(
140
150
  return `__rango_registerCachedFunction(${value}, ${JSON.stringify(funcId)}, "default")`;
141
151
  },
142
152
  rejectNonAsyncFunction: false,
143
- filter: (name: string, meta: { isFunction?: boolean }) => {
153
+ filter: (name: string, meta: ModuleExportMeta) => {
144
154
  if (name === "default" && isLayoutOrTemplate) return false;
155
+ // plugin-rsc 0.5.34 hoists mixed inline `"use server"` out of a
156
+ // file-level `"use cache"` module as `$$hoist_*` exports and rebinds the
157
+ // original name to `registerServerReference($$hoist_*, ...)`. Both are
158
+ // server references, not cached functions. The directive check covers
159
+ // the pre-hoist shape (this plugin seeing the source first).
160
+ if (name.startsWith("$$hoist_")) return false;
161
+ if (isHoistedServerReferenceRebind(meta.valueNode)) return false;
162
+ if (functionHasUseServerDirective(meta.valueNode, hasDirective)) {
163
+ return false;
164
+ }
145
165
  // isFunction is boolean | undefined: true = confirmed function, false =
146
166
  // confirmed non-function, undefined = cannot tell statically (e.g. a
147
167
  // factory/HOF initializer `const x = makeCached(fn)`). Deliberate policy:
@@ -250,6 +270,50 @@ function transformFunctionLevelUseCache(
250
270
  }
251
271
  }
252
272
 
273
+ function stripNullDirectiveFields(node: unknown): void {
274
+ if (!node || typeof node !== "object") return;
275
+ const rec = node as Record<string, unknown>;
276
+ if (rec.type === "ExpressionStatement" && typeof rec.directive !== "string") {
277
+ delete rec.directive;
278
+ }
279
+ for (const value of Object.values(rec)) {
280
+ if (Array.isArray(value)) {
281
+ for (const item of value) stripNullDirectiveFields(item);
282
+ } else if (value && typeof value === "object" && "type" in value) {
283
+ stripNullDirectiveFields(value);
284
+ }
285
+ }
286
+ }
287
+
288
+ function isHoistedServerReferenceRebind(
289
+ valueNode: ModuleExportMeta["valueNode"],
290
+ ): boolean {
291
+ if (!valueNode || valueNode.type !== "CallExpression") return false;
292
+ const first = valueNode.arguments[0];
293
+ return (
294
+ first !== undefined &&
295
+ first.type === "Identifier" &&
296
+ first.name.startsWith("$$hoist_")
297
+ );
298
+ }
299
+
300
+ function functionHasUseServerDirective(
301
+ valueNode: ModuleExportMeta["valueNode"],
302
+ hasDirective: (typeof import("@vitejs/plugin-rsc/transforms"))["hasDirective"],
303
+ ): boolean {
304
+ if (!valueNode || !("body" in valueNode)) return false;
305
+ const { body } = valueNode;
306
+ if (!body || Array.isArray(body) || body.type !== "BlockStatement") {
307
+ return false;
308
+ }
309
+ // plugin-rsc types valueNode with plain estree nodes but hasDirective with
310
+ // its oxc-flavored AST; the flavors differ only in position/extra fields.
311
+ return hasDirective(
312
+ body.body as Parameters<typeof hasDirective>[0],
313
+ "use server",
314
+ );
315
+ }
316
+
253
317
  function findFileLevelDirective(
254
318
  ast: any,
255
319
  ): { start: number; end: number } | null {
@@ -28,9 +28,11 @@
28
28
  * type:module in scope the deployed (isolated) function loads them as
29
29
  * CommonJS and fails on the first `import`.
30
30
  *
31
- * The launcher is bundled with srvx (the Web->Node streaming bridge, a
32
- * @rangojs/router dependency) and @vercel/functions (resolved from the app)
33
- * inlined, keeping the RSC bundle a runtime-relative external.
31
+ * The launcher is bundled with rolldown (Vite 8's bundler a production
32
+ * dependency of `vite`, resolved through the app's vite install) so a
33
+ * standalone consumer does not need `esbuild`. srvx (the Web->Node streaming
34
+ * bridge, a @rangojs/router dependency) and @vercel/functions (resolved from
35
+ * the app) are inlined; the RSC bundle stays a runtime-relative external.
34
36
  *
35
37
  * Timing: this runs in the `buildApp` hook (order "post"), which fires once
36
38
  * after every environment has built, so dist/{client,rsc,ssr} all exist.
@@ -52,19 +54,30 @@ import type {
52
54
  VercelPresetOptions,
53
55
  } from "../plugin-types.js";
54
56
 
55
- // Minimal structural types for the esbuild API we use, resolved dynamically from
56
- // the app so @rangojs/router does not depend on esbuild's type package.
57
- interface EsbuildPluginBuild {
58
- onResolve(
59
- options: { filter: RegExp },
60
- callback: () => { path: string; external: boolean },
61
- ): void;
57
+ // Minimal structural types for the rolldown API we use. Resolved dynamically
58
+ // from the app's vite install so @rangojs/router does not depend on rolldown's
59
+ // type package (same stance the previous esbuild path took).
60
+ interface RolldownResolveResult {
61
+ id: string;
62
+ external?: boolean;
62
63
  }
63
- type EsbuildBuild = (options: Record<string, unknown>) => Promise<unknown>;
64
- interface EsbuildModule {
65
- build?: EsbuildBuild;
66
- default?: { build?: EsbuildBuild };
64
+ interface RolldownBundle {
65
+ write: (output: {
66
+ file: string;
67
+ format: string;
68
+ exports: string;
69
+ codeSplitting?: boolean;
70
+ }) => Promise<unknown>;
71
+ close: () => Promise<void>;
67
72
  }
73
+ interface RolldownModule {
74
+ rolldown?: (options: Record<string, unknown>) => Promise<RolldownBundle>;
75
+ default?: {
76
+ rolldown?: (options: Record<string, unknown>) => Promise<RolldownBundle>;
77
+ };
78
+ }
79
+
80
+ const VIRTUAL_LAUNCHER_ID = "\0rango-vercel-launcher";
68
81
 
69
82
  const LAUNCHER_SOURCE = `import { toNodeHandler } from "srvx/node";
70
83
  import { waitUntil } from "@vercel/functions";
@@ -86,6 +99,122 @@ const fetchHandler = (request) =>
86
99
  export default toNodeHandler(fetchHandler);
87
100
  `;
88
101
 
102
+ /**
103
+ * Resolve rolldown through the app's vite install (rolldown is a production
104
+ * dependency of Vite 8, unlike esbuild which is only an optional peer). Fall
105
+ * back to the app root, then the plugin's own vite, so a hoisted or
106
+ * workspace copy still works. Issue #785: the previous "esbuild ships with
107
+ * Vite" path broke standalone consumers on Vite 8.
108
+ */
109
+ export function resolveRolldownPath(root: string): string {
110
+ const appRequire = createRequire(join(root, "package.json"));
111
+ const rangoRequire = createRequire(import.meta.url);
112
+ const attempts: Array<() => string> = [
113
+ () => createRequire(appRequire.resolve("vite")).resolve("rolldown"),
114
+ () => appRequire.resolve("rolldown"),
115
+ () => createRequire(rangoRequire.resolve("vite")).resolve("rolldown"),
116
+ () => rangoRequire.resolve("rolldown"),
117
+ ];
118
+ for (const attempt of attempts) {
119
+ try {
120
+ return attempt();
121
+ } catch {
122
+ // Intentionally empty: try the next resolver.
123
+ }
124
+ }
125
+ throw new Error(
126
+ '[rango] preset "vercel" requires "rolldown" to bundle the function launcher. Vite 8 depends on it; reinstall dependencies.',
127
+ );
128
+ }
129
+
130
+ /**
131
+ * Bundle the Node launcher into `funcDir/index.mjs`: srvx + @vercel/functions
132
+ * inlined, `./rsc/index.js` left as a runtime-relative external.
133
+ */
134
+ export async function bundleVercelLauncher(opts: {
135
+ root: string;
136
+ funcDir: string;
137
+ srvxNodePath: string;
138
+ }): Promise<void> {
139
+ const { root, funcDir, srvxNodePath } = opts;
140
+ const appRequire = createRequire(join(root, "package.json"));
141
+ let vercelFunctionsPath: string;
142
+ try {
143
+ vercelFunctionsPath = appRequire.resolve("@vercel/functions");
144
+ } catch {
145
+ throw new Error(
146
+ '[rango] preset "vercel": could not resolve "@vercel/functions". Add it to your app dependencies (it also backs VercelCacheStore).',
147
+ );
148
+ }
149
+ let rolldownModule: RolldownModule;
150
+ try {
151
+ rolldownModule = (await import(
152
+ pathToFileURL(resolveRolldownPath(root)).href
153
+ )) as RolldownModule;
154
+ } catch {
155
+ throw new Error(
156
+ '[rango] preset "vercel" requires "rolldown" to bundle the function launcher. Vite 8 depends on it; reinstall dependencies.',
157
+ );
158
+ }
159
+ const rolldownFn =
160
+ rolldownModule.rolldown ?? rolldownModule.default?.rolldown;
161
+ if (typeof rolldownFn !== "function") {
162
+ throw new Error('[rango] preset "vercel": could not load rolldown().');
163
+ }
164
+
165
+ let bundle: RolldownBundle | undefined;
166
+ try {
167
+ bundle = await rolldownFn({
168
+ input: VIRTUAL_LAUNCHER_ID,
169
+ cwd: root,
170
+ platform: "node",
171
+ logLevel: "silent",
172
+ resolve: {
173
+ alias: {
174
+ "srvx/node": srvxNodePath,
175
+ "@vercel/functions": vercelFunctionsPath,
176
+ },
177
+ },
178
+ plugins: [
179
+ {
180
+ name: "rango-vercel-launcher",
181
+ resolveId(id: string): string | RolldownResolveResult | null {
182
+ if (id === VIRTUAL_LAUNCHER_ID) return VIRTUAL_LAUNCHER_ID;
183
+ if (id === "./rsc/index.js") {
184
+ return { id: "./rsc/index.js", external: true };
185
+ }
186
+ return null;
187
+ },
188
+ load(id: string): string | null {
189
+ if (id === VIRTUAL_LAUNCHER_ID) return LAUNCHER_SOURCE;
190
+ return null;
191
+ },
192
+ },
193
+ ],
194
+ });
195
+ await bundle.write({
196
+ file: join(funcDir, "index.mjs"),
197
+ format: "esm",
198
+ exports: "default",
199
+ // @vercel/functions (and srvx) may contain dynamic import(); the
200
+ // launcher must stay a single index.mjs — Vercel's handler field
201
+ // points at that one file.
202
+ codeSplitting: false,
203
+ });
204
+ } catch (error) {
205
+ const message = error instanceof Error ? error.message : String(error);
206
+ if (/@vercel\/functions/.test(message)) {
207
+ throw new Error(
208
+ '[rango] preset "vercel": could not resolve "@vercel/functions". Add it to your app dependencies (it also backs VercelCacheStore).\n' +
209
+ message,
210
+ );
211
+ }
212
+ throw error;
213
+ } finally {
214
+ await bundle?.close();
215
+ }
216
+ }
217
+
89
218
  /**
90
219
  * Reject a non-Node runtime for the vercel preset. The preset only emits a Node
91
220
  * serverless function (launcherType "Nodejs", bundled Node APIs, response
@@ -237,7 +366,8 @@ async function assemble(
237
366
 
238
367
  // 3. Bundle the Node launcher. srvx (a @rangojs/router dependency) is aliased
239
368
  // to its resolved path; @vercel/functions resolves from the app; the RSC
240
- // server bundle stays a runtime-relative external.
369
+ // server bundle stays a runtime-relative external. Rolldown (Vite 8's
370
+ // bundler) is resolved through the app's vite install — #785.
241
371
  const rangoRequire = createRequire(import.meta.url);
242
372
  let srvxNodePath: string;
243
373
  try {
@@ -247,79 +377,7 @@ async function assemble(
247
377
  '[rango] preset "vercel" requires "srvx" (a dependency of @rangojs/router). Reinstall dependencies.',
248
378
  );
249
379
  }
250
-
251
- // esbuild ships with Vite, so we never add it as a @rangojs/router dependency.
252
- // It is a DIRECT dependency of Vite but only a TRANSITIVE one from the app's
253
- // view, so under strict pnpm it is NOT resolvable from the app root. Resolve it
254
- // through Vite's module location (Vite is a direct app dependency, and esbuild
255
- // is a direct dependency of Vite). Minimal structural types avoid coupling to
256
- // esbuild's type package at compile time.
257
- const appRequire = createRequire(join(root, "package.json"));
258
- const resolveEsbuildPath = (): string => {
259
- try {
260
- const viteRequire = createRequire(appRequire.resolve("vite"));
261
- return viteRequire.resolve("esbuild");
262
- } catch {
263
- // Intentionally empty: fall through to the app/rango fallbacks below.
264
- }
265
- try {
266
- return appRequire.resolve("esbuild");
267
- } catch {
268
- // Intentionally empty: last resort is @rangojs/router's own resolver.
269
- }
270
- return rangoRequire.resolve("esbuild");
271
- };
272
- let esbuildModule: EsbuildModule;
273
- try {
274
- esbuildModule = (await import(
275
- pathToFileURL(resolveEsbuildPath()).href
276
- )) as EsbuildModule;
277
- } catch {
278
- throw new Error(
279
- '[rango] preset "vercel" requires "esbuild" to bundle the function launcher. It ships with Vite; reinstall dependencies (or add esbuild to your app dependencies).',
280
- );
281
- }
282
- const esbuildBuild = esbuildModule.build ?? esbuildModule.default?.build;
283
- if (typeof esbuildBuild !== "function") {
284
- throw new Error('[rango] preset "vercel": could not load esbuild.build.');
285
- }
286
-
287
- try {
288
- await esbuildBuild({
289
- stdin: {
290
- contents: LAUNCHER_SOURCE,
291
- resolveDir: root,
292
- sourcefile: "func-entry.mjs",
293
- loader: "js",
294
- },
295
- outfile: join(funcDir, "index.mjs"),
296
- bundle: true,
297
- format: "esm",
298
- platform: "node",
299
- target: "node18",
300
- alias: { "srvx/node": srvxNodePath },
301
- plugins: [
302
- {
303
- name: "external-rsc-entry",
304
- setup(b: EsbuildPluginBuild) {
305
- b.onResolve({ filter: /^\.\/rsc\/index\.js$/ }, () => ({
306
- path: "./rsc/index.js",
307
- external: true,
308
- }));
309
- },
310
- },
311
- ],
312
- });
313
- } catch (error) {
314
- const message = error instanceof Error ? error.message : String(error);
315
- if (/@vercel\/functions/.test(message)) {
316
- throw new Error(
317
- '[rango] preset "vercel": could not resolve "@vercel/functions". Add it to your app dependencies (it also backs VercelCacheStore).\n' +
318
- message,
319
- );
320
- }
321
- throw error;
322
- }
380
+ await bundleVercelLauncher({ root, funcDir, srvxNodePath });
323
381
 
324
382
  // 3b. Mark the function as ESM. The rsc/ssr bundles are .js ESM files with no
325
383
  // package.json in scope on the deployed function (it is isolated at
@@ -63,18 +63,20 @@ function emitProgressiveChunkSize(value: number): string {
63
63
  /**
64
64
  * Generate the virtual SSR entry. `headScripts` mirrors the rango() plugin
65
65
  * option: "preinit" (default) installs the client-reference preinit hook and
66
- * lets the SSR handlers convert the bootstrap to `bootstrapModules`;
67
- * "preload" omits the hook and pins the handlers to the hint-only strategy.
66
+ * threads `getClientEntryUrl` so Fizz emits `bootstrapModules`;
67
+ * "preload" omits the hook and uses the deprecated inline
68
+ * `loadBootstrapScriptContent` bootstrap.
68
69
  */
69
70
  export function getVirtualEntrySSR(
70
71
  headScripts: HeadScriptsOption = "preinit",
71
72
  progressiveChunkSize?: number,
72
73
  ): string {
73
74
  const preinit = headScripts !== "preload";
74
- // The preload variant drops exactly three preinit-only lines, all built
75
- // here so the template below stays a single unconditional shape.
75
+ // The preload variant drops the preinit-only imports/install and swaps the
76
+ // bootstrap dep, all built here so the template below stays a single
77
+ // unconditional shape.
76
78
  const depsImportNames = preinit
77
- ? "createFromReadableStream,\n setOnClientReference,"
79
+ ? "createFromReadableStream,\n setOnClientReference,\n getClientEntryUrl,"
78
80
  : "createFromReadableStream,";
79
81
  const ssrImportNames = preinit ? "\n installClientReferencePreinit," : "";
80
82
  const install = preinit
@@ -85,6 +87,10 @@ export function getVirtualEntrySSR(
85
87
  installClientReferencePreinit(setOnClientReference);
86
88
  `
87
89
  : "";
90
+ const bootstrapDep = preinit
91
+ ? "getClientEntryUrl,"
92
+ : `loadBootstrapScriptContent: () =>
93
+ import.meta.viteRsc.loadBootstrapScriptContent("index"),`;
88
94
  const hs = JSON.stringify(headScripts);
89
95
  // Emitted into all three handlers: live SSR and shell capture consume it
90
96
  // directly; the resume handler receives it for dep-shape uniformity (resume()
@@ -114,8 +120,7 @@ export const renderHTML = createSSRHandler({
114
120
  renderToReadableStream,
115
121
  injectRSCPayload,
116
122
  headScripts: ${hs},${pcs}
117
- loadBootstrapScriptContent: () =>
118
- import.meta.viteRsc.loadBootstrapScriptContent("index"),
123
+ ${bootstrapDep}
119
124
  });
120
125
 
121
126
  export const captureShellHTML = createShellCaptureHandler({
@@ -125,8 +130,7 @@ export const captureShellHTML = createShellCaptureHandler({
125
130
  prerender,
126
131
  resume,
127
132
  headScripts: ${hs},${pcs}
128
- loadBootstrapScriptContent: () =>
129
- import.meta.viteRsc.loadBootstrapScriptContent("index"),
133
+ ${bootstrapDep}
130
134
  });
131
135
 
132
136
  export const resumeShellHTML = createShellResumeHandler({
@@ -136,8 +140,7 @@ export const resumeShellHTML = createShellResumeHandler({
136
140
  prerender,
137
141
  resume,
138
142
  headScripts: ${hs},${pcs}
139
- loadBootstrapScriptContent: () =>
140
- import.meta.viteRsc.loadBootstrapScriptContent("index"),
143
+ ${bootstrapDep}
141
144
  });
142
145
  `.trim();
143
146
  }