@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.
@@ -64,6 +64,21 @@ export class NoRouteMatchError extends HostRouterError {
64
64
  }
65
65
  }
66
66
 
67
+ /**
68
+ * True when `err` is a NoRouteMatchError — including one thrown by a
69
+ * DUPLICATED @rangojs/router copy in the module graph (a workspace pinning a
70
+ * second version), whose class identity differs so a bare `instanceof` misses
71
+ * it and an unmatched-host 404 becomes an opaque 500. Use this in worker
72
+ * catch blocks instead of `instanceof`; it also matches by the stable `name`
73
+ * the constructor stamps.
74
+ */
75
+ export function isNoRouteMatchError(err: unknown): err is NoRouteMatchError {
76
+ return (
77
+ err instanceof NoRouteMatchError ||
78
+ (err instanceof Error && err.name === "NoRouteMatchError")
79
+ );
80
+ }
81
+
67
82
  export class InvalidHandlerError extends HostRouterError {
68
83
  constructor(handler: unknown, options?: ErrorOptions) {
69
84
  super(`Invalid handler type: ${typeof handler}`, options);
package/src/host/index.ts CHANGED
@@ -42,6 +42,7 @@ export {
42
42
  InvalidHostnameError,
43
43
  HostValidationError,
44
44
  NoRouteMatchError,
45
+ isNoRouteMatchError,
45
46
  InvalidHandlerError,
46
47
  } from "./errors.js";
47
48
 
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Vercel preset integration surface for @rangojs/router.
3
+ *
4
+ * Imported via `@rangojs/router/vercel`. Vercel-only helpers live here so
5
+ * Node/other-platform consumers never pull them in. The Vercel cache store
6
+ * (`VercelCacheStore`) is exported from `@rangojs/router/cache`, mirroring the
7
+ * Cloudflare split where the store lives in `/cache` and the tracing helper in
8
+ * the platform subpath.
9
+ */
10
+
11
+ export { createVercelTracing, type VercelTracingOptions } from "./tracing.js";
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Vercel OpenTelemetry tracing integration.
3
+ *
4
+ * Bridges the router's performance phases (request, middleware, action,
5
+ * loaders, handler, render, ssr) onto OpenTelemetry spans so they show up in
6
+ * Vercel's trace waterfall next to the platform's automatic spans, with correct
7
+ * nesting. Vercel exposes tracing through OpenTelemetry (not a native import-free
8
+ * API like Cloudflare), so this is a thin convenience over `createOTelTracing`:
9
+ * it reads the global OTel tracer that `@vercel/otel`'s `registerOTel()` installs.
10
+ *
11
+ * Usage (vercel preset). A Rango/Vite app does NOT auto-load `instrumentation.ts`
12
+ * the way Next.js does, so export the tracing config from there and import it —
13
+ * the import is what runs `registerOTel()`. A standalone `registerOTel()` that
14
+ * nothing imports is a silent no-op (the tracer never registers, spans drop):
15
+ *
16
+ * // instrumentation.ts
17
+ * import { registerOTel } from "@vercel/otel";
18
+ * import { createVercelTracing } from "@rangojs/router/vercel";
19
+ * registerOTel({ serviceName: "my-app" });
20
+ * export const tracing = createVercelTracing();
21
+ *
22
+ * // router.tsx — importing `tracing` runs instrumentation.ts (and registerOTel)
23
+ * import { createRouter } from "@rangojs/router";
24
+ * import { tracing } from "./instrumentation.js";
25
+ * export const router = createRouter({ tracing });
26
+ *
27
+ * Two runtime caveats inherited from the platform:
28
+ * - Node.js runtime only. Vercel custom spans are unsupported on the Edge
29
+ * runtime; `startActiveSpan` also needs an AsyncLocalStorage context manager
30
+ * (which @vercel/otel configures on Node) for spans to nest correctly.
31
+ * - `registerOTel()` must run before the first request. Reading the tracer via
32
+ * `trace.getTracer` here is safe even if it runs first — the API's proxy
33
+ * tracer delegates to the provider once registered — but no provider means
34
+ * every span is a no-op (the request behaves exactly as if tracing were off).
35
+ *
36
+ * Whether spans are actually exported is governed by your OTel setup / Vercel
37
+ * tracing destination, not by this adapter.
38
+ */
39
+
40
+ import { trace } from "@opentelemetry/api";
41
+ import {
42
+ createOTelTracing,
43
+ type OTelActiveSpanTracer,
44
+ } from "../router/telemetry-otel.js";
45
+ import type {
46
+ RouterTracingConfig,
47
+ TracePhaseToggles,
48
+ } from "../router/tracing.js";
49
+
50
+ /** Options for createVercelTracing. */
51
+ export interface VercelTracingOptions {
52
+ /** Master switch. Defaults to true. */
53
+ enabled?: boolean;
54
+ /** Per-phase span toggles. Omitted phases default to enabled. */
55
+ spans?: TracePhaseToggles;
56
+ /**
57
+ * OTel instrumentation-scope name passed to `trace.getTracer()`. Defaults to
58
+ * `"rango"`. Ignored when `tracer` is provided.
59
+ */
60
+ tracerName?: string;
61
+ /**
62
+ * Explicit tracer override. Defaults to the global OTel tracer
63
+ * (`trace.getTracer(tracerName)`) — the provider `@vercel/otel`'s
64
+ * `registerOTel()` installs. Pass this to bridge onto a tracer you own.
65
+ */
66
+ tracer?: OTelActiveSpanTracer;
67
+ }
68
+
69
+ /**
70
+ * Create the tracing config for a Vercel router. Pass the result to
71
+ * `createRouter({ tracing })`. Spans are emitted for the request, middleware,
72
+ * action, loaders, handler, render, and ssr phases; pass `spans` to turn
73
+ * individual phases off.
74
+ *
75
+ * @see createOTelTracing (`@rangojs/router`) for the underlying adapter on any
76
+ * platform with an OpenTelemetry SDK.
77
+ * @see createCloudflareTracing (`@rangojs/router/cloudflare`) for the same slot
78
+ * using Cloudflare Workers native custom spans.
79
+ */
80
+ export function createVercelTracing(
81
+ options: VercelTracingOptions = {},
82
+ ): RouterTracingConfig {
83
+ const { tracerName = "rango", tracer, enabled, spans } = options;
84
+ return createOTelTracing(tracer ?? trace.getTracer(tracerName), {
85
+ enabled,
86
+ spans,
87
+ });
88
+ }
@@ -19,7 +19,7 @@ export interface PluginOptions {
19
19
  /** Build-time env option from rango() config. */
20
20
  buildEnv?: import("../plugin-types.js").BuildEnvOption;
21
21
  /** Deployment preset (needed for buildEnv "auto" resolution). */
22
- preset?: "node" | "cloudflare";
22
+ preset?: "node" | "cloudflare" | "vercel";
23
23
  /**
24
24
  * Route-discovery scan filter (glob include/exclude) from rango() config.
25
25
  * Compiled into `DiscoveryState.scanFilter` once `projectRoot` is known.
package/src/vite/index.ts CHANGED
@@ -12,6 +12,8 @@ export { poke } from "./plugins/refresh-cmd.js";
12
12
  export type {
13
13
  RangoNodeOptions,
14
14
  RangoCloudflareOptions,
15
+ RangoVercelOptions,
16
+ VercelPresetOptions,
15
17
  RangoOptions,
16
18
  ClientChunks,
17
19
  ClientChunkMeta,
@@ -12,7 +12,7 @@ export interface BuildEnvFactoryContext {
12
12
  /** Vite command ("serve" for dev, "build" for production). */
13
13
  command: "serve" | "build";
14
14
  /** Router deployment preset. */
15
- preset: "node" | "cloudflare";
15
+ preset: "node" | "cloudflare" | "vercel";
16
16
  }
17
17
 
18
18
  /**
@@ -175,6 +175,20 @@ export interface RangoNodeOptions extends RangoBaseOptions {
175
175
  */
176
176
  preset?: "node";
177
177
 
178
+ /**
179
+ * Path to a host-router entry (a module that calls `createHostRouter()` and
180
+ * exports the instance) to serve instead of a single `createRouter()` app.
181
+ * Root-relative (e.g. `"./src/worker.rsc.tsx"`).
182
+ *
183
+ * Set this when the app is a multi-app host router: auto-discovery otherwise
184
+ * finds the sub-apps' multiple `createRouter()` files and cannot pick an entry.
185
+ * When omitted, rango auto-detects a single `createHostRouter()` file if the
186
+ * app has several `createRouter()` files. The host module must export the
187
+ * `HostRouter` instance (default export or a named `hostRouter`/`router`
188
+ * export), not a Cloudflare-style `{ fetch }` object.
189
+ */
190
+ hostRouter?: string;
191
+
178
192
  /**
179
193
  * Environment bindings available to Prerender and Static handlers at build
180
194
  * time via `ctx.env`. Shared across all prerender invocations for the build.
@@ -215,7 +229,75 @@ export interface RangoCloudflareOptions extends RangoBaseOptions {
215
229
  buildEnv?: BuildEnvOption;
216
230
  }
217
231
 
232
+ /**
233
+ * Per-function knobs for the Vercel deployment, written into the generated
234
+ * `.vc-config.json` (and `config.json` for `functionName`).
235
+ */
236
+ export interface VercelPresetOptions {
237
+ /** Node runtime for the function. @default "nodejs22.x" */
238
+ runtime?: string;
239
+ /** Max execution time in seconds. @default 30 */
240
+ maxDuration?: number;
241
+ /** Function memory in MB (platform default when omitted). */
242
+ memory?: number;
243
+ /** Regions to pin the function to (platform default when omitted). */
244
+ regions?: string[];
245
+ /**
246
+ * Function name — the `<name>.func` directory and the `config.json` route
247
+ * destination. @default "index"
248
+ */
249
+ functionName?: string;
250
+ }
251
+
252
+ /**
253
+ * Options for Vercel Functions deployment.
254
+ *
255
+ * Builds like the node preset (Vercel runs Node Functions, not Workers): rango
256
+ * owns the RSC entry, `process.env.NODE_ENV` is folded for the build, and after
257
+ * the build a `.vercel/output` directory (Build Output API v3) is assembled from
258
+ * `dist/` — a single streaming Node Function plus the static client assets. The
259
+ * app must install `@vercel/functions` (used by `VercelCacheStore` and the
260
+ * generated function launcher).
261
+ */
262
+ export interface RangoVercelOptions extends RangoBaseOptions {
263
+ /**
264
+ * Deployment preset for Vercel Functions.
265
+ */
266
+ preset: "vercel";
267
+
268
+ /**
269
+ * Path to a host-router entry (a module that calls `createHostRouter()` and
270
+ * exports the instance) to serve instead of a single `createRouter()` app.
271
+ * Root-relative (e.g. `"./src/worker.rsc.tsx"`).
272
+ *
273
+ * Set this when the app is a multi-app host router: auto-discovery otherwise
274
+ * finds the sub-apps' multiple `createRouter()` files and cannot pick an entry.
275
+ * When omitted, rango auto-detects a single `createHostRouter()` file if the
276
+ * app has several `createRouter()` files. The host module must export the
277
+ * `HostRouter` instance (default export or a named `hostRouter`/`router`
278
+ * export), not a Cloudflare-style `{ fetch }` object. The Vercel function then
279
+ * runs `hostRouter.match()` for every request (single-function deploy).
280
+ */
281
+ hostRouter?: string;
282
+
283
+ /**
284
+ * Environment bindings available to Prerender and Static handlers at build
285
+ * time via `ctx.env`. `"auto"` is Cloudflare-only; pass an object or a factory.
286
+ *
287
+ * @default false
288
+ */
289
+ buildEnv?: Exclude<BuildEnvOption, "auto">;
290
+
291
+ /**
292
+ * Vercel function configuration written into the Build Output.
293
+ */
294
+ vercel?: VercelPresetOptions;
295
+ }
296
+
218
297
  /**
219
298
  * Options for rango() Vite plugin
220
299
  */
221
- export type RangoOptions = RangoNodeOptions | RangoCloudflareOptions;
300
+ export type RangoOptions =
301
+ | RangoNodeOptions
302
+ | RangoCloudflareOptions
303
+ | RangoVercelOptions;
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Vercel Build Output (Build Output API v3) emitter for `preset: "vercel"`.
3
+ *
4
+ * After the full app build, restructures dist/ into .vercel/output:
5
+ *
6
+ * .vercel/output/
7
+ * config.json routing: static first, else the function
8
+ * static/ dist/client (browser assets, served at /)
9
+ * functions/<name>.func/
10
+ * .vc-config.json Node serverless, response streaming
11
+ * index.mjs bundled launcher (srvx + @vercel/functions)
12
+ * rsc/ dist/rsc (self-contained RSC server bundle)
13
+ * ssr/ dist/ssr (rsc imports ../ssr/index.js)
14
+ *
15
+ * A prebuilt .vercel/output gets no `npm install`, so everything the function
16
+ * imports must physically live inside the .func directory. This relies on two
17
+ * things the vercel preset arranges (each is a failure only a real deploy — or
18
+ * the isolated smoke test — catches, since a local in-place run is masked by the
19
+ * app's own package.json + node_modules up the tree):
20
+ *
21
+ * 1. The rsc/ssr builds are fully bundled (`resolve.noExternal`, set in
22
+ * rango.ts for this preset). The node default externalizes node_modules
23
+ * deps, which works under `vite preview` but leaves bare imports
24
+ * (@vercel/functions, react-dom/server.edge, ...) that have no node_modules
25
+ * to resolve against on Vercel.
26
+ * 2. A `package.json` with `"type": "module"` is written into the .func dir.
27
+ * The rsc/ssr bundles are ESM but use a `.js` extension; without a
28
+ * type:module in scope the deployed (isolated) function loads them as
29
+ * CommonJS and fails on the first `import`.
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.
34
+ *
35
+ * Timing: this runs in the `buildApp` hook (order "post"), which fires once
36
+ * after every environment has built, so dist/{client,rsc,ssr} all exist.
37
+ * closeBundle is unusable here — it fires per environment, and twice for ssr
38
+ * (the server-reference scan and the real build), so it would run before
39
+ * dist/client exists. rango's own prerender/static emitters hardcode dist/rsc,
40
+ * so we build to dist/ and restructure here rather than retargeting outDir.
41
+ */
42
+
43
+ import type { Plugin } from "vite";
44
+ import { rm, mkdir, cp, writeFile } from "node:fs/promises";
45
+ import { existsSync } from "node:fs";
46
+ import { resolve, join } from "node:path";
47
+ import { createRequire } from "node:module";
48
+ import { pathToFileURL } from "node:url";
49
+ import { escapeRegExp } from "../../regex-escape.js";
50
+ import type {
51
+ RangoVercelOptions,
52
+ VercelPresetOptions,
53
+ } from "../plugin-types.js";
54
+
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;
62
+ }
63
+ type EsbuildBuild = (options: Record<string, unknown>) => Promise<unknown>;
64
+ interface EsbuildModule {
65
+ build?: EsbuildBuild;
66
+ default?: { build?: EsbuildBuild };
67
+ }
68
+
69
+ const LAUNCHER_SOURCE = `import { toNodeHandler } from "srvx/node";
70
+ import { waitUntil } from "@vercel/functions";
71
+ import rscHandler from "./rsc/index.js";
72
+
73
+ // The Vercel Node launcher invokes a Node (req, res) handler, not a Web fetch
74
+ // handler. srvx's toNodeHandler bridges the Rango Web fetch handler and pipes
75
+ // the streamed Response to the Node response (set supportsResponseStreaming).
76
+ const onVercel = Boolean(process.env.VERCEL);
77
+
78
+ const fetchHandler = (request) =>
79
+ rscHandler(request, {
80
+ env: process.env,
81
+ // Forward Vercel's waitUntil so cache writes / revalidation run off the
82
+ // response path. Omitted off-platform so those writes settle inline.
83
+ ctx: onVercel ? { waitUntil } : undefined,
84
+ });
85
+
86
+ export default toNodeHandler(fetchHandler);
87
+ `;
88
+
89
+ /**
90
+ * Reject a non-Node runtime for the vercel preset. The preset only emits a Node
91
+ * serverless function (launcherType "Nodejs", bundled Node APIs, response
92
+ * streaming). Vercel's Edge runtime needs a different Build Output primitive
93
+ * (EdgeFunction, a Web-handler entry, no node_modules) that this assembler does
94
+ * not produce, so a non-nodejs runtime would emit a config the platform rejects
95
+ * or mis-runs. Fail fast at build time instead of shipping a broken function.
96
+ */
97
+ export function assertVercelNodeRuntime(runtime: string | undefined): void {
98
+ if (runtime != null && !runtime.startsWith("nodejs")) {
99
+ throw new Error(
100
+ `[rango] preset "vercel": runtime "${runtime}" is not supported. ` +
101
+ `This preset emits a Node serverless function; use a "nodejs*" runtime ` +
102
+ `(default "nodejs22.x"). The Edge runtime is not supported.`,
103
+ );
104
+ }
105
+ }
106
+
107
+ /**
108
+ * The function name becomes a `.func` directory segment and the config.json
109
+ * route `dest` (`/${functionName}`). An empty or space/slash-containing value
110
+ * would land raw in both, producing a broken function path, so restrict it to a
111
+ * safe single path segment and fail loudly rather than emit unroutable output.
112
+ */
113
+ export function assertValidVercelFunctionName(functionName: string): void {
114
+ if (!/^[A-Za-z0-9._-]+$/.test(functionName)) {
115
+ throw new Error(
116
+ `[rango] preset "vercel": invalid functionName ${JSON.stringify(
117
+ functionName,
118
+ )}. Use letters, digits, ".", "_" or "-" only (it becomes the .func directory and the routing dest).`,
119
+ );
120
+ }
121
+ }
122
+
123
+ /** The `.vc-config.json` body: a Node serverless function with streaming. */
124
+ export function buildVercelVcConfig(
125
+ vercel: VercelPresetOptions,
126
+ ): Record<string, unknown> {
127
+ const vcConfig: Record<string, unknown> = {
128
+ runtime: vercel.runtime ?? "nodejs22.x",
129
+ handler: "index.mjs",
130
+ launcherType: "Nodejs",
131
+ shouldAddHelpers: false,
132
+ supportsResponseStreaming: true,
133
+ maxDuration: vercel.maxDuration ?? 30,
134
+ };
135
+ if (vercel.memory != null) vcConfig.memory = vercel.memory;
136
+ if (vercel.regions != null) vcConfig.regions = vercel.regions;
137
+ return vcConfig;
138
+ }
139
+
140
+ /**
141
+ * The Build Output API v3 `config.json` body. Routes, in order: long-cache the
142
+ * content-hashed assets under `${assetsDir}/` (safe to serve `immutable`;
143
+ * without this Vercel serves them `max-age=0, must-revalidate` and browsers
144
+ * re-request every asset on each visit), then the filesystem handler, then
145
+ * everything to the function. The header route uses `continue: true` so it falls
146
+ * through to the filesystem handler that actually serves the file.
147
+ *
148
+ * Route `src` values are REGEXES on Vercel, so the prefix is regex-escaped
149
+ * (an unescaped `assetsDir: "static.v2"` would immutable-stamp any
150
+ * `/static?v2/...` path, including function-rendered pages). An empty
151
+ * assetsDir (assets at the outDir root) gets no header route at all: there is
152
+ * no prefix separating hashed from non-hashed files, so fall back to Vercel's
153
+ * safe default headers. Two accepted edges, matching what other framework
154
+ * adapters (Astro, SvelteKit) emit: files a user places in
155
+ * `public/${assetsDir}/` land under the same prefix and are also stamped
156
+ * immutable (assemble() warns about the collision), and a request for a
157
+ * MISSING asset falls through to the function whose 404 carries the header.
158
+ */
159
+ export function buildVercelOutputConfig(
160
+ functionName: string,
161
+ assetsDir: string,
162
+ ): { version: number; routes: unknown[] } {
163
+ const assetsPrefix = assetsDir.replace(/^\/+|\/+$/g, "");
164
+ const assetHeaderRoute = assetsPrefix
165
+ ? [
166
+ {
167
+ src: `/${escapeRegExp(assetsPrefix)}/(.*)`,
168
+ headers: { "cache-control": "public, max-age=31536000, immutable" },
169
+ continue: true,
170
+ },
171
+ ]
172
+ : [];
173
+ return {
174
+ version: 3,
175
+ routes: [
176
+ ...assetHeaderRoute,
177
+ { handle: "filesystem" },
178
+ { src: "/(.*)", dest: `/${functionName}` },
179
+ ],
180
+ };
181
+ }
182
+
183
+ async function assemble(
184
+ root: string,
185
+ options: RangoVercelOptions,
186
+ assetsDir: string,
187
+ publicDir: string,
188
+ ): Promise<void> {
189
+ const vercel = options.vercel ?? {};
190
+ // Validate config before touching the build output.
191
+ assertVercelNodeRuntime(vercel.runtime);
192
+
193
+ // Files in public/<assetsDir>/ are copied into the same output prefix as the
194
+ // content-hashed build assets, so the immutable cache-control route stamps
195
+ // them too -- replacing such a file after a deploy never reaches returning
196
+ // visitors. Warn instead of silently pinning.
197
+ const assetsPrefix = assetsDir.replace(/^\/+|\/+$/g, "");
198
+ if (publicDir && assetsPrefix && existsSync(join(publicDir, assetsPrefix))) {
199
+ console.warn(
200
+ `[rango] preset "vercel": ${join(publicDir, assetsPrefix)} exists. ` +
201
+ `Files under public/${assetsPrefix}/ share the /${assetsPrefix}/ URL prefix ` +
202
+ `with hashed build assets and will be served with a one-year immutable ` +
203
+ `cache-control header. Move un-hashed public files out of "${assetsPrefix}/".`,
204
+ );
205
+ }
206
+
207
+ const dist = join(root, "dist");
208
+ for (const dir of ["client", "rsc", "ssr"]) {
209
+ if (!existsSync(join(dist, dir))) {
210
+ throw new Error(
211
+ `[rango] preset "vercel": missing dist/${dir}. Run the production build first.`,
212
+ );
213
+ }
214
+ }
215
+ const functionName = vercel.functionName ?? "index";
216
+ assertValidVercelFunctionName(functionName);
217
+ const out = join(root, ".vercel", "output");
218
+ const funcDir = join(out, "functions", `${functionName}.func`);
219
+
220
+ await rm(out, { recursive: true, force: true });
221
+ await mkdir(funcDir, { recursive: true });
222
+
223
+ // 1. Static client assets -> served from the CDN at the root.
224
+ await cp(join(dist, "client"), join(out, "static"), { recursive: true });
225
+
226
+ // 2. Server bundle into the function. Preserve the rsc -> ../ssr/index.js
227
+ // relative import by mirroring the dist/{rsc,ssr} layout inside the func.
228
+ await cp(join(dist, "rsc"), join(funcDir, "rsc"), { recursive: true });
229
+ await cp(join(dist, "ssr"), join(funcDir, "ssr"), { recursive: true });
230
+
231
+ // Prerender/static payload manifests (present only when routes are prerendered).
232
+ if (existsSync(join(dist, "static"))) {
233
+ await cp(join(dist, "static"), join(funcDir, "static"), {
234
+ recursive: true,
235
+ });
236
+ }
237
+
238
+ // 3. Bundle the Node launcher. srvx (a @rangojs/router dependency) is aliased
239
+ // to its resolved path; @vercel/functions resolves from the app; the RSC
240
+ // server bundle stays a runtime-relative external.
241
+ const rangoRequire = createRequire(import.meta.url);
242
+ let srvxNodePath: string;
243
+ try {
244
+ srvxNodePath = rangoRequire.resolve("srvx/node");
245
+ } catch {
246
+ throw new Error(
247
+ '[rango] preset "vercel" requires "srvx" (a dependency of @rangojs/router). Reinstall dependencies.',
248
+ );
249
+ }
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
+ }
323
+
324
+ // 3b. Mark the function as ESM. The rsc/ssr bundles are .js ESM files with no
325
+ // package.json in scope on the deployed function (it is isolated at
326
+ // /var/task), so Node would load them as CommonJS and fail on `import`.
327
+ // Locally this is masked because the func inherits the app's
328
+ // "type": "module" up the tree; the deployed func has nothing above it.
329
+ await writeFile(
330
+ join(funcDir, "package.json"),
331
+ JSON.stringify({ type: "module" }, null, 2) + "\n",
332
+ );
333
+
334
+ // 4. Function config: Node serverless with response streaming.
335
+ await writeFile(
336
+ join(funcDir, ".vc-config.json"),
337
+ JSON.stringify(buildVercelVcConfig(vercel), null, 2) + "\n",
338
+ );
339
+
340
+ // 5. Routing config (immutable assets -> filesystem -> function).
341
+ await writeFile(
342
+ join(out, "config.json"),
343
+ JSON.stringify(buildVercelOutputConfig(functionName, assetsDir), null, 2) +
344
+ "\n",
345
+ );
346
+
347
+ console.log(
348
+ `[rango] assembled .vercel/output (function: ${functionName}.func)`,
349
+ );
350
+ }
351
+
352
+ export function createVercelOutputPlugin(options: RangoVercelOptions): Plugin {
353
+ let root = process.cwd();
354
+ let isBuild = false;
355
+ // The client build's assetsDir (Vite default "assets"); used to scope the
356
+ // immutable cache-control route to the content-hashed asset output.
357
+ let assetsDir = "assets";
358
+ // Resolved publicDir ("" when disabled); used to warn when public/<assetsDir>
359
+ // exists, since its un-hashed files share the immutable-header prefix.
360
+ let publicDir = "";
361
+ return {
362
+ name: "@rangojs/router:vercel-output",
363
+ configResolved(config) {
364
+ root = resolve(config.root);
365
+ isBuild = config.command === "build";
366
+ assetsDir =
367
+ config.environments?.client?.build?.assetsDir ??
368
+ config.build?.assetsDir ??
369
+ "assets";
370
+ publicDir = config.publicDir || "";
371
+ },
372
+ // buildApp runs once after the whole multi-environment build (rsc, client,
373
+ // ssr), so dist/ is complete here. closeBundle is unusable for this: it
374
+ // fires per environment, and twice for ssr (the server-reference scan and
375
+ // the real build), so it would run before dist/client exists.
376
+ buildApp: {
377
+ order: "post",
378
+ async handler() {
379
+ if (!isBuild) return;
380
+ await assemble(root, options, assetsDir, publicDir);
381
+ },
382
+ },
383
+ };
384
+ }