@rangojs/router 0.0.0-experimental.150 → 0.0.0-experimental.151

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.
@@ -108,6 +108,27 @@ export interface RequestContext<
108
108
  /** @internal Stub response for collecting headers/cookies. Use ctx.headers or ctx.header() instead. */
109
109
  readonly res: Response;
110
110
 
111
+ /**
112
+ * True for build-time render/capture requests. Live requests use false.
113
+ * Build-time shell capture sets this while replaying middleware so apps can
114
+ * skip side-effectful runtime work.
115
+ */
116
+ readonly build: boolean;
117
+
118
+ /**
119
+ * Opt this request out of PPR shell serving/capture.
120
+ * Runtime middleware can call this before the PPR commit point; handlers can
121
+ * call it on a MISS to prevent the follow-up capture.
122
+ *
123
+ * Scope: the PPR SHELL axis only. It does NOT disable prerender B-segment
124
+ * (Prerender/Static) serving, and it is inert in the prerender-collect /
125
+ * static-render contexts (no live shell decision to influence there).
126
+ */
127
+ dynamic(): void;
128
+
129
+ /** @internal Request-local PPR opt-out marker set by ctx.dynamic(). */
130
+ _dynamic?: boolean;
131
+
111
132
  /** @internal Get a cookie value (effective: request + response mutations). Use cookies().get() instead. */
112
133
  cookie(name: string): string | undefined;
113
134
  /** @internal Get all cookies (effective merged view). Use cookies().getAll() instead. */
@@ -628,6 +649,7 @@ export type PublicRequestContext<
628
649
  | "_variables"
629
650
  | "_classifiedRoute"
630
651
  | "_cacheSignal"
652
+ | "_dynamic"
631
653
  | "res"
632
654
  >;
633
655
 
