@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.
- package/README.md +273 -924
- package/dist/bin/rango.js +28 -14
- package/dist/vite/index.js +472 -77
- package/package.json +33 -19
- package/skills/comparison/SKILL.md +50 -0
- package/skills/comparison/agents/openai.yaml +4 -0
- package/skills/comparison/references/framework-comparison.md +837 -0
- package/skills/host-router/SKILL.md +45 -3
- package/skills/layout/SKILL.md +26 -9
- package/skills/middleware/SKILL.md +6 -2
- package/skills/observability/SKILL.md +23 -1
- package/skills/parallel/SKILL.md +11 -3
- package/skills/rango/SKILL.md +71 -23
- package/skills/route/SKILL.md +19 -3
- package/skills/router-setup/SKILL.md +1 -0
- package/skills/shell-manifest/SKILL.md +185 -0
- package/skills/vercel/SKILL.md +128 -0
- package/src/build/generate-route-types.ts +1 -0
- package/src/build/route-types/router-processing.ts +77 -27
- package/src/cache/index.ts +12 -0
- package/src/cache/vercel/index.ts +11 -0
- package/src/cache/vercel/vercel-cache-store.ts +948 -0
- package/src/host/errors.ts +15 -0
- package/src/host/index.ts +1 -0
- package/src/vercel/index.ts +11 -0
- package/src/vercel/tracing.ts +88 -0
- package/src/vite/discovery/state.ts +1 -1
- package/src/vite/index.ts +2 -0
- package/src/vite/plugin-types.ts +84 -2
- package/src/vite/plugins/vercel-output.ts +384 -0
- package/src/vite/plugins/virtual-entries.ts +72 -0
- package/src/vite/rango.ts +128 -21
- package/src/vite/utils/shared-utils.ts +52 -2
package/src/host/errors.ts
CHANGED
|
@@ -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
|
@@ -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
package/src/vite/plugin-types.ts
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|