@rangojs/router 0.30.1 → 0.31.0

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.
@@ -26,6 +26,8 @@ import {
26
26
  } from "./include-resolution.js";
27
27
  import { findUrlsVariableNames } from "./per-module-writer.js";
28
28
  import { isAutoGeneratedRouteName } from "../../route-name.js";
29
+ import { normalizeBasename } from "../../router/basename.js";
30
+ import { getStringValue } from "./ast-helpers.js";
29
31
 
30
32
  function countPublicRouteEntries(source: string): number {
31
33
  const matches =
@@ -232,15 +234,16 @@ function collectCreateRouterCallChain(
232
234
  }
233
235
  }
234
236
 
235
- function extractAllUrlsFromRouter(code: string): UrlsExtractionResult[] {
236
- const sourceFile = ts.createSourceFile(
237
- "router.tsx",
238
- code,
239
- ts.ScriptTarget.Latest,
240
- true,
241
- ts.ScriptKind.TSX,
242
- );
243
- let result: UrlsExtractionResult[] | null = null;
237
+ // The first createRouter chain (in source order) that mounts urls, with its
238
+ // extractions in runtime order. Route types and basename both come from it.
239
+ function findRouterUrls(sourceFile: ts.SourceFile): {
240
+ createRouterCall: ts.CallExpression;
241
+ extractions: UrlsExtractionResult[];
242
+ } | null {
243
+ let result: {
244
+ createRouterCall: ts.CallExpression;
245
+ extractions: UrlsExtractionResult[];
246
+ } | null = null;
244
247
 
245
248
  function visit(node: ts.Node) {
246
249
  if (result) return;
@@ -279,7 +282,7 @@ function extractAllUrlsFromRouter(code: string): UrlsExtractionResult[] {
279
282
  }
280
283
 
281
284
  if (extracted.length > 0) {
282
- result = extracted;
285
+ result = { createRouterCall, extractions: extracted };
283
286
  return;
284
287
  }
285
288
  }
@@ -288,7 +291,18 @@ function extractAllUrlsFromRouter(code: string): UrlsExtractionResult[] {
288
291
  }
289
292
 
290
293
  visit(sourceFile);
291
- return result ?? [];
294
+ return result;
295
+ }
296
+
297
+ function extractAllUrlsFromRouter(code: string): UrlsExtractionResult[] {
298
+ const sourceFile = ts.createSourceFile(
299
+ "router.tsx",
300
+ code,
301
+ ts.ScriptTarget.Latest,
302
+ true,
303
+ ts.ScriptKind.TSX,
304
+ );
305
+ return findRouterUrls(sourceFile)?.extractions ?? [];
292
306
  }
293
307
 
294
308
  /**
@@ -307,48 +321,189 @@ export function extractUrlsFromRouter(
307
321
  return extractions[extractions.length - 1] ?? null;
308
322
  }
309
323
 
324
+ function unwrapTypeOperators(expr: ts.Expression): ts.Expression {
325
+ while (ts.isAsExpression(expr) || ts.isSatisfiesExpression(expr)) {
326
+ expr = expr.expression;
327
+ }
328
+ return expr;
329
+ }
330
+
331
+ function hasModifier(statement: ts.Statement, kind: ts.SyntaxKind): boolean {
332
+ return (
333
+ ts.canHaveModifiers(statement) &&
334
+ (ts.getModifiers(statement)?.some((m) => m.kind === kind) ?? false)
335
+ );
336
+ }
337
+
338
+ // `exported` limits the lookup to `export const name = ...`; an export list or
339
+ // re-export is not followed.
340
+ function findTopLevelConst(
341
+ sourceFile: ts.SourceFile,
342
+ name: string,
343
+ exported: boolean,
344
+ ): ts.Expression | undefined {
345
+ for (const statement of sourceFile.statements) {
346
+ if (
347
+ ts.isVariableStatement(statement) &&
348
+ statement.declarationList.flags & ts.NodeFlags.Const &&
349
+ !hasModifier(statement, ts.SyntaxKind.DeclareKeyword) &&
350
+ (!exported || hasModifier(statement, ts.SyntaxKind.ExportKeyword))
351
+ ) {
352
+ for (const decl of statement.declarationList.declarations) {
353
+ if (ts.isIdentifier(decl.name) && decl.name.text === name) {
354
+ return decl.initializer && unwrapTypeOperators(decl.initializer);
355
+ }
356
+ }
357
+ }
358
+ }
359
+ return undefined;
360
+ }
361
+
362
+ // The value import binding `name` in the router file: a named import
363
+ // (`import { X }` / `import { Y as X }`). Read from the AST, so a commented-out
364
+ // or quoted import never matches. Default and namespace imports are not read.
365
+ function findNamedImport(
366
+ sourceFile: ts.SourceFile,
367
+ name: string,
368
+ ): { specifier: string; exportedName: string } | undefined {
369
+ for (const statement of sourceFile.statements) {
370
+ if (
371
+ !ts.isImportDeclaration(statement) ||
372
+ !ts.isStringLiteral(statement.moduleSpecifier) ||
373
+ statement.importClause?.isTypeOnly
374
+ ) {
375
+ continue;
376
+ }
377
+ const bindings = statement.importClause?.namedBindings;
378
+ if (!bindings || !ts.isNamedImports(bindings)) continue;
379
+ for (const el of bindings.elements) {
380
+ if (!el.isTypeOnly && el.name.text === name) {
381
+ return {
382
+ specifier: statement.moduleSpecifier.text,
383
+ exportedName: el.propertyName?.text ?? name,
384
+ };
385
+ }
386
+ }
387
+ }
388
+ return undefined;
389
+ }
390
+
391
+ // The top-level const `name` refers to: declared in the router file or
392
+ // exported as a const by a relative module it is imported from by name.
393
+ function resolveTopLevelConst(
394
+ name: string,
395
+ sourceFile: ts.SourceFile,
396
+ routerFilePath: string,
397
+ ): ts.Expression | undefined {
398
+ const local = findTopLevelConst(sourceFile, name, false);
399
+ if (local) return local;
400
+ const imported = findNamedImport(sourceFile, name);
401
+ if (!imported) return undefined;
402
+ const target = resolveImportPath(imported.specifier, routerFilePath);
403
+ if (!target) return undefined;
404
+ const targetSource = readFileSync(target, "utf-8");
405
+ return findTopLevelConst(
406
+ ts.createSourceFile(target, targetSource, ts.ScriptTarget.Latest, true),
407
+ imported.exportedName,
408
+ true,
409
+ );
410
+ }
411
+
412
+ // Inside any nested scope a local declaration can shadow the top-level
413
+ // binding, and resolving it anyway would emit a wrong prefix.
414
+ function isTopLevelScope(node: ts.Node): boolean {
415
+ for (let p = node.parent; !ts.isSourceFile(p); p = p.parent) {
416
+ if (
417
+ ts.isFunctionLike(p) ||
418
+ ts.isClassLike(p) ||
419
+ ts.isBlock(p) ||
420
+ ts.isCaseBlock(p) ||
421
+ ts.isModuleBlock(p) ||
422
+ ts.isIterationStatement(p, false)
423
+ ) {
424
+ return false;
425
+ }
426
+ }
427
+ return true;
428
+ }
429
+
310
430
  /**
311
- * Extract the `basename` string literal from createRouter({ basename: "..." }).
312
- * Returns the basename value or undefined if not present.
431
+ * Read inline createRouter({ basename: X }) where X is a string literal, a
432
+ * top-level const, or `Const.prop` on a top-level const object literal.
433
+ * `unresolved` is the source of any other X.
313
434
  */
314
- export function extractBasenameFromRouter(code: string): string | undefined {
435
+ function resolveRouterBasename(
436
+ routerSource: string,
437
+ routerFilePath: string,
438
+ ): { value?: string; unresolved?: string } {
315
439
  const sourceFile = ts.createSourceFile(
316
- "router.tsx",
317
- code,
440
+ routerFilePath,
441
+ routerSource,
318
442
  ts.ScriptTarget.Latest,
319
443
  true,
320
444
  ts.ScriptKind.TSX,
321
445
  );
322
- let result: string | undefined;
323
-
324
- function visit(node: ts.Node) {
325
- if (result !== undefined) return;
326
- if (
327
- ts.isCallExpression(node) &&
328
- ts.isIdentifier(node.expression) &&
329
- node.expression.text === "createRouter"
446
+ // Read from the createRouter call whose urls the route types use. Only an
447
+ // explicit `basename` key counts: one inside a spread
448
+ // (`createRouter({ ...shared })`) or a non-literal options argument stays
449
+ // unread and unreported.
450
+ const options = findRouterUrls(sourceFile)?.createRouterCall.arguments[0];
451
+ const prop =
452
+ options && ts.isObjectLiteralExpression(options)
453
+ ? options.properties.find(
454
+ (p): p is ts.PropertyAssignment | ts.ShorthandPropertyAssignment =>
455
+ (ts.isPropertyAssignment(p) ||
456
+ ts.isShorthandPropertyAssignment(p)) &&
457
+ ts.isIdentifier(p.name) &&
458
+ p.name.text === "basename",
459
+ )
460
+ : undefined;
461
+ if (!prop) return {};
462
+
463
+ const valueExpr = ts.isPropertyAssignment(prop)
464
+ ? prop.initializer
465
+ : prop.name;
466
+ const expr = unwrapTypeOperators(valueExpr);
467
+ let value = getStringValue(expr);
468
+ if (value === null && isTopLevelScope(prop)) {
469
+ if (ts.isIdentifier(expr)) {
470
+ const init = resolveTopLevelConst(expr.text, sourceFile, routerFilePath);
471
+ value = init ? getStringValue(init) : null;
472
+ } else if (
473
+ ts.isPropertyAccessExpression(expr) &&
474
+ ts.isIdentifier(expr.expression)
330
475
  ) {
331
- for (const arg of node.arguments) {
332
- if (ts.isObjectLiteralExpression(arg)) {
333
- for (const prop of arg.properties) {
334
- if (
335
- ts.isPropertyAssignment(prop) &&
336
- ts.isIdentifier(prop.name) &&
337
- prop.name.text === "basename" &&
338
- ts.isStringLiteral(prop.initializer)
339
- ) {
340
- result = prop.initializer.text;
341
- return;
342
- }
343
- }
344
- }
345
- }
476
+ const owner = resolveTopLevelConst(
477
+ expr.expression.text,
478
+ sourceFile,
479
+ routerFilePath,
480
+ );
481
+ value = owner ? resolveConstMember(owner, expr.name.text) : null;
346
482
  }
347
- ts.forEachChild(node, visit);
348
483
  }
484
+ return value === null
485
+ ? { unresolved: valueExpr.getText(sourceFile).replace(/\s+/g, " ") }
486
+ : { value };
487
+ }
349
488
 
350
- visit(sourceFile);
351
- return result;
489
+ // String value of `owner.name` when owner is an object literal. A spread may
490
+ // override the property, so an owner containing one is not resolved.
491
+ function resolveConstMember(owner: ts.Expression, name: string): string | null {
492
+ if (
493
+ !ts.isObjectLiteralExpression(owner) ||
494
+ owner.properties.some(ts.isSpreadAssignment)
495
+ ) {
496
+ return null;
497
+ }
498
+ const member = owner.properties.find(
499
+ (p): p is ts.PropertyAssignment =>
500
+ ts.isPropertyAssignment(p) &&
501
+ ts.isIdentifier(p.name) &&
502
+ p.name.text === name,
503
+ );
504
+ return member
505
+ ? getStringValue(unwrapTypeOperators(member.initializer))
506
+ : null;
352
507
  }
353
508
 
354
509
  /** Apply a basename prefix to all route patterns in a result set. */
@@ -496,11 +651,9 @@ export function buildCombinedRouteMapForRouterFile(routerFilePath: string): {
496
651
  return { routes: {}, searchSchemas: {} };
497
652
  }
498
653
 
499
- // Detect basename from createRouter({ basename: "..." })
500
- const rawBasename = extractBasenameFromRouter(routerSource);
501
- const basename = rawBasename
502
- ? ("/" + rawBasename.replace(/^\/+|\/+$/g, "")).replace(/^\/$/, "")
503
- : undefined;
654
+ const basename = normalizeBasename(
655
+ resolveRouterBasename(routerSource, routerFilePath).value,
656
+ );
504
657
 
505
658
  const result: RouteMapWithSearch = { routes: {}, searchSchemas: {} };
506
659
  const memo = createScanMemo();
@@ -531,8 +684,9 @@ export function buildCombinedRouteMapForRouterFile(routerFilePath: string): {
531
684
 
532
685
  /**
533
686
  * Walk the full include tree starting from a router file and detect
534
- * all includes that the static parser cannot resolve.
535
- * Returns an array of diagnostics; empty means fully resolvable.
687
+ * all includes that the static parser cannot resolve, plus an explicit
688
+ * createRouter `basename` it cannot read (every pattern would lack the
689
+ * prefix). Returns an array of diagnostics; empty means fully resolvable.
536
690
  */
537
691
  export function detectUnresolvableIncludes(
538
692
  routerFilePath: string,
@@ -559,6 +713,16 @@ export function detectUnresolvableIncludes(
559
713
  memo,
560
714
  );
561
715
  }
716
+ const { unresolved } = resolveRouterBasename(source, realPath);
717
+ if (unresolved !== undefined) {
718
+ diagnostics.push({
719
+ pathPrefix: "/",
720
+ namePrefix: null,
721
+ reason: "unresolvable-basename",
722
+ sourceFile: realPath,
723
+ detail: `basename \`${unresolved}\` is not a string literal or top-level const; every pattern would lack the prefix`,
724
+ });
725
+ }
562
726
  return diagnostics;
563
727
  }
564
728
 
@@ -1,5 +1,6 @@
1
- import { resolve } from "node:path";
1
+ import { basename, resolve } from "node:path";
2
2
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import type { Plugin, PluginOption, ViteDevServer } from "vite";
3
4
  import {
4
5
  generateRouteTypesSource,
5
6
  genFileTsPath,
@@ -35,13 +36,10 @@ export async function discoverAndWriteRouteTypes(
35
36
  routeCount: number;
36
37
  outputFiles: string[];
37
38
  }> {
38
- let createViteServer: typeof import("vite").createServer;
39
39
  let loadConfigFromFile: typeof import("vite").loadConfigFromFile;
40
- let rsc: any;
41
40
 
42
41
  try {
43
42
  const vite = await import("vite");
44
- createViteServer = vite.createServer;
45
43
  loadConfigFromFile = vite.loadConfigFromFile;
46
44
  } catch {
47
45
  throw new Error(
@@ -50,61 +48,57 @@ export async function discoverAndWriteRouteTypes(
50
48
  }
51
49
 
52
50
  try {
53
- const rscMod = await import("@vitejs/plugin-rsc");
54
- rsc = rscMod.default;
51
+ await import("@vitejs/plugin-rsc");
55
52
  } catch {
56
53
  throw new Error(
57
54
  "Runtime discovery requires '@vitejs/plugin-rsc'. Install it with: pnpm add -D @vitejs/plugin-rsc",
58
55
  );
59
56
  }
60
57
 
61
- const { createVersionPlugin } =
62
- await import("../vite/plugins/version-plugin.ts");
63
- const { createVirtualStubPlugin } =
64
- await import("../vite/plugins/virtual-stub-plugin.ts");
58
+ const { createTempRscServer } = await import("../vite/temp-rsc-server.js");
59
+ const { createDiscoveryState } = await import("../vite/discovery/state.js");
60
+ const { discoverClientUrlProjections } =
61
+ await import("../vite/discovery/client-urls-projection.js");
62
+ const { pickForwardedRunnerConfig, selectForwardableResolvePlugins } =
63
+ await import("../vite/utils/forward-user-plugins.js");
65
64
 
66
- // Load user's vite config to get resolve.alias (unless provided directly)
67
- let userResolveAlias: any = opts.resolveAlias;
68
- if (!userResolveAlias) {
69
- const configPath = opts.configFile;
65
+ // The discovery temp server of `vite dev/build` (expose-internal-ids, the
66
+ // cloudflare:* stubs, the user's resolver plugins and resolve/define/oxc).
67
+ // The config stays unresolved: resolving it would run rango()'s
68
+ // configResolved, which writes the static gen files. Forwarded resolvers
69
+ // therefore run without their own config/configResolved hooks.
70
+ const entryPath = resolve(opts.entry);
71
+ const state = createDiscoveryState(entryPath, undefined);
72
+ state.projectRoot = opts.root;
73
+ state.userResolveAlias = opts.resolveAlias;
74
+ if (!opts.resolveAlias) {
70
75
  try {
71
76
  const loaded = await loadConfigFromFile(
72
77
  { command: "serve", mode: "development" },
73
- configPath,
78
+ opts.configFile,
74
79
  opts.root,
75
80
  );
76
- if (loaded?.config?.resolve?.alias) {
77
- userResolveAlias = loaded.config.resolve.alias;
81
+ if (loaded) {
82
+ state.userRunnerConfig = pickForwardedRunnerConfig(loaded.config);
83
+ state.userResolvePlugins = selectForwardableResolvePlugins(
84
+ await flattenPluginOptions(loaded.config.plugins),
85
+ );
78
86
  }
79
- } catch {
80
- // Config loading failed; proceed without aliases
87
+ } catch (err: any) {
88
+ // Discovery without the app's resolvers fails later on an unrelated
89
+ // import; name the cause here.
90
+ console.warn(
91
+ `[rango] Could not load the Vite config (${err?.message ?? err}); discovering without its resolve config and plugins`,
92
+ );
81
93
  }
82
94
  }
83
95
 
84
- const entryPath = resolve(opts.entry);
85
- let tempServer: any = null;
96
+ let tempServer: ViteDevServer | null = null;
86
97
 
87
98
  try {
88
- tempServer = await createViteServer({
89
- root: opts.root,
90
- configFile: false,
91
- server: { middlewareMode: true },
92
- appType: "custom",
93
- logLevel: "silent",
99
+ tempServer = await createTempRscServer(state, {
94
100
  cacheDir: "node_modules/.vite_rango_generate",
95
- resolve: userResolveAlias ? { alias: userResolveAlias } : undefined,
96
- esbuild: { jsx: "automatic", jsxImportSource: "react" },
97
- plugins: [
98
- rsc({
99
- entries: {
100
- client: "virtual:entry-client",
101
- ssr: "virtual:entry-ssr",
102
- rsc: entryPath,
103
- },
104
- }),
105
- createVersionPlugin(),
106
- createVirtualStubPlugin(),
107
- ],
101
+ plugins: [missingGenFileStubPlugin()],
108
102
  });
109
103
 
110
104
  const rscEnv = (tempServer.environments as any)?.rsc;
@@ -125,6 +119,14 @@ export async function discoverAndWriteRouteTypes(
125
119
  );
126
120
  }
127
121
 
122
+ // include() of a clientUrls() module resolves from these projections.
123
+ await discoverClientUrlProjections(
124
+ state,
125
+ (tempServer.environments as any)?.ssr,
126
+ serverMod,
127
+ rscEnv,
128
+ );
129
+
128
130
  // Import build utilities for manifest generation
129
131
  const buildMod = await rscEnv.runner.import("@rangojs/router/build");
130
132
  const generateManifestFull = buildMod.generateManifestFull;
@@ -149,7 +151,11 @@ export async function discoverAndWriteRouteTypes(
149
151
  const mountManifests = [];
150
152
  for (const mount of mounts) {
151
153
  mountManifests.push(
152
- await generateManifestFull(mount.patterns, globalMountIndex),
154
+ await generateManifestFull(
155
+ mount.patterns,
156
+ globalMountIndex,
157
+ router.__basename ? { urlPrefix: router.__basename } : undefined,
158
+ ),
153
159
  );
154
160
  globalMountIndex++;
155
161
  }
@@ -232,3 +238,46 @@ export async function discoverAndWriteRouteTypes(
232
238
  }
233
239
  }
234
240
  }
241
+
242
+ const MISSING_GEN_PREFIX = "\0rango-missing-named-routes-gen:";
243
+
244
+ /**
245
+ * expose-router-id makes every createRouter() file import its sibling
246
+ * `./<name>.named-routes.gen.js`. `vite dev/build` write those from static
247
+ * parsing before discovery; the CLI does not, so a missing one resolves to an
248
+ * empty NamedRoutes here and discovery writes the real file. An existing gen
249
+ * file loads from disk as usual.
250
+ */
251
+ function missingGenFileStubPlugin(): Plugin {
252
+ return {
253
+ name: "@rangojs/router:missing-gen-file-stub",
254
+ enforce: "pre",
255
+ resolveId(id, importer) {
256
+ if (!importer || !id.endsWith(".named-routes.gen.js")) return null;
257
+ const genPath = genFileTsPath(importer.split("?", 1)[0]);
258
+ if (id !== `./${basename(genPath, ".ts")}.js` || existsSync(genPath)) {
259
+ return null;
260
+ }
261
+ return MISSING_GEN_PREFIX + genPath;
262
+ },
263
+ load(id) {
264
+ return id.startsWith(MISSING_GEN_PREFIX)
265
+ ? "export const NamedRoutes = {};\n"
266
+ : null;
267
+ },
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Flatten an unresolved `plugins` option (nested arrays, promises, falsy
273
+ * entries) the way Vite's config resolution does, without running any hook.
274
+ */
275
+ async function flattenPluginOptions(
276
+ option: PluginOption | undefined,
277
+ ): Promise<Plugin[]> {
278
+ const value = await option;
279
+ if (Array.isArray(value)) {
280
+ return (await Promise.all(value.map(flattenPluginOptions))).flat();
281
+ }
282
+ return value ? [value as Plugin] : [];
283
+ }
@@ -170,8 +170,10 @@ function serializeSearchSchema(
170
170
  throw projectionError(route, 'path option "search" must be an object');
171
171
  }
172
172
 
173
+ // Source order, not sorted: the build writes the gen file's search keys from
174
+ // this projection and must match `rango generate`'s static parse (#1322).
173
175
  const entries: Array<[string, SearchSchemaValue]> = [];
174
- for (const key of Object.keys(value).sort()) {
176
+ for (const key of Object.keys(value)) {
175
177
  const schemaValue = (value as Record<string, unknown>)[key];
176
178
  if (
177
179
  typeof schemaValue !== "string" ||
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * The discovery temp RSC server: a minimal Vite server whose module runner
3
3
  * imports the user's router files. Shared by dev discovery
4
- * (discovery/dev-server-phase.ts) and build discovery
5
- * (discovery/build-discovery-phase.ts).
4
+ * (discovery/dev-server-phase.ts), build discovery
5
+ * (discovery/build-discovery-phase.ts) and `rango generate --runtime`
6
+ * (build/runtime-discovery.ts).
6
7
  *
7
8
  * Sits in src/vite/, not src/vite/discovery/: the loader hook URL below
8
- * resolves against this module's import.meta.url, and the published bundle
9
- * (dist/vite/index.js) ships the hook at the same relative path.
9
+ * resolves against this module's import.meta.url, and the published bundles
10
+ * (dist/vite/index.js, dist/bin/rango.js) ship the hook at the same relative
11
+ * path.
10
12
  */
11
13
 
12
14
  import type { ViteDevServer } from "vite";
@@ -103,6 +105,8 @@ export async function createTempRscServer(
103
105
  * shell capture actually runs.
104
106
  */
105
107
  realSsrEntry?: boolean;
108
+ /** Caller-specific plugins, appended after the forwarded user plugins. */
109
+ plugins?: import("vite").Plugin[];
106
110
  } = {},
107
111
  ): Promise<ViteDevServer> {
108
112
  // Install the Node ESM loader hook before any module evaluation so
@@ -218,6 +222,7 @@ export async function createTempRscServer(
218
222
  // IMPORT_REWRITE_PLUGINS) and placed last so framework resolution runs first;
219
223
  // Vite re-sorts by `enforce`, so `enforce: "pre"` resolvers still lead.
220
224
  ...state.userResolvePlugins,
225
+ ...(options.plugins ?? []),
221
226
  ],
222
227
  });
223
228
  }
@@ -139,6 +139,8 @@ export interface ForwardedRunnerConfig {
139
139
  /**
140
140
  * Extract the data-only config slice to mirror into the discovery temp server.
141
141
  * `alias` is included here so callers no longer need to thread it separately.
142
+ * Accepts the unresolved config too: `rango generate --runtime` must not
143
+ * resolve it (that runs rango()'s configResolved, which writes gen files).
142
144
  *
143
145
  * `tsconfigPaths` is forwarded so Vite 8's native tsconfig `paths` resolution
144
146
  * (a top-level `resolve` flag, off by default) reaches the temp server. The
@@ -154,9 +156,9 @@ export interface ForwardedRunnerConfig {
154
156
  * forward `oxc` exclusively — no `esbuild` field is touched.
155
157
  */
156
158
  export function pickForwardedRunnerConfig(
157
- config: ResolvedConfig,
159
+ config: Pick<ResolvedConfig | UserConfig, "resolve" | "define" | "oxc">,
158
160
  ): ForwardedRunnerConfig {
159
- const r = config.resolve ?? ({} as ResolvedConfig["resolve"]);
161
+ const r = config.resolve ?? {};
160
162
  const resolve: NonNullable<UserConfig["resolve"]> = {};
161
163
  if (r.alias !== undefined) resolve.alias = r.alias as any;
162
164
  if (r.dedupe !== undefined) resolve.dedupe = r.dedupe;