@tanstack/start-plugin-core 1.142.11 → 1.142.13

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.
@@ -68,6 +68,57 @@ const LookupSetup: Record<LookupKind, MethodChainSetup | DirectCallSetup> = {
68
68
  ClientOnlyFn: { type: 'directCall' },
69
69
  }
70
70
 
71
+ // Single source of truth for detecting which kinds are present in code
72
+ // These patterns are used for:
73
+ // 1. Pre-scanning code to determine which kinds to look for (before AST parsing)
74
+ // 2. Deriving the plugin's transform code filter
75
+ export const KindDetectionPatterns: Record<LookupKind, RegExp> = {
76
+ ServerFn: /\.handler\s*\(/,
77
+ Middleware: /createMiddleware/,
78
+ IsomorphicFn: /createIsomorphicFn/,
79
+ ServerOnlyFn: /createServerOnlyFn/,
80
+ ClientOnlyFn: /createClientOnlyFn/,
81
+ }
82
+
83
+ // Which kinds are valid for each environment
84
+ export const LookupKindsPerEnv: Record<'client' | 'server', Set<LookupKind>> = {
85
+ client: new Set([
86
+ 'Middleware',
87
+ 'ServerFn',
88
+ 'IsomorphicFn',
89
+ 'ServerOnlyFn',
90
+ 'ClientOnlyFn',
91
+ ] as const),
92
+ server: new Set([
93
+ 'ServerFn',
94
+ 'IsomorphicFn',
95
+ 'ServerOnlyFn',
96
+ 'ClientOnlyFn',
97
+ ] as const),
98
+ }
99
+
100
+ /**
101
+ * Detects which LookupKinds are present in the code using string matching.
102
+ * This is a fast pre-scan before AST parsing to limit the work done during compilation.
103
+ */
104
+ export function detectKindsInCode(
105
+ code: string,
106
+ env: 'client' | 'server',
107
+ ): Set<LookupKind> {
108
+ const detected = new Set<LookupKind>()
109
+ const validForEnv = LookupKindsPerEnv[env]
110
+
111
+ for (const [kind, pattern] of Object.entries(KindDetectionPatterns) as Array<
112
+ [LookupKind, RegExp]
113
+ >) {
114
+ if (validForEnv.has(kind) && pattern.test(code)) {
115
+ detected.add(kind)
116
+ }
117
+ }
118
+
119
+ return detected
120
+ }
121
+
71
122
  // Pre-computed map: identifier name -> Set<LookupKind> for fast candidate detection (method chain only)
72
123
  // Multiple kinds can share the same identifier (e.g., 'server' and 'client' are used by both Middleware and IsomorphicFn)
73
124
  const IdentifierToKinds = new Map<string, Set<LookupKind>>()
@@ -86,6 +137,16 @@ for (const [kind, setup] of Object.entries(LookupSetup) as Array<
86
137
  }
87
138
  }
88
139
 