@@ -775,6 +797,8 @@ export interface CreateRequestContextOptions<TEnv> {
775
797
  >;
776
798
  /** Optional Cloudflare execution context for waitUntil support */
777
799
  executionContext?: ExecutionContext;
800
+ /** Build-time render/capture request marker. Defaults to false. */
801
+ build?: boolean;
778
802
  /** Optional theme configuration (enables ctx.theme and ctx.setTheme) */
779
803
  themeConfig?: ResolvedThemeConfig | null;
780
804
  /** Resolved rango state cookie name, for the server seat of invalidateClientCache(). */
@@ -804,6 +828,7 @@ export function createRequestContext<TEnv>(
804
828
  explicitTaggedStores,
805
829
  cacheProfiles,
806
830
  executionContext,
831
+ build = false,
807
832
  themeConfig,
808
833
  stateCookieName,
809
834
  version: stateVersion,
@@ -951,6 +976,11 @@ export function createRequestContext<TEnv>(
951
976
  pathname: url.pathname,
952
977
  searchParams: cleanUrl.searchParams,
953
978
  _variables: variables,
979
+ build,
980
+ dynamic(): void {
981
+ ctx._dynamic = true;
982
+ },
983
+ _dynamic: false,
954
984
  get: ((keyOrVar: any) => {
955
985
  if (isNonCacheable(variables, keyOrVar) && isInsideCacheScope()) {
956
986
  throw new Error(
@@ -1089,6 +1119,7 @@ export function createRequestContext<TEnv>(
1089
1119
  _cacheProfiles: cacheProfiles,
1090
1120
 
1091
1121
  waitUntil(fn: () => Promise<void>): void {
1122
+ if (ctx.build) return;
1092
1123
  // Wrap in Promise.resolve().then(fn) so a SYNCHRONOUS throw in a
1093
1124
  // non-async callback becomes a rejected promise handed to the host's
1094
1125
  // waitUntil (logged as a background failure), instead of escaping into
@@ -55,6 +55,14 @@ export interface CreateTestContextOptions<TEnv> {
55
55
  routeMap?: Record<string, string>;
56
56
  routeName?: string;
57
57
  params?: Record<string, string>;
58
+ /**
59
+ * Seed `ctx.build` (default false). Set `true` to unit-test middleware or a
60
+ * handler that branches on the build-time PPR shell-capture pass — e.g.
61
+ * `if (ctx.build) ctx.dynamic()`. Mirrors the synthetic build request the
62
+ * shell-capture producer creates; also makes `ctx.waitUntil()` inert, as at
63
+ * build time.
64
+ */
65
+ build?: boolean;
58
66
  /**
59
67
  * Router basename for this request (what the RSC handler stores on the
60
68
  * context). Drives redirect() prefixing. Normalized exactly like
@@ -150,6 +158,7 @@ export function createTestRequestContext<TEnv>(
150
158
  request,
151
159
  url,
152
160
  variables,
161
+ build: opts.build,
153
162
  themeConfig:
154
163
  opts.theme === undefined ? undefined : resolveThemeConfig(opts.theme),
155
164
  cacheStore: opts.cacheStore,
@@ -80,6 +80,12 @@ export interface RenderHandlerOptions<TEnv = any> {
80
80
  routeName?: string;
81
81
  /** Route name -> pattern map enabling `ctx.reverse()`. */
82
82
  routeMap?: Record<string, string>;
83
+ /**
84
+ * Seed `ctx.build` (default false) so a handler that branches on the
85
+ * build-time pass — including calling `ctx.dynamic()` on a MISS — is
86
+ * unit-testable. Assert a `ctx.dynamic()` call via `result.dynamic`.
87
+ */
88
+ build?: boolean;
83
89
  /**
84
90
  * Seed the data `ctx.use(SomeLoader)` returns — NO real loader runs (same model
85
91
  * as `runLoader`'s `loaders`). Matched by loader reference, so a real
@@ -163,6 +169,12 @@ export interface RenderHandlerResult {
163
169
  locationState: Record<string, unknown>;
164
170
  /** What the handler pushed via `ctx.use(Handle)(...)` (e.g. Meta, Breadcrumbs), keyed by handle. */
165
171
  handles: Map<Handle<any, any>, unknown[]>;
172
+ /**
173
+ * Whether the handler called `ctx.dynamic()` (the PPR shell opt-out). The
174
+ * public way to assert the opt-out without reading the `@internal`
175
+ * `ctx._dynamic`.
176
+ */
177
+ dynamic: boolean;
166
178
  }
167
179
 
168
180
  /**
@@ -239,6 +251,7 @@ export async function renderHandler<TEnv = any>(
239
251
  request,
240
252
  url,
241
253
  variables: seedVariables({}, opts.vars),
254
+ build: opts.build,
242
255
  stateCookieName,
243
256
  version: opts.stateCookie?.version,
244
257
  cacheStore: opts.cacheStore,
@@ -353,5 +366,6 @@ export async function renderHandler<TEnv = any>(
353
366
  stateCookieName,
354
367
  locationState,
355
368
  handles: handlePushes,
369
+ dynamic: (reqCtx as RequestContext<TEnv>)._dynamic === true,
356
370
  };
357
371
  }
@@ -49,6 +49,13 @@ export interface RunMiddlewareOptions<TEnv = any> {
49
49
  env?: TEnv;
50
50
  /** Route params surfaced as `ctx.params`. */
51
51
  params?: Record<string, string>;
52
+ /**
53
+ * Seed `ctx.build` (default false) so a middleware that branches on the
54
+ * build-time PPR shell-capture pass (e.g. `if (ctx.build) ctx.dynamic()`) is
55
+ * unit-testable. With `build: true`, `ctx.waitUntil()` is inert, matching the
56
+ * build producer. Assert a `ctx.dynamic()` call via `result.dynamic`.
57
+ */
58
+ build?: boolean;
52
59
  /** Variables a prior middleware would have set (object or [key, value] list). */
53
60
  vars?: VarsInit;
54
61
  /** Route name -> pattern map enabling `ctx.reverse()`. */
@@ -102,6 +109,11 @@ export interface RunMiddlewareResult<TEnv = any> {
102
109
  ctx: RequestContext<TEnv>;
103
110
  /** Number of times the terminal handler ran (0 = short-circuited, 1 = passed through). */
104
111
  nextCalled: number;
112
+ /**
113
+ * Whether the chain called `ctx.dynamic()` (the PPR shell opt-out). The public
114
+ * way to assert the opt-out without reading the `@internal` `ctx._dynamic`.
115
+ */
116
+ dynamic: boolean;
105
117
  /**
106
118
  * The effective cookie view after the chain ran: request cookies merged with
107
119
  * anything the chain set or deleted (last-write-wins), as `{ name: value }`.
@@ -146,6 +158,7 @@ export async function runMiddleware<TEnv = any>(
146
158
  routeMap: opts.routeMap,
147
159
  routeName: opts.routeName,
148
160
  params: opts.params,
161
+ build: opts.build,
149
162
  basename: opts.basename,
150
163
  theme: opts.theme,
151
164
  cacheStore: opts.cacheStore,
@@ -197,6 +210,7 @@ export async function runMiddleware<TEnv = any>(
197
210
  response,
198
211
  ctx,
199
212
  nextCalled,
213
+ dynamic: ctx._dynamic === true,
200
214
  cookies,
201
215
  headers,
202
216
  locationState,
@@ -202,7 +202,18 @@ export type HandlerContext<
202
202
  * Build-time collection and dev on-demand prerender use `true`.
203
203
  * Live request rendering, including passthrough fallback, uses `false`.
204
204
  */
205
- build: boolean;
205
+ readonly build: boolean;
206
+ /**
207
+ * Opt this request out of PPR shell serving/capture.
208
+ * Middleware can call this before a shell HIT commits. Handlers run after
209
+ * that commit point, so they only prevent scheduling a new capture on MISS.
210
+ *
211
+ * Scope: this gates the PPR SHELL axis only. It does NOT disable prerender
212
+ * B-segment (Prerender/Static) serving — a Prerender() route's build-baked
213
+ * segments still replay at runtime — and it is inert in the prerender-collect
214
+ * and static-render contexts, which have no live shell decision to influence.
215
+ */
216
+ dynamic(): void;
206
217
  /**
207
218
  * True when running in Vite dev mode, false during production build or
208
219
  * live request rendering. Use this to branch on runtime mode without
@@ -150,7 +150,6 @@ export async function discoverRouters(
150
150
  const newPerRouterManifestDataMap = new Map<string, any>();
151
151
  const newPerRouterPrecomputedMap = new Map<string, PrecomputedEntry[]>();
152
152
  const newPerRouterTrieMap = new Map<string, any>();
153
- let mergedRouteAncestry: Record<string, string[]> = {};
154
153
  let mergedRouteTrailingSlash: Record<string, string> = {};
155
154
 
156
155
  let routerMountIndex = 0;
@@ -245,10 +244,6 @@ export async function discoverRouters(
245
244
  factoryOnlyPrefixes,
246
245
  });
247
246
 
248
- // Merge ancestry (internal field, used only for trie building)
249
- if (manifest._routeAncestry) {
250
- Object.assign(mergedRouteAncestry, manifest._routeAncestry);
251
- }
252
247
  // Merge trailing slash config
253
248
  if (manifest.routeTrailingSlash) {
254
249
  Object.assign(mergedRouteTrailingSlash, manifest.routeTrailingSlash);
@@ -303,64 +298,61 @@ export async function discoverRouters(
303
298
  (performance.now() - manifestGenStart).toFixed(1),
304
299
  );
305
300
 
306
- // Build route trie from merged manifest + ancestry
301
+ // Build route trie from merged manifest
307
302
  let newMergedRouteTrie: any = null;
308
303
  const trieStart = debug ? performance.now() : 0;
309
304
  if (Object.keys(newMergedRouteManifest).length > 0) {
310
- if (mergedRouteAncestry) {
311
- // Build routeToStaticPrefix from saved manifests
312
- const routeToStaticPrefix: Record<string, string> = {};
313
- for (const { manifest } of allManifests) {
314
- // Root-level routes have empty static prefix
315
- for (const name of Object.keys(manifest.routeManifest)) {
316
- if (!(name in routeToStaticPrefix)) {
317
- routeToStaticPrefix[name] = "";
318
- }
305
+ // Build routeToStaticPrefix from saved manifests
306
+ const routeToStaticPrefix: Record<string, string> = {};
307
+ for (const { manifest } of allManifests) {
308
+ // Root-level routes have empty static prefix
309
+ for (const name of Object.keys(manifest.routeManifest)) {
310
+ if (!(name in routeToStaticPrefix)) {
311
+ routeToStaticPrefix[name] = "";
319
312
  }
320
- buildRouteToStaticPrefix(manifest.prefixTree, routeToStaticPrefix);
321
313
  }
314
+ buildRouteToStaticPrefix(manifest.prefixTree, routeToStaticPrefix);
315
+ }
322
316
 
323
- // Collect prerender route names and response type routes from all manifests
324
- const prerenderRouteNames = new Set<string>();
325
- const passthroughRouteNames = new Set<string>();
326
- const mergedResponseTypeRoutes: Record<string, string> = {};
327
- for (const { manifest } of allManifests) {
328
- if (manifest.prerenderRoutes) {
329
- for (const name of manifest.prerenderRoutes) {
330
- prerenderRouteNames.add(name);
331
- }
317
+ // Collect prerender route names and response type routes from all manifests
318
+ const prerenderRouteNames = new Set<string>();
319
+ const passthroughRouteNames = new Set<string>();
320
+ const mergedResponseTypeRoutes: Record<string, string> = {};
321
+ for (const { manifest } of allManifests) {
322
+ if (manifest.prerenderRoutes) {
323
+ for (const name of manifest.prerenderRoutes) {
324
+ prerenderRouteNames.add(name);
332
325
  }
333
- if (manifest.passthroughRoutes) {
334
- for (const name of manifest.passthroughRoutes) {
335
- passthroughRouteNames.add(name);
336
- }
337
- }
338
- if (manifest.responseTypeRoutes) {
339
- Object.assign(mergedResponseTypeRoutes, manifest.responseTypeRoutes);
326
+ }
327
+ if (manifest.passthroughRoutes) {
328
+ for (const name of manifest.passthroughRoutes) {
329
+ passthroughRouteNames.add(name);
340
330
  }
341
331
  }
332
+ if (manifest.responseTypeRoutes) {
333
+ Object.assign(mergedResponseTypeRoutes, manifest.responseTypeRoutes);
334
+ }
335
+ }
342
336
 
343
- // buildRouteTrie reads these via ?.has / ?.[] — empty is observationally
344
- // identical to undefined, so no empty->undefined coercion is needed.
345
- newMergedRouteTrie = buildRouteTrie(
346
- newMergedRouteManifest,
347
- mergedRouteAncestry,
348
- routeToStaticPrefix,
349
- mergedRouteTrailingSlash,
350
- prerenderRouteNames,
351
- passthroughRouteNames,
352
- mergedResponseTypeRoutes,
353
- );
337
+ // buildRouteTrie reads these via ?.has / ?.[] — empty is observationally
338
+ // identical to undefined, so no empty->undefined coercion is needed.
339
+ newMergedRouteTrie = buildRouteTrie(
340
+ newMergedRouteManifest,
341
+ routeToStaticPrefix,
342
+ mergedRouteTrailingSlash,
343
+ prerenderRouteNames,
344
+ passthroughRouteNames,
345
+ mergedResponseTypeRoutes,
346
+ );
354
347
 
355
- // Build per-router tries for multi-router isolation. Uses the single
356
- // shared buildPerRouterTrie so the production serialized trie is built by
357
- // exactly the same code as the dev/HMR runtime rebuild (manifest-init.ts).
358
- // Returns null for route-less manifests (route-trie.ts).
359
- for (const { id, manifest } of allManifests) {
360
- const perRouterTrie = buildPerRouterTrie(manifest);
361
- if (perRouterTrie) {
362
- newPerRouterTrieMap.set(id, perRouterTrie);
363
- }
348
+ // Build per-router tries for multi-router isolation. Uses the single
349
+ // shared buildPerRouterTrie so the production serialized trie is built by
350
+ // exactly the same code as the dev/HMR runtime rebuild (manifest-init.ts).
351
+ // Returns null for route-less manifests (route-trie.ts).
352
+ for (const { id, manifest } of allManifests) {
353
+ const perRouterTrie = buildPerRouterTrie(manifest);
354
+ if (perRouterTrie) {
355
+ newPerRouterTrieMap.set(id, perRouterTrie);
364
356
  }
365
357
  }
366
358
  }
@@ -5,11 +5,25 @@
5
5
  * per-router virtual modules used by the load() hook.
6
6
  */
7
7
 
8
+ import { mkdirSync, writeFileSync } from "node:fs";
8
9
  import { dirname, basename, join } from "node:path";
9
10
  import { jsonParseExpression } from "../utils/manifest-utils.js";
10
11
  import { VIRTUAL_ROUTES_MANIFEST_ID } from "./state.js";
11
12
  import type { DiscoveryState } from "./state.js";
12
13
 
14
+ /**
15
+ * Serialized-payload size (bytes) at or above which the trie/precomputedEntries
16
+ * are shipped through a runtime channel (Text module / `?raw` import) instead of
17
+ * an inline `JSON.parse` literal (issue #665). 512KB is ~2500 routes — the
18
+ * crossover where the channel's benefit (removing the JS compile on cloudflare,
19
+ * reclaiming ~19% escaping bloat everywhere) clears the cost of an extra
20
+ * artifact. Below it the inline literal wins (sub-millisecond compile, ~0 gain),
21
+ * so the whole long tail of normal apps stays byte-identical. The compile/parse
22
+ * cost tracks bytes, not route count, so this stays a byte threshold.
23
+ * RANGO_MANIFEST_TEXT=1 bypasses it.
24
+ */
25
+ const MANIFEST_EXTERNALIZE_THRESHOLD = 512 * 1024;
26
+
13
27
  /**
14
28
  * Generate the code for the main virtual:rsc-router/routes-manifest module.
15
29
  */
@@ -137,6 +151,22 @@ export function generateRoutesManifestModule(state: DiscoveryState): string {
137
151
  return `// Route manifest will be populated at runtime`;
138
152
  }
139
153
 
154
+ /**
155
+ * Deterministic short hash (FNV-1a, base36) used to disambiguate staged
156
+ * manifest filenames. The sanitized `safeId` alone collapses distinct router
157
+ * ids — e.g. "a/b" and "a.b" both sanitize to "a_b" — which in a multi-router
158
+ * build would overwrite one router's staged manifest with another's. Appending
159
+ * a hash of the ORIGINAL id keeps them distinct.
160
+ */
161
+ function shortHash(input: string): string {
162
+ let h = 0x811c9dc5;
163
+ for (let i = 0; i < input.length; i++) {
164
+ h ^= input.charCodeAt(i);
165
+ h = Math.imul(h, 0x01000193);
166
+ }
167
+ return (h >>> 0).toString(36);
168
+ }
169
+
140
170
  /**
141
171
  * Generate the code for a per-router virtual module.
142
172
  */
@@ -171,13 +201,93 @@ export function generatePerRouterModule(
171
201
  lines.push(`export const manifest = ${jsonParseExpression(manifest)};`);
172
202
  }
173
203
  }
174
- if (trie) {
204
+ const hasTrie = !!trie;
205
+ const hasEntries = !!entries && entries.length > 0;
206
+
207
+ // The trie + precomputedEntries are the largest generated data. Inlined as a
208
+ // `JSON.parse('<literal>')` in the JS chunk, the isolate/process must LEX and
209
+ // compile that multi-MB source literal on first request before JSON.parse even
210
+ // runs — pure overhead, since it is data, not logic (issue #665). It also
211
+ // ships bloated: the build minifier re-quotes the literal to double quotes,
212
+ // escaping every JSON `"` as `\"` (measured 5.71MB vs 4.64MB un-escaped at 26k
213
+ // routes).
214
+ //
215
+ // The ONLY way to fix both is to keep the JSON out of JavaScript source — as a
216
+ // raw asset file that never passes through the JS minifier/compiler. On
217
+ // cloudflare that is a workerd Text module: import the staged `.txt` and the
218
+ // isolate gets the raw un-escaped bytes as a string with no compile pass
219
+ // (measured ~17% / ~88ms cold first-hit on a real edge deploy at 26k, and it
220
+ // shrinks the worker upload). The CF vite plugin types `.txt` as Text;
221
+ // wrangler's default rules type **/*.txt as Text for deploy and dev.
222
+ //
223
+ // Scoped to cloudflare deliberately. A Text module is the only no-JS byte
224
+ // channel that needs no filesystem, and cloudflare is where it pays: edge cold
225
+ // isolates spin up per-request under load and on every deploy, and Workers
226
+ // have a hard upload-size limit. node/vercel fall through to the inline
227
+ // literal below — an efficient runtime channel there needs `fs` (a raw asset
228
+ // read), whose fragility (asset co-location, runtime ENOENT) is not worth a
229
+ // large-app-only win that Fluid Compute amortizes and that essentially no real
230
+ // app reaches. `?raw` is NOT an option: in this build it inlines the JSON as a
231
+ // (double-quoted, compiled) JS string literal — same bloat, same compile as
232
+ // the inline form, plus a staged file for nothing.
233
+ //
234
+ // Build mode only (dev rebuilds the manifest per HMR; parse cost is
235
+ // irrelevant). Below MANIFEST_EXTERNALIZE_THRESHOLD the inline literal wins.
236
+ // RANGO_MANIFEST_TEXT overrides: "0" forces inline, "1" forces the Text module
237
+ // and bypasses the threshold (used by the cloudflare-basic dogfood e2e, whose
238
+ // real manifest is below the threshold).
239
+ const preset = state.opts?.preset ?? "node";
240
+ const override = process.env.RANGO_MANIFEST_TEXT;
241
+ const payload = manifestPayload(trie, hasTrie, entries, hasEntries);
242
+ const payloadJson = hasTrie || hasEntries ? JSON.stringify(payload) : "";
243
+ const useTextModule =
244
+ state.isBuildMode &&
245
+ preset === "cloudflare" &&
246
+ override !== "0" &&
247
+ (hasTrie || hasEntries) &&
248
+ (override === "1" || payloadJson.length >= MANIFEST_EXTERNALIZE_THRESHOLD);
249
+
250
+ if (useTextModule) {
251
+ const dir = join(state.projectRoot, "node_modules", ".rango");
252
+ mkdirSync(dir, { recursive: true });
253
+ const safeId = `${routerId.replace(/[^a-zA-Z0-9_-]/g, "_")}-${shortHash(routerId)}`;
254
+ const filePath = join(dir, `manifest-${safeId}.txt`).replaceAll("\\", "/");
255
+ writeFileSync(filePath, payloadJson);
256
+ lines.push(`import __manifestJson from ${JSON.stringify(filePath)};`);
257
+ lines.push(`const __manifestData = JSON.parse(__manifestJson);`);
258
+ emitManifestExports(lines, hasTrie, hasEntries);
259
+ return lines.join("\n");
260
+ }
261
+
262
+ if (hasTrie) {
175
263
  lines.push(`export const trie = ${jsonParseExpression(trie)};`);
176
264
  }
177
- if (entries && entries.length > 0) {
265
+ if (hasEntries) {
178
266
  lines.push(
179
267
  `export const precomputedEntries = ${jsonParseExpression(entries)};`,
180
268
  );
181
269
  }
182
270
  return lines.join("\n") || "";
183
271
  }
272
+
273
+ function manifestPayload(
274
+ trie: unknown,
275
+ hasTrie: boolean,
276
+ entries: unknown,
277
+ hasEntries: boolean,
278
+ ): Record<string, unknown> {
279
+ const payload: Record<string, unknown> = {};
280
+ if (hasTrie) payload.t = trie;
281
+ if (hasEntries) payload.p = entries;
282
+ return payload;
283
+ }
284
+
285
+ function emitManifestExports(
286
+ lines: string[],
287
+ hasTrie: boolean,
288
+ hasEntries: boolean,
289
+ ): void {
290
+ if (hasTrie) lines.push(`export const trie = __manifestData.t;`);
291
+ if (hasEntries)
292
+ lines.push(`export const precomputedEntries = __manifestData.p;`);
293
+ }
package/src/vite/rango.ts CHANGED
@@ -46,6 +46,31 @@ import { createRangoDebugger, NS } from "./debug.js";
46
46
 
47
47
  const debugConfig = createRangoDebugger(NS.config);
48
48
 
49
+ /**
50
+ * Syntax target for the node/vercel server (`ssr` + `rsc`) build environments.
51
+ *
52
+ * Vite 8's build-target default is `baseline-widely-available`, which resolves
53
+ * to a BROWSER baseline (`chrome111`/`edge111`/…, ≈ES2022) and is applied
54
+ * per-environment with NO server carve-out: `resolveRolldownOptions` pipes it
55
+ * straight into rolldown/oxc's `transform.target` for `consumer:"server"` chunks
56
+ * too. So without an explicit server target our ssr/rsc bundles — which only run
57
+ * on Node or workerd — get compiled for browsers, needlessly rewriting modern
58
+ * syntax (e.g. Explicit Resource Management `using`/`await using`) into
59
+ * `_usingCtx()` runtime helpers. `esnext` emits server code at authored
60
+ * modernity: no helpers, marginally smaller/faster bundles, semantics that match
61
+ * reality. The `client` env is intentionally left at the browser baseline.
62
+ *
63
+ * The cloudflare preset does NOT use this: `@cloudflare/vite-plugin` hardcodes
64
+ * `es2024` on its server envs and its `config()` merges after ours, so anything
65
+ * we set there is a dead no-op (workerd runs es2024 fine).
66
+ *
67
+ * Caveat: `esnext` stops downleveling ERM `using`, which parses natively only on
68
+ * Node 24+ (workerd runs it). Ordinary ≤ES2022 server code still runs across the
69
+ * whole supported Node range; only an app that authors `using` AND deploys to
70
+ * Node < 24 would need to downlevel it itself. See issue #729.
71
+ */
72
+ const SERVER_BUILD_TARGET = "esnext";
73
+
49
74
  // The leading-directive 'use client' sniff is shared with version-plugin's
50
75
  // getClientModuleSignature so the two cannot drift. Imported for local use by the
51
76
  // HMR transform below and re-exported because the E8 sniff test imports it from
@@ -409,6 +434,9 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
409
434
  },
410
435
  ssr: {
411
436
  ...(vercelServerEnv ?? {}),
437
+ build: {
438
+ target: SERVER_BUILD_TARGET,
439
+ },
412
440
  optimizeDeps: {
413
441
  entries: [VIRTUAL_IDS.ssr],
414
442
  include: [
@@ -428,6 +456,9 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
428
456
  },
429
457
  rsc: {
430
458
  ...(vercelServerEnv ?? {}),
459
+ build: {
460
+ target: SERVER_BUILD_TARGET,
461
+ },
431
462
  optimizeDeps: {
432
463
  entries: [VIRTUAL_IDS.rsc],
433
464
  include: [