140
+ // Known factory function names for direct call and root-as-candidate patterns
141
+ // These are the names that, when called directly, create a new function.
142
+ // Used to filter nested candidates - we only want to include actual factory calls,
143
+ // not invocations of already-created functions (e.g., `myServerFn()` should NOT be a candidate)
144
+ const DirectCallFactoryNames = new Set([
145
+ 'createServerOnlyFn',
146
+ 'createClientOnlyFn',
147
+ 'createIsomorphicFn',
148
+ ])
149
+
89
150
  export type LookupConfig = {
90
151
  libName: string
91
152
  rootExport: string
@@ -100,13 +161,94 @@ interface ModuleInfo {
100
161
  reExportAllSources: Array<string>
101
162
  }
102
163
 
164
+ /**
165
+ * Computes whether any file kinds need direct-call candidate detection.
166
+ * This includes both directCall types (ServerOnlyFn, ClientOnlyFn) and
167
+ * allowRootAsCandidate types (IsomorphicFn).
168
+ */
169
+ function needsDirectCallDetection(kinds: Set<LookupKind>): boolean {
170
+ for (const kind of kinds) {
171
+ const setup = LookupSetup[kind]
172
+ if (setup.type === 'directCall' || setup.allowRootAsCandidate) {
173
+ return true
174
+ }
175
+ }
176
+ return false
177
+ }
178
+
179
+ /**
180
+ * Checks if all kinds in the set are guaranteed to be top-level only.
181
+ * Only ServerFn is always declared at module level (must be assigned to a variable).
182
+ * Middleware, IsomorphicFn, ServerOnlyFn, ClientOnlyFn can be nested inside functions.
183
+ * When all kinds are top-level-only, we can use a fast scan instead of full traversal.
184
+ */
185
+ function areAllKindsTopLevelOnly(kinds: Set<LookupKind>): boolean {
186
+ return kinds.size === 1 && kinds.has('ServerFn')
187
+ }
188
+
189
+ /**
190
+ * Checks if a CallExpression is a direct-call candidate for NESTED detection.
191
+ * Returns true if the callee is a known factory function name.
192
+ * This is stricter than top-level detection because we need to filter out
193
+ * invocations of existing server functions (e.g., `myServerFn()`).
194
+ */
195
+ function isNestedDirectCallCandidate(node: t.CallExpression): boolean {
196
+ let calleeName: string | undefined
197
+ if (t.isIdentifier(node.callee)) {
198
+ calleeName = node.callee.name
199
+ } else if (
200
+ t.isMemberExpression(node.callee) &&
201
+ t.isIdentifier(node.callee.property)
202
+ ) {
203
+ calleeName = node.callee.property.name
204
+ }
205
+ return calleeName !== undefined && DirectCallFactoryNames.has(calleeName)
206
+ }
207
+
208
+ /**
209
+ * Checks if a CallExpression path is a top-level direct-call candidate.
210
+ * Top-level means the call is the init of a VariableDeclarator at program level.
211
+ * We accept any simple identifier call or namespace call at top level
212
+ * (e.g., `isomorphicFn()`, `TanStackStart.createServerOnlyFn()`) and let
213
+ * resolution verify it. This handles renamed imports.
214
+ */
215
+ function isTopLevelDirectCallCandidate(
216
+ path: babel.NodePath<t.CallExpression>,
217
+ ): boolean {
218
+ const node = path.node
219
+
220
+ // Must be a simple identifier call or namespace call
221
+ const isSimpleCall =
222
+ t.isIdentifier(node.callee) ||
223
+ (t.isMemberExpression(node.callee) &&
224
+ t.isIdentifier(node.callee.object) &&
225
+ t.isIdentifier(node.callee.property))
226
+
227
+ if (!isSimpleCall) {
228
+ return false
229
+ }
230
+
231
+ // Must be top-level: VariableDeclarator -> VariableDeclaration -> Program
232
+ const parent = path.parent
233
+ if (!t.isVariableDeclarator(parent) || parent.init !== node) {
234
+ return false
235
+ }
236
+ const grandParent = path.parentPath.parent
237
+ if (!t.isVariableDeclaration(grandParent)) {
238
+ return false
239
+ }
240
+ return t.isProgram(path.parentPath.parentPath?.parent)
241
+ }
242
+
103
243
  export class ServerFnCompiler {
104
244
  private moduleCache = new Map<string, ModuleInfo>()
105
245
  private initialized = false
106
246
  private validLookupKinds: Set<LookupKind>
107
- // Precomputed flags for candidate detection (avoid recomputing on each collectCandidates call)
108
- private hasDirectCallKinds: boolean
109
- private hasRootAsCandidateKinds: boolean
247
+ private resolveIdCache = new Map<string, string | null>()
248
+ private exportResolutionCache = new Map<
249
+ string,
250
+ Map<string, { moduleInfo: ModuleInfo; binding: Binding } | null>
251
+ >()
110
252
  // Fast lookup for direct imports from known libraries (e.g., '@tanstack/react-start')
111
253
  // Maps: libName → (exportName → Kind)
112
254
  // This allows O(1) resolution for the common case without async resolveId calls
@@ -119,21 +261,42 @@ export class ServerFnCompiler {
119
261
  lookupKinds: Set<LookupKind>
120
262
  loadModule: (id: string) => Promise<void>
121
263
  resolveId: (id: string, importer?: string) => Promise<string | null>
264
+ /**
265
+ * In 'build' mode, resolution results are cached for performance.
266
+ * In 'dev' mode (default), caching is disabled to avoid invalidation complexity with HMR.
267
+ */
268
+ mode?: 'dev' | 'build'
122
269
  },
123
270
  ) {
124
271
  this.validLookupKinds = options.lookupKinds
272
+ }
125
273
 
126
- // Precompute flags for candidate detection
127
- this.hasDirectCallKinds = false
128
- this.hasRootAsCandidateKinds = false
129
- for (const kind of options.lookupKinds) {
130
- const setup = LookupSetup[kind]
131
- if (setup.type === 'directCall') {
132
- this.hasDirectCallKinds = true
133
- } else if (setup.allowRootAsCandidate) {
134
- this.hasRootAsCandidateKinds = true
135
- }
274
+ private get mode(): 'dev' | 'build' {
275
+ return this.options.mode ?? 'dev'
276
+ }
277
+
278
+ private async resolveIdCached(id: string, importer?: string) {
279
+ if (this.mode === 'dev') {
280
+ return this.options.resolveId(id, importer)
136
281
  }
282
+
283
+ const cacheKey = importer ? `${importer}::${id}` : id
284
+ const cached = this.resolveIdCache.get(cacheKey)
285
+ if (cached !== undefined) {
286
+ return cached
287
+ }
288
+ const resolved = await this.options.resolveId(id, importer)
289
+ this.resolveIdCache.set(cacheKey, resolved)
290
+ return resolved
291
+ }
292
+
293
+ private getExportResolutionCache(moduleId: string) {
294
+ let cache = this.exportResolutionCache.get(moduleId)
295
+ if (!cache) {
296
+ cache = new Map()
297
+ this.exportResolutionCache.set(moduleId, cache)
298
+ }
299
+ return cache
137
300
  }
138
301
 
139
302
  private async init() {
@@ -159,7 +322,7 @@ export class ServerFnCompiler {
159
322
  }
160
323
  libExports.set(config.rootExport, config.kind)
161
324
 
162
- const libId = await this.options.resolveId(config.libName)
325
+ const libId = await this.resolveIdCached(config.libName)
163
326
  if (!libId) {
164
327
  throw new Error(`could not resolve "${config.libName}"`)
165
328
  }
@@ -196,9 +359,14 @@ export class ServerFnCompiler {
196
359
  this.initialized = true
197
360
  }
198
361
 
199
- public ingestModule({ code, id }: { code: string; id: string }) {
200
- const ast = parseAst({ code })
201
-
362
+ /**
363
+ * Extracts bindings and exports from an already-parsed AST.
364
+ * This is the core logic shared by ingestModule and ingestModuleFromAst.
365
+ */
366
+ private extractModuleInfo(
367
+ ast: ReturnType<typeof parseAst>,
368
+ id: string,
369
+ ): ModuleInfo {
202
370
  const bindings = new Map<string, Binding>()
203
371
  const exports = new Map<string, ExportEntry>()
204
372
  const reExportAllSources: Array<string> = []
@@ -299,10 +467,19 @@ export class ServerFnCompiler {
299
467
  reExportAllSources,
300
468
  }
301
469
  this.moduleCache.set(id, info)
470
+ return info
471
+ }
472
+
473
+ public ingestModule({ code, id }: { code: string; id: string }) {
474
+ const ast = parseAst({ code })
475
+ const info = this.extractModuleInfo(ast, id)
302
476
  return { info, ast }
303
477
  }
304
478
 
305
479
  public invalidateModule(id: string) {
480
+ // Note: Resolution caches (resolveIdCache, exportResolutionCache) are only
481
+ // used in build mode where there's no HMR. In dev mode, caching is disabled,
482
+ // so we only need to invalidate the moduleCache here.
306
483
  return this.moduleCache.delete(id)
307
484
  }
308
485
 
@@ -310,68 +487,184 @@ export class ServerFnCompiler {
310
487
  code,
311
488
  id,
312
489
  isProviderFile,
490
+ detectedKinds,
313
491
  }: {
314
492
  code: string
315
493
  id: string
316
494
  isProviderFile: boolean
495
+ /** Pre-detected kinds present in this file. If not provided, all valid kinds are checked. */
496
+ detectedKinds?: Set<LookupKind>
317
497
  }) {
318
498
  if (!this.initialized) {
319
499
  await this.init()
320
500
  }
321
- const { info, ast } = this.ingestModule({ code, id })
322
- const candidates = this.collectCandidates(info.bindings)
323
- if (candidates.length === 0) {
324
- // this hook will only be invoked if there is `.handler(` | `.server(` | `.client(` in the code,
325
- // so not discovering a handler candidate is rather unlikely, but maybe possible?
501
+
502
+ // Use detected kinds if provided, otherwise fall back to all valid kinds for this env
503
+ const fileKinds = detectedKinds
504
+ ? new Set([...detectedKinds].filter((k) => this.validLookupKinds.has(k)))
505
+ : this.validLookupKinds
506
+
507
+ // Early exit if no kinds to process
508
+ if (fileKinds.size === 0) {
326
509
  return null
327
510
  }
328
511
 
329
- // let's find out which of the candidates are actually server functions
330
- // Resolve all candidates in parallel for better performance
512
+ const checkDirectCalls = needsDirectCallDetection(fileKinds)
513
+ // Optimization: ServerFn is always a top-level declaration (must be assigned to a variable).
514
+ // If the file only has ServerFn, we can skip full AST traversal and only visit
515
+ // the specific top-level declarations that have candidates.
516
+ const canUseFastPath = areAllKindsTopLevelOnly(fileKinds)
517
+
518
+ // Always parse and extract module info upfront.
519
+ // This ensures the module is cached for import resolution even if no candidates are found.
520
+ const { ast } = this.ingestModule({ code, id })
521
+
522
+ // Single-pass traversal to:
523
+ // 1. Collect candidate paths (only candidates, not all CallExpressions)
524
+ // 2. Build a map for looking up paths of nested calls in method chains
525
+ const candidatePaths: Array<babel.NodePath<t.CallExpression>> = []
526
+ // Map for nested chain lookup - only populated for CallExpressions that are
527
+ // part of a method chain (callee.object is a CallExpression)
528
+ const chainCallPaths = new Map<
529
+ t.CallExpression,
530
+ babel.NodePath<t.CallExpression>
531
+ >()
532
+
533
+ if (canUseFastPath) {
534
+ // Fast path: only visit top-level statements that have potential candidates
535
+
536
+ // Collect indices of top-level statements that contain candidates
537
+ const candidateIndices: Array<number> = []
538
+ for (let i = 0; i < ast.program.body.length; i++) {
539
+ const node = ast.program.body[i]!
540
+ let declarations: Array<t.VariableDeclarator> | undefined
541
+
542
+ if (t.isVariableDeclaration(node)) {
543
+ declarations = node.declarations
544
+ } else if (t.isExportNamedDeclaration(node) && node.declaration) {
545
+ if (t.isVariableDeclaration(node.declaration)) {
546
+ declarations = node.declaration.declarations
547
+ }
548
+ }
549
+
550
+ if (declarations) {
551
+ for (const decl of declarations) {
552
+ if (decl.init && t.isCallExpression(decl.init)) {
553
+ if (isMethodChainCandidate(decl.init, fileKinds)) {
554
+ candidateIndices.push(i)
555
+ break // Only need to mark this statement once
556
+ }
557
+ }
558
+ }
559
+ }
560
+ }
561
+
562
+ // Early exit: no potential candidates found at top level
563
+ if (candidateIndices.length === 0) {
564
+ return null
565
+ }
566
+
567
+ // Targeted traversal: only visit the specific statements that have candidates
568
+ // This is much faster than traversing the entire AST
569
+ babel.traverse(ast, {
570
+ Program(programPath) {
571
+ const bodyPaths = programPath.get('body')
572
+ for (const idx of candidateIndices) {
573
+ const stmtPath = bodyPaths[idx]
574
+ if (!stmtPath) continue
575
+
576
+ // Traverse only this statement's subtree
577
+ stmtPath.traverse({
578
+ CallExpression(path) {
579
+ const node = path.node
580
+ const parent = path.parent
581
+
582
+ // Check if this call is part of a larger chain (inner call)
583
+ if (
584
+ t.isMemberExpression(parent) &&
585
+ t.isCallExpression(path.parentPath.parent)
586
+ ) {
587
+ chainCallPaths.set(node, path)
588
+ return
589
+ }
590
+
591
+ // Method chain pattern
592
+ if (isMethodChainCandidate(node, fileKinds)) {
593
+ candidatePaths.push(path)
594
+ }
595
+ },
596
+ })
597
+ }
598
+ // Stop traversal after processing Program
599
+ programPath.stop()
600
+ },
601
+ })
602
+ } else {
603
+ // Normal path: full traversal for non-fast-path kinds
604
+ babel.traverse(ast, {
605
+ CallExpression: (path) => {
606
+ const node = path.node
607
+ const parent = path.parent
608
+
609
+ // Check if this call is part of a larger chain (inner call)
610
+ // If so, store it for method chain lookup but don't treat as candidate
611
+ if (
612
+ t.isMemberExpression(parent) &&
613
+ t.isCallExpression(path.parentPath.parent)
614
+ ) {
615
+ // This is an inner call in a chain - store for later lookup
616
+ chainCallPaths.set(node, path)
617
+ return
618
+ }
619
+
620
+ // Pattern 1: Method chain pattern (.handler(), .server(), .client(), etc.)
621
+ if (isMethodChainCandidate(node, fileKinds)) {
622
+ candidatePaths.push(path)
623
+ return
624
+ }
625
+
626
+ // Pattern 2: Direct call pattern
627
+ if (checkDirectCalls) {
628
+ if (isTopLevelDirectCallCandidate(path)) {
629
+ candidatePaths.push(path)
630
+ } else if (isNestedDirectCallCandidate(node)) {
631
+ candidatePaths.push(path)
632
+ }
633
+ }
634
+ },
635
+ })
636
+ }
637
+
638
+ if (candidatePaths.length === 0) {
639
+ return null
640
+ }
641
+
642
+ // Resolve all candidates in parallel to determine their kinds
331
643
  const resolvedCandidates = await Promise.all(
332
- candidates.map(async (candidate) => ({
333
- candidate,
334
- kind: await this.resolveExprKind(candidate, id),
644
+ candidatePaths.map(async (path) => ({
645
+ path,
646
+ kind: await this.resolveExprKind(path.node, id),
335
647
  })),
336
648
  )
337
649
 
338
- // Map from candidate/root node -> kind
339
- // Note: For top-level variable declarations, candidate === root (the outermost CallExpression)
340
- const toRewriteMap = new Map<t.CallExpression, LookupKind>()
341
- for (const { candidate, kind } of resolvedCandidates) {
342
- if (this.validLookupKinds.has(kind as LookupKind)) {
343
- toRewriteMap.set(candidate, kind as LookupKind)
344
- }
345
- }
346
- if (toRewriteMap.size === 0) {
650
+ // Filter to valid candidates
651
+ const validCandidates = resolvedCandidates.filter(({ kind }) =>
652
+ this.validLookupKinds.has(kind as LookupKind),
653
+ ) as Array<{ path: babel.NodePath<t.CallExpression>; kind: LookupKind }>
654
+
655
+ if (validCandidates.length === 0) {
347
656
  return null
348
657
  }
349
658
 
350
- // Single-pass traversal to find NodePaths and collect method chains
659
+ // Process valid candidates to collect method chains
351
660
  const pathsToRewrite: Array<{
352
661
  path: babel.NodePath<t.CallExpression>
353
662
  kind: LookupKind
354
663
  methodChain: MethodChainPaths
355
664
  }> = []
356
665
 
357
- // First, collect all CallExpression paths in the AST for O(1) lookup
358
- const callExprPaths = new Map<
359
- t.CallExpression,
360
- babel.NodePath<t.CallExpression>
361
- >()
362
-
363
- babel.traverse(ast, {
364
- CallExpression(path) {
365
- callExprPaths.set(path.node, path)
366
- },
367
- })
368
-
369
- // Now process candidates - we can look up any CallExpression path in O(1)
370
- for (const [node, kind] of toRewriteMap) {
371
- const path = callExprPaths.get(node)
372
- if (!path) {
373
- continue
374
- }
666
+ for (const { path, kind } of validCandidates) {
667
+ const node = path.node
375
668
 
376
669
  // Collect method chain paths by walking DOWN from root through the chain
377
670
  const methodChain: MethodChainPaths = {
@@ -384,6 +677,8 @@ export class ServerFnCompiler {
384
677
 
385
678
  // Walk down the call chain using nodes, look up paths from map
386
679
  let currentNode: t.CallExpression = node
680
+ let currentPath: babel.NodePath<t.CallExpression> = path
681
+
387
682
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
388
683
  while (true) {
389
684
  const callee = currentNode.callee
@@ -395,7 +690,6 @@ export class ServerFnCompiler {
395
690
  if (t.isIdentifier(callee.property)) {
396
691
  const name = callee.property.name as keyof MethodChainPaths
397
692
  if (name in methodChain) {
398
- const currentPath = callExprPaths.get(currentNode)!
399
693
  // Get first argument path
400
694
  const args = currentPath.get('arguments')
401
695
  const firstArgPath =
@@ -412,18 +706,17 @@ export class ServerFnCompiler {
412
706
  break
413
707
  }
414
708
  currentNode = callee.object
709
+ // Look up path from chain map, or use candidate path if not found
710
+ const nextPath = chainCallPaths.get(currentNode)
711
+ if (!nextPath) {
712
+ break
713
+ }
714
+ currentPath = nextPath
415
715
  }
416
716
 
417
717
  pathsToRewrite.push({ path, kind, methodChain })
418
718
  }
419
719
 
420
- // Verify we found all candidates (pathsToRewrite should have same size as toRewriteMap had)
421
- if (pathsToRewrite.length !== toRewriteMap.size) {
422
- throw new Error(
423
- `Internal error: could not find all paths to rewrite. please file an issue`,
424
- )
425
- }
426
-
427
720
  const refIdents = findReferencedIdentifiers(ast)
428
721
 
429
722
  for (const { path, kind, methodChain } of pathsToRewrite) {
@@ -461,42 +754,6 @@ export class ServerFnCompiler {
461
754
  })
462
755
  }
463
756
 
464
- // collects all candidate CallExpressions at top-level
465
- private collectCandidates(bindings: Map<string, Binding>) {
466
- const candidates: Array<t.CallExpression> = []
467
-
468
- for (const binding of bindings.values()) {
469
- if (binding.type === 'var' && t.isCallExpression(binding.init)) {
470
- // Pattern 1: Method chain pattern (.handler(), .server(), etc.)
471
- const methodChainCandidate = isCandidateCallExpression(
472
- binding.init,
473
- this.validLookupKinds,
474
- )
475
- if (methodChainCandidate) {
476
- candidates.push(methodChainCandidate)
477
- continue
478
- }
479
-
480
- // Pattern 2: Direct call pattern
481
- // Handles:
482
- // - createServerOnlyFn(), createClientOnlyFn() (direct call kinds)
483
- // - createIsomorphicFn() (root-as-candidate kinds)
484
- // - TanStackStart.createServerOnlyFn() (namespace calls)
485
- if (this.hasDirectCallKinds || this.hasRootAsCandidateKinds) {
486
- if (
487
- t.isIdentifier(binding.init.callee) ||
488
- (t.isMemberExpression(binding.init.callee) &&
489
- t.isIdentifier(binding.init.callee.property))
490
- ) {
491
- // Include as candidate - kind resolution will verify it's actually a known export
492
- candidates.push(binding.init)
493
- }
494
- }
495
- }
496
- }
497
- return candidates
498
- }
499
-
500
757
  private async resolveIdentifierKind(
501
758
  ident: string,
502
759
  id: string,
@@ -534,6 +791,19 @@ export class ServerFnCompiler {
534
791
  exportName: string,
535
792
  visitedModules = new Set<string>(),
536
793
  ): Promise<{ moduleInfo: ModuleInfo; binding: Binding } | undefined> {
794
+ const isBuildMode = this.mode === 'build'
795
+
796
+ // Check cache first (only for top-level calls in build mode)
797
+ if (isBuildMode && visitedModules.size === 0) {
798
+ const moduleCache = this.exportResolutionCache.get(moduleInfo.id)
799
+ if (moduleCache) {
800
+ const cached = moduleCache.get(exportName)
801
+ if (cached !== undefined) {
802
+ return cached ?? undefined
803
+ }
804
+ }
805
+ }
806
+
537
807
  // Prevent infinite loops in circular re-exports
538
808
  if (visitedModules.has(moduleInfo.id)) {
539
809
  return undefined
@@ -545,7 +815,12 @@ export class ServerFnCompiler {
545
815
  if (directExport) {
546
816
  const binding = moduleInfo.bindings.get(directExport.name)
547
817
  if (binding) {
548
- return { moduleInfo, binding }
818
+ const result = { moduleInfo, binding }
819
+ // Cache the result (build mode only)
820
+ if (isBuildMode) {
821
+ this.getExportResolutionCache(moduleInfo.id).set(exportName, result)
822
+ }
823
+ return result
549
824
  }
550
825
  }
551
826
 
@@ -554,10 +829,11 @@ export class ServerFnCompiler {
554
829
  if (moduleInfo.reExportAllSources.length > 0) {
555
830
  const results = await Promise.all(
556
831
  moduleInfo.reExportAllSources.map(async (reExportSource) => {
557
- const reExportTarget = await this.options.resolveId(
832
+ const reExportTarget = await this.resolveIdCached(
558
833
  reExportSource,
559
834
  moduleInfo.id,
560
835
  )
836
+
561
837
  if (reExportTarget) {
562
838
  const reExportModule = await this.getModuleInfo(reExportTarget)
563
839
  return this.findExportInModule(
@@ -572,11 +848,19 @@ export class ServerFnCompiler {
572
848
  // Return the first valid result
573
849
  for (const result of results) {
574
850
  if (result) {
851
+ // Cache the result (build mode only)
852
+ if (isBuildMode) {
853
+ this.getExportResolutionCache(moduleInfo.id).set(exportName, result)
854
+ }
575
855
  return result
576
856
  }
577
857
  }
578
858
  }
579
859
 
860
+ // Cache negative result (build mode only)
861
+ if (isBuildMode) {
862
+ this.getExportResolutionCache(moduleInfo.id).set(exportName, null)
863
+ }
580
864
  return undefined
581
865
  }
582
866
 
@@ -602,7 +886,7 @@ export class ServerFnCompiler {
602
886
  }
603
887
 
604
888
  // Slow path: resolve through the module graph
605
- const target = await this.options.resolveId(binding.source, fileId)
889
+ const target = await this.resolveIdCached(binding.source, fileId)
606
890
  if (!target) {
607
891
  return 'None'
608
892
  }
@@ -746,7 +1030,7 @@ export class ServerFnCompiler {
746
1030
  binding.importedName === '*'
747
1031
  ) {
748
1032
  // resolve the property from the target module
749
- const targetModuleId = await this.options.resolveId(
1033
+ const targetModuleId = await this.resolveIdCached(
750
1034
  binding.source,
751
1035
  fileId,
752
1036
  )
@@ -793,15 +1077,17 @@ export class ServerFnCompiler {
793
1077
  }
794
1078
  }
795
1079
 
796
- function isCandidateCallExpression(
797
- node: t.Node | null | undefined,
1080
+ /**
1081
+ * Checks if a CallExpression has a method chain pattern that matches any of the lookup kinds.
1082
+ * E.g., `.handler()`, `.server()`, `.client()`, `.createMiddlewares()`
1083
+ */
1084
+ function isMethodChainCandidate(
1085
+ node: t.CallExpression,
798
1086
  lookupKinds: Set<LookupKind>,
799
- ): t.CallExpression | undefined {
800
- if (!t.isCallExpression(node)) return undefined
801
-
1087
+ ): boolean {
802
1088
  const callee = node.callee
803
1089
  if (!t.isMemberExpression(callee) || !t.isIdentifier(callee.property)) {
804
- return undefined
1090
+ return false
805
1091
  }
806
1092
 
807
1093
  // Use pre-computed map for O(1) lookup
@@ -811,10 +1097,10 @@ function isCandidateCallExpression(
811
1097
  // Check if any of the possible kinds are in the valid lookup kinds
812
1098
  for (const kind of possibleKinds) {
813
1099
  if (lookupKinds.has(kind)) {
814
- return node
1100
+ return true
815
1101
  }
816
1102
  }
817
1103
  }
818
1104
 
819
- return undefined
1105
+ return false
820
1106
  }