@tanstack/start-plugin-core 1.169.11 → 1.169.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.
Files changed (43) hide show
  1. package/dist/esm/index.d.ts +1 -1
  2. package/dist/esm/rsbuild/index.d.ts +1 -0
  3. package/dist/esm/rsbuild/plugin.js +2 -0
  4. package/dist/esm/rsbuild/plugin.js.map +1 -1
  5. package/dist/esm/rsbuild/schema.d.ts +27 -27
  6. package/dist/esm/rsbuild/start-compiler-host.d.ts +3 -1
  7. package/dist/esm/rsbuild/start-compiler-host.js +6 -2
  8. package/dist/esm/rsbuild/start-compiler-host.js.map +1 -1
  9. package/dist/esm/schema.d.ts +51 -51
  10. package/dist/esm/start-compiler/compiler.d.ts +21 -5
  11. package/dist/esm/start-compiler/compiler.js +197 -48
  12. package/dist/esm/start-compiler/compiler.js.map +1 -1
  13. package/dist/esm/start-compiler/config.d.ts +7 -3
  14. package/dist/esm/start-compiler/config.js +19 -7
  15. package/dist/esm/start-compiler/config.js.map +1 -1
  16. package/dist/esm/start-compiler/handleCreateServerFn.js +12 -0
  17. package/dist/esm/start-compiler/handleCreateServerFn.js.map +1 -1
  18. package/dist/esm/start-compiler/host.d.ts +3 -1
  19. package/dist/esm/start-compiler/host.js +5 -3
  20. package/dist/esm/start-compiler/host.js.map +1 -1
  21. package/dist/esm/start-compiler/types.d.ts +4 -13
  22. package/dist/esm/types.d.ts +33 -0
  23. package/dist/esm/vite/index.d.ts +1 -0
  24. package/dist/esm/vite/plugin.js +2 -0
  25. package/dist/esm/vite/plugin.js.map +1 -1
  26. package/dist/esm/vite/schema.d.ts +27 -27
  27. package/dist/esm/vite/start-compiler-plugin/plugin.d.ts +3 -1
  28. package/dist/esm/vite/start-compiler-plugin/plugin.js +6 -2
  29. package/dist/esm/vite/start-compiler-plugin/plugin.js.map +1 -1
  30. package/package.json +6 -6
  31. package/src/index.ts +6 -1
  32. package/src/rsbuild/index.ts +5 -0
  33. package/src/rsbuild/plugin.ts +3 -0
  34. package/src/rsbuild/start-compiler-host.ts +22 -3
  35. package/src/start-compiler/compiler.ts +389 -70
  36. package/src/start-compiler/config.ts +43 -6
  37. package/src/start-compiler/handleCreateServerFn.ts +29 -0
  38. package/src/start-compiler/host.ts +13 -3
  39. package/src/start-compiler/types.ts +5 -14
  40. package/src/types.ts +44 -0
  41. package/src/vite/index.ts +5 -0
  42. package/src/vite/plugin.ts +3 -0
  43. package/src/vite/start-compiler-plugin/plugin.ts +22 -3
@@ -21,7 +21,11 @@ import type {
21
21
  RewriteCandidate,
22
22
  ServerFn,
23
23
  } from './types'
24
- import type { CompileStartFrameworkOptions } from '../types'
24
+ import type {
25
+ CompileStartFrameworkOptions,
26
+ StartCompilerEnvironment,
27
+ StartCompilerImportTransform,
28
+ } from '../types'
25
29
 
26
30
  type Binding =
27
31
  | {
@@ -38,7 +42,7 @@ type Binding =
38
42
 
39
43
  type Kind = 'None' | `Root` | `Builder` | LookupKind
40
44
 
41
- export type LookupKind =
45
+ export type BuiltInLookupKind =
42
46
  | 'ServerFn'
43
47
  | 'Middleware'
44
48
  | 'IsomorphicFn'
@@ -46,6 +50,10 @@ export type LookupKind =
46
50
  | 'ClientOnlyFn'
47
51
  | 'ClientOnlyJSX'
48
52
 
53
+ export type ExternalLookupKind = `External:${string}`
54
+
55
+ export type LookupKind = BuiltInLookupKind | ExternalLookupKind
56
+
49
57
  // Detection strategy for each kind
50
58
  type MethodChainSetup = {
51
59
  type: 'methodChain'
@@ -54,16 +62,37 @@ type MethodChainSetup = {
54
62
  type DirectCallSetup = {
55
63
  type: 'directCall'
56
64
  // The factory function name used to create this kind (e.g., 'createServerOnlyFn')
57
- factoryName: string
65
+ factoryNames: Set<string>
58
66
  }
59
67
  type JSXSetup = { type: 'jsx'; componentName: string }
60
68
 
61
69
  function isLookupKind(kind: Kind): kind is LookupKind {
62
- return kind in LookupSetup
70
+ return kind in BuiltInLookupSetup || isExternalLookupKind(kind)
71
+ }
72
+
73
+ export function getExternalLookupKind(
74
+ transform: StartCompilerImportTransform,
75
+ ): ExternalLookupKind {
76
+ return `External:${transform.name}`
77
+ }
78
+
79
+ function isExternalLookupKind(kind: Kind): kind is ExternalLookupKind {
80
+ return typeof kind === 'string' && kind.startsWith('External:')
81
+ }
82
+
83
+ export function isCompilerTransformEnabledForEnv(
84
+ transform: StartCompilerImportTransform,
85
+ env: StartCompilerEnvironment,
86
+ ): boolean {
87
+ if (!transform.environment) return true
88
+ if (Array.isArray(transform.environment)) {
89
+ return transform.environment.includes(env)
90
+ }
91
+ return transform.environment === env
63
92
  }
64
93
 
65
- const LookupSetup: Record<
66
- LookupKind,
94
+ const BuiltInLookupSetup: Record<
95
+ BuiltInLookupKind,
67
96
  MethodChainSetup | DirectCallSetup | JSXSetup
68
97
  > = {
69
98
  ServerFn: {
@@ -78,8 +107,14 @@ const LookupSetup: Record<
78
107
  type: 'methodChain',
79
108
  candidateCallIdentifier: new Set(['server', 'client']),
80
109
  },
81
- ServerOnlyFn: { type: 'directCall', factoryName: 'createServerOnlyFn' },
82
- ClientOnlyFn: { type: 'directCall', factoryName: 'createClientOnlyFn' },
110
+ ServerOnlyFn: {
111
+ type: 'directCall',
112
+ factoryNames: new Set(['createServerOnlyFn']),
113
+ },
114
+ ClientOnlyFn: {
115
+ type: 'directCall',
116
+ factoryNames: new Set(['createClientOnlyFn']),
117
+ },
83
118
  ClientOnlyJSX: { type: 'jsx', componentName: 'ClientOnly' },
84
119
  }
85
120
 
@@ -87,7 +122,7 @@ const LookupSetup: Record<
87
122
  // These patterns are used for:
88
123
  // 1. Pre-scanning code to determine which kinds to look for (before AST parsing)
89
124
  // 2. Deriving the plugin's transform code filter
90
- export const KindDetectionPatterns: Record<LookupKind, RegExp> = {
125
+ export const KindDetectionPatterns: Record<BuiltInLookupKind, RegExp> = {
91
126
  ServerFn: /\bcreateServerFn\b|\.\s*handler\s*\(/,
92
127
  Middleware: /createMiddleware/,
93
128
  IsomorphicFn: /createIsomorphicFn/,
@@ -97,7 +132,10 @@ export const KindDetectionPatterns: Record<LookupKind, RegExp> = {
97
132
  }
98
133
 
99
134
  // Which kinds are valid for each environment
100
- export const LookupKindsPerEnv: Record<'client' | 'server', Set<LookupKind>> = {
135
+ export const LookupKindsPerEnv: Record<
136
+ 'client' | 'server',
137
+ Set<BuiltInLookupKind>
138
+ > = {
101
139
  client: new Set([
102
140
  'Middleware',
103
141
  'ServerFn',
@@ -114,6 +152,21 @@ export const LookupKindsPerEnv: Record<'client' | 'server', Set<LookupKind>> = {
114
152
  ] as const),
115
153
  }
116
154
 
155
+ export function getLookupKindsForEnv(
156
+ env: 'client' | 'server',
157
+ opts?: {
158
+ compilerTransforms?: Array<StartCompilerImportTransform> | undefined
159
+ },
160
+ ): Set<LookupKind> {
161
+ const kinds: Set<LookupKind> = new Set(LookupKindsPerEnv[env])
162
+ for (const transform of opts?.compilerTransforms ?? []) {
163
+ if (isCompilerTransformEnabledForEnv(transform, env)) {
164
+ kinds.add(getExternalLookupKind(transform))
165
+ }
166
+ }
167
+ return kinds
168
+ }
169
+
117
170
  /**
118
171
  * Handler type for processing candidates of a specific kind.
119
172
  * The kind is passed as the third argument to allow shared handlers (like handleEnvOnlyFn).
@@ -121,15 +174,15 @@ export const LookupKindsPerEnv: Record<'client' | 'server', Set<LookupKind>> = {
121
174
  type KindHandler = (
122
175
  candidates: Array<RewriteCandidate>,
123
176
  context: CompilationContext,
124
- kind: LookupKind,
177
+ kind: BuiltInLookupKind,
125
178
  ) => void
126
179
 
127
180
  /**
128
181
  * Registry mapping each LookupKind to its handler function.
129
182
  * When adding a new kind, add its handler here.
130
183
  */
131
- const KindHandlers: Record<
132
- Exclude<LookupKind, 'ClientOnlyJSX'>,
184
+ const BuiltInKindHandlers: Record<
185
+ Exclude<BuiltInLookupKind, 'ClientOnlyJSX'>,
133
186
  KindHandler
134
187
  > = {
135
188
  ServerFn: handleCreateServerFn,
@@ -140,8 +193,14 @@ const KindHandlers: Record<
140
193
  // ClientOnlyJSX is handled separately via JSX traversal, not here
141
194
  }
142
195
 
196
+ const BuiltInKindHandlerOrder: Array<
197
+ Exclude<BuiltInLookupKind, 'ClientOnlyJSX'>
198
+ > = ['ServerFn', 'Middleware', 'IsomorphicFn', 'ServerOnlyFn', 'ClientOnlyFn']
199
+
143
200
  // All lookup kinds as an array for iteration with proper typing
144
- const AllLookupKinds = Object.keys(LookupSetup) as Array<LookupKind>
201
+ const AllBuiltInLookupKinds = Object.keys(
202
+ BuiltInLookupSetup,
203
+ ) as Array<BuiltInLookupKind>
145
204
 
146
205
  /**
147
206
  * Detects which LookupKinds are present in the code using string matching.
@@ -150,24 +209,34 @@ const AllLookupKinds = Object.keys(LookupSetup) as Array<LookupKind>
150
209
  export function detectKindsInCode(
151
210
  code: string,
152
211
  env: 'client' | 'server',
212
+ opts?: {
213
+ compilerTransforms?: Array<StartCompilerImportTransform> | undefined
214
+ },
153
215
  ): Set<LookupKind> {
154
216
  const detected = new Set<LookupKind>()
155
- const validForEnv = LookupKindsPerEnv[env]
217
+ const validForEnv = getLookupKindsForEnv(env, opts)
156
218
 
157
- for (const kind of AllLookupKinds) {
219
+ for (const kind of AllBuiltInLookupKinds) {
158
220
  if (validForEnv.has(kind) && KindDetectionPatterns[kind].test(code)) {
159
221
  detected.add(kind)
160
222
  }
161
223
  }
162
224
 
225
+ for (const transform of opts?.compilerTransforms ?? []) {
226
+ if (!isCompilerTransformEnabledForEnv(transform, env)) continue
227
+ if (transform.detect.test(code)) {
228
+ detected.add(getExternalLookupKind(transform))
229
+ }
230
+ }
231
+
163
232
  return detected
164
233
  }
165
234
 
166
235
  // Pre-computed map: identifier name -> Set<LookupKind> for fast candidate detection (method chain only)
167
236
  // Multiple kinds can share the same identifier (e.g., 'server' and 'client' are used by both Middleware and IsomorphicFn)
168
237
  const IdentifierToKinds = new Map<string, Set<LookupKind>>()
169
- for (const kind of AllLookupKinds) {
170
- const setup = LookupSetup[kind]
238
+ for (const kind of AllBuiltInLookupKinds) {
239
+ const setup = BuiltInLookupSetup[kind]
171
240
  if (setup.type === 'methodChain') {
172
241
  for (const id of setup.candidateCallIdentifier) {
173
242
  let kinds = IdentifierToKinds.get(id)
@@ -180,15 +249,19 @@ for (const kind of AllLookupKinds) {
180
249
  }
181
250
  }
182
251
 
183
- // Factory function names for direct call patterns.
184
- // Used to filter nested candidates - we only want to include actual factory calls,
185
- // not invocations of already-created functions (e.g., `myServerFn()` should NOT be a candidate)
186
- const DirectCallFactoryNames = new Set<string>()
187
- for (const kind of AllLookupKinds) {
188
- const setup = LookupSetup[kind]
189
- if (setup.type === 'directCall') {
190
- DirectCallFactoryNames.add(setup.factoryName)
252
+ function getLookupSetup(
253
+ kind: LookupKind,
254
+ externalLookupSetup?: Map<ExternalLookupKind, DirectCallSetup>,
255
+ ): MethodChainSetup | DirectCallSetup | JSXSetup | undefined {
256
+ if (kind in BuiltInLookupSetup) {
257
+ return BuiltInLookupSetup[kind as BuiltInLookupKind]
258
+ }
259
+
260
+ if (isExternalLookupKind(kind)) {
261
+ return externalLookupSetup?.get(kind)
191
262
  }
263
+
264
+ return undefined
192
265
  }
193
266
 
194
267
  export type LookupConfig = {
@@ -206,19 +279,6 @@ interface ModuleInfo {
206
279
  reExportAllSources: Array<string>
207
280
  }
208
281
 
209
- /**
210
- * Computes whether any file kinds need direct-call candidate detection.
211
- * This applies to directCall types (ServerOnlyFn, ClientOnlyFn).
212
- */
213
- function needsDirectCallDetection(kinds: Set<LookupKind>): boolean {
214
- for (const kind of kinds) {
215
- if (LookupSetup[kind].type === 'directCall') {
216
- return true
217
- }
218
- }
219
- return false
220
- }
221
-
222
282
  /**
223
283
  * Checks if all kinds in the set are guaranteed to be top-level only.
224
284
  * Only ServerFn is always declared at module level (must be assigned to a variable).
@@ -232,9 +292,12 @@ function areAllKindsTopLevelOnly(kinds: Set<LookupKind>): boolean {
232
292
  /**
233
293
  * Checks if we need to detect JSX elements (e.g., <ClientOnly>).
234
294
  */
235
- function needsJSXDetection(kinds: Set<LookupKind>): boolean {
295
+ function needsJSXDetection(
296
+ kinds: Set<LookupKind>,
297
+ externalLookupSetup?: Map<ExternalLookupKind, DirectCallSetup>,
298
+ ): boolean {
236
299
  for (const kind of kinds) {
237
- if (LookupSetup[kind].type === 'jsx') {
300
+ if (getLookupSetup(kind, externalLookupSetup)?.type === 'jsx') {
238
301
  return true
239
302
  }
240
303
  }
@@ -247,7 +310,11 @@ function needsJSXDetection(kinds: Set<LookupKind>): boolean {
247
310
  * This is stricter than top-level detection because we need to filter out
248
311
  * invocations of existing server functions (e.g., `myServerFn()`).
249
312
  */
250
- function isNestedDirectCallCandidate(node: t.CallExpression): boolean {
313
+ function isNestedDirectCallCandidate(
314
+ node: t.CallExpression,
315
+ lookupKinds: Set<LookupKind>,
316
+ externalLookupSetup?: Map<ExternalLookupKind, DirectCallSetup>,
317
+ ): boolean {
251
318
  let calleeName: string | undefined
252
319
  if (t.isIdentifier(node.callee)) {
253
320
  calleeName = node.callee.name
@@ -257,7 +324,15 @@ function isNestedDirectCallCandidate(node: t.CallExpression): boolean {
257
324
  ) {
258
325
  calleeName = node.callee.property.name
259
326
  }
260
- return calleeName !== undefined && DirectCallFactoryNames.has(calleeName)
327
+ if (!calleeName) return false
328
+ for (const kind of lookupKinds) {
329
+ if (isExternalLookupKind(kind)) continue
330
+ const setup = getLookupSetup(kind, externalLookupSetup)
331
+ if (setup?.type === 'directCall' && setup.factoryNames.has(calleeName)) {
332
+ return true
333
+ }
334
+ }
335
+ return false
261
336
  }
262
337
 
263
338
  function isSimpleDirectCallExpression(node: t.CallExpression): boolean {
@@ -304,14 +379,87 @@ function isTopLevelDirectCallCandidate(
304
379
 
305
380
  function isDirectCallCandidateForKind(
306
381
  kind: Exclude<LookupKind, 'ClientOnlyJSX'>,
382
+ externalLookupSetup?: Map<ExternalLookupKind, DirectCallSetup>,
383
+ ): boolean {
384
+ return getLookupSetup(kind, externalLookupSetup)?.type === 'directCall'
385
+ }
386
+
387
+ function hasBuiltInDirectCallKinds(kinds: Set<LookupKind>): boolean {
388
+ for (const kind of kinds) {
389
+ if (isExternalLookupKind(kind)) continue
390
+ if (BuiltInLookupSetup[kind].type === 'directCall') return true
391
+ }
392
+ return false
393
+ }
394
+
395
+ function hasExternalLookupKinds(kinds: Set<LookupKind>): boolean {
396
+ for (const kind of kinds) {
397
+ if (isExternalLookupKind(kind)) return true
398
+ }
399
+ return false
400
+ }
401
+
402
+ interface ExternalDirectCallCandidates {
403
+ identifiers: Map<string, ExternalLookupKind>
404
+ namespaces: Map<string, Map<string, ExternalLookupKind>>
405
+ }
406
+
407
+ interface CallExpressionCandidate {
408
+ path: babel.NodePath<t.CallExpression>
409
+ /** Set when import scanning already proved the call's lookup kind. */
410
+ kind?: Exclude<LookupKind, 'ClientOnlyJSX'>
411
+ }
412
+
413
+ function hasExternalDirectCallCandidates(
414
+ candidates: ExternalDirectCallCandidates,
307
415
  ): boolean {
308
- return LookupSetup[kind].type === 'directCall'
416
+ return candidates.identifiers.size > 0 || candidates.namespaces.size > 0
417
+ }
418
+
419
+ function getExternalDirectCallCandidateKind(
420
+ path: babel.NodePath<t.CallExpression>,
421
+ candidates: ExternalDirectCallCandidates,
422
+ ): ExternalLookupKind | undefined {
423
+ const node = path.node
424
+
425
+ if (t.isIdentifier(node.callee)) {
426
+ const kind = candidates.identifiers.get(node.callee.name)
427
+ if (!kind) return undefined
428
+
429
+ const binding = path.scope.getBinding(node.callee.name)
430
+ return binding?.path.isImportSpecifier() ? kind : undefined
431
+ }
432
+
433
+ if (
434
+ t.isMemberExpression(node.callee) &&
435
+ t.isIdentifier(node.callee.object) &&
436
+ t.isIdentifier(node.callee.property)
437
+ ) {
438
+ const kind = candidates.namespaces
439
+ .get(node.callee.object.name)
440
+ ?.get(node.callee.property.name)
441
+ if (!kind) return undefined
442
+
443
+ const binding = path.scope.getBinding(node.callee.object.name)
444
+ return binding?.path.isImportNamespaceSpecifier() ? kind : undefined
445
+ }
446
+
447
+ return undefined
309
448
  }
310
449
 
311
450
  export class StartCompiler {
312
451
  private moduleCache = new Map<string, ModuleInfo>()
313
452
  private initialized = false
314
453
  private validLookupKinds: Set<LookupKind>
454
+ private externalTransformsByKind = new Map<
455
+ ExternalLookupKind,
456
+ StartCompilerImportTransform
457
+ >()
458
+ private externalLookupSetup = new Map<ExternalLookupKind, DirectCallSetup>()
459
+ private externalDirectCallKindsBySource = new Map<
460
+ string,
461
+ Map<string, ExternalLookupKind>
462
+ >()
315
463
  private resolveIdCache = new Map<string, string | null>()
316
464
  private exportResolutionCache = new Map<
317
465
  string,
@@ -360,6 +508,8 @@ export class StartCompiler {
360
508
  * Called after each file is compiled with its new functions.
361
509
  */
362
510
  onServerFnsById?: (d: Record<string, ServerFn>) => void
511
+ compilerTransforms?: Array<StartCompilerImportTransform> | undefined
512
+ serverFnProviderModuleDirectives?: ReadonlyArray<string> | undefined
363
513
  /**
364
514
  * Returns the currently known server functions from previous builds.
365
515
  * Used by server callers to look up canonical extracted filenames.
@@ -369,6 +519,31 @@ export class StartCompiler {
369
519
  },
370
520
  ) {
371
521
  this.validLookupKinds = options.lookupKinds
522
+ for (const transform of options.compilerTransforms ?? []) {
523
+ const kind = getExternalLookupKind(transform)
524
+ if (!this.validLookupKinds.has(kind)) continue
525
+
526
+ this.externalTransformsByKind.set(kind, transform)
527
+
528
+ const factoryNames = new Set<string>()
529
+ for (const entry of transform.imports) {
530
+ factoryNames.add(entry.rootExport)
531
+
532
+ let rootExports = this.externalDirectCallKindsBySource.get(
533
+ entry.libName,
534
+ )
535
+ if (!rootExports) {
536
+ rootExports = new Map()
537
+ this.externalDirectCallKindsBySource.set(entry.libName, rootExports)
538
+ }
539
+ rootExports.set(entry.rootExport, kind)
540
+ }
541
+
542
+ this.externalLookupSetup.set(kind, {
543
+ type: 'directCall',
544
+ factoryNames,
545
+ })
546
+ }
372
547
  }
373
548
 
374
549
  /**
@@ -446,6 +621,46 @@ export class StartCompiler {
446
621
  return this.options.mode ?? 'dev'
447
622
  }
448
623
 
624
+ private getExternalDirectCallCandidates(
625
+ kinds: Set<LookupKind>,
626
+ moduleInfo: ModuleInfo,
627
+ ): ExternalDirectCallCandidates {
628
+ const identifiers = new Map<string, ExternalLookupKind>()
629
+ const namespaces = new Map<string, Map<string, ExternalLookupKind>>()
630
+
631
+ if (this.externalDirectCallKindsBySource.size === 0) {
632
+ return { identifiers, namespaces }
633
+ }
634
+
635
+ for (const [localName, binding] of moduleInfo.bindings) {
636
+ if (binding.type !== 'import') continue
637
+
638
+ const rootExports = this.externalDirectCallKindsBySource.get(
639
+ binding.source,
640
+ )
641
+ if (!rootExports) continue
642
+
643
+ if (binding.importedName === '*') {
644
+ const namespaceExports = new Map<string, ExternalLookupKind>()
645
+ for (const [rootExport, kind] of rootExports) {
646
+ if (kinds.has(kind)) {
647
+ namespaceExports.set(rootExport, kind)
648
+ }
649
+ }
650
+ if (namespaceExports.size > 0) {
651
+ namespaces.set(localName, namespaceExports)
652
+ }
653
+ } else {
654
+ const kind = rootExports.get(binding.importedName)
655
+ if (kind && kinds.has(kind)) {
656
+ identifiers.set(localName, kind)
657
+ }
658
+ }
659
+ }
660
+
661
+ return { identifiers, namespaces }
662
+ }
663
+
449
664
  private async resolveIdCached(id: string, importer?: string) {
450
665
  if (this.mode === 'dev') {
451
666
  return this.options.resolveId(id, importer)
@@ -482,7 +697,7 @@ export class StartCompiler {
482
697
  ]),
483
698
  )
484
699
 
485
- // Register start-client-core exports for internal package usage (e.g., react-start-rsc).
700
+ // Register start-client-core exports for internal package usage.
486
701
  // These don't need module resolution - only the knownRootImports fast path.
487
702
  this.knownRootImports.set(
488
703
  '@tanstack/start-client-core',
@@ -506,8 +721,8 @@ export class StartCompiler {
506
721
  // For JSX lookups (e.g., ClientOnlyJSX), we only need the knownRootImports
507
722
  // fast path to verify imports. Skip synthetic root module setup.
508
723
  if (config.kind !== 'Root') {
509
- const setup = LookupSetup[config.kind]
510
- if (setup.type === 'jsx') {
724
+ const setup = getLookupSetup(config.kind, this.externalLookupSetup)
725
+ if (setup?.type === 'jsx') {
511
726
  continue
512
727
  }
513
728
  }
@@ -780,7 +995,12 @@ export class StartCompiler {
780
995
  return null
781
996
  }
782
997
 
783
- const checkDirectCalls = needsDirectCallDetection(fileKinds)
998
+ const hasExternalKinds = hasExternalLookupKinds(fileKinds)
999
+ const checkDirectCalls =
1000
+ hasBuiltInDirectCallKinds(fileKinds) ||
1001
+ (fileKinds.has('ServerFn') &&
1002
+ !hasExternalKinds &&
1003
+ hasBuiltInDirectCallKinds(this.validLookupKinds))
784
1004
  // Optimization: ServerFn is always a top-level declaration (must be assigned to a variable).
785
1005
  // If the file only has ServerFn, we can skip full AST traversal and only visit
786
1006
  // the specific top-level declarations that have candidates.
@@ -793,7 +1013,7 @@ export class StartCompiler {
793
1013
  // Single-pass traversal to:
794
1014
  // 1. Collect candidate paths (only candidates, not all CallExpressions)
795
1015
  // 2. Build a map for looking up paths of nested calls in method chains
796
- const candidatePaths: Array<babel.NodePath<t.CallExpression>> = []
1016
+ const candidatePaths: Array<CallExpressionCandidate> = []
797
1017
  // Map for nested chain lookup - only populated for CallExpressions that are
798
1018
  // part of a method chain (callee.object is a CallExpression)
799
1019
  const chainCallPaths = new Map<
@@ -803,9 +1023,16 @@ export class StartCompiler {
803
1023
 
804
1024
  // JSX candidates (e.g., <ClientOnly>)
805
1025
  const jsxCandidatePaths: Array<babel.NodePath<t.JSXElement>> = []
806
- const checkJSX = needsJSXDetection(fileKinds)
1026
+ const checkJSX = needsJSXDetection(fileKinds, this.externalLookupSetup)
807
1027
  // Get module info that was just cached by ingestModule
808
1028
  const moduleInfo = this.moduleCache.get(id)!
1029
+ const externalDirectCallCandidates = this.getExternalDirectCallCandidates(
1030
+ fileKinds,
1031
+ moduleInfo,
1032
+ )
1033
+ const checkExternalDirectCalls = hasExternalDirectCallCandidates(
1034
+ externalDirectCallCandidates,
1035
+ )
809
1036
 
810
1037
  if (canUseFastPath) {
811
1038
  // Fast path: only visit top-level statements that have potential candidates
@@ -829,7 +1056,8 @@ export class StartCompiler {
829
1056
  if (decl.init && t.isCallExpression(decl.init)) {
830
1057
  if (
831
1058
  isMethodChainCandidate(decl.init, fileKinds) ||
832
- isTopLevelDirectCallCandidateNode(decl.init)
1059
+ (checkDirectCalls &&
1060
+ isTopLevelDirectCallCandidateNode(decl.init))
833
1061
  ) {
834
1062
  candidateIndices.push(i)
835
1063
  break // Only need to mark this statement once
@@ -870,12 +1098,23 @@ export class StartCompiler {
870
1098
 
871
1099
  // Method chain pattern
872
1100
  if (isMethodChainCandidate(node, fileKinds)) {
873
- candidatePaths.push(path)
1101
+ candidatePaths.push({ path })
874
1102
  return
875
1103
  }
876
1104
 
1105
+ if (checkExternalDirectCalls) {
1106
+ const kind = getExternalDirectCallCandidateKind(
1107
+ path,
1108
+ externalDirectCallCandidates,
1109
+ )
1110
+ if (kind) {
1111
+ candidatePaths.push({ path, kind })
1112
+ return
1113
+ }
1114
+ }
1115
+
877
1116
  if (isTopLevelDirectCallCandidate(path)) {
878
- candidatePaths.push(path)
1117
+ candidatePaths.push({ path })
879
1118
  }
880
1119
  },
881
1120
  })
@@ -904,19 +1143,39 @@ export class StartCompiler {
904
1143
 
905
1144
  // Pattern 1: Method chain pattern (.handler(), .server(), .client(), etc.)
906
1145
  if (isMethodChainCandidate(node, fileKinds)) {
907
- candidatePaths.push(path)
1146
+ candidatePaths.push({ path })
908
1147
  return
909
1148
  }
910
1149
 
911
- if (isTopLevelDirectCallCandidate(path)) {
912
- candidatePaths.push(path)
1150
+ // External direct-call transforms are import-bound. Direct imports
1151
+ // already identify the transform kind, so skip async import tracing.
1152
+ if (checkExternalDirectCalls) {
1153
+ const kind = getExternalDirectCallCandidateKind(
1154
+ path,
1155
+ externalDirectCallCandidates,
1156
+ )
1157
+ if (kind) {
1158
+ candidatePaths.push({ path, kind })
1159
+ return
1160
+ }
1161
+ }
1162
+
1163
+ if (checkDirectCalls && isTopLevelDirectCallCandidate(path)) {
1164
+ candidatePaths.push({ path })
913
1165
  return
914
1166
  }
915
1167
 
916
1168
  // Pattern 2: Direct call pattern
917
1169
  if (checkDirectCalls) {
918
- if (isNestedDirectCallCandidate(node)) {
919
- candidatePaths.push(path)
1170
+ if (
1171
+ isNestedDirectCallCandidate(
1172
+ node,
1173
+ fileKinds,
1174
+ this.externalLookupSetup,
1175
+ )
1176
+ ) {
1177
+ candidatePaths.push({ path })
1178
+ return
920
1179
  }
921
1180
  }
922
1181
  },
@@ -955,13 +1214,34 @@ export class StartCompiler {
955
1214
  return null
956
1215
  }
957
1216
 
958
- // Resolve all candidates in parallel to determine their kinds
959
- const resolvedCandidates = await Promise.all(
960
- candidatePaths.map(async (path) => ({
961
- path,
962
- kind: await this.resolveExprKind(path.node, id),
963
- })),
964
- )
1217
+ // Resolve only candidates whose import scan did not already prove the kind.
1218
+ const resolvedCandidates: Array<{
1219
+ path: babel.NodePath<t.CallExpression>
1220
+ kind: Kind
1221
+ }> = []
1222
+ const unresolvedCandidates: Array<CallExpressionCandidate> = []
1223
+
1224
+ for (const candidate of candidatePaths) {
1225
+ if (candidate.kind) {
1226
+ resolvedCandidates.push({
1227
+ path: candidate.path,
1228
+ kind: candidate.kind,
1229
+ })
1230
+ } else {
1231
+ unresolvedCandidates.push(candidate)
1232
+ }
1233
+ }
1234
+
1235
+ if (unresolvedCandidates.length > 0) {
1236
+ resolvedCandidates.push(
1237
+ ...(await Promise.all(
1238
+ unresolvedCandidates.map(async (candidate) => ({
1239
+ path: candidate.path,
1240
+ kind: await this.resolveExprKind(candidate.path.node, id),
1241
+ })),
1242
+ )),
1243
+ )
1244
+ }
965
1245
 
966
1246
  // Filter to valid candidates
967
1247
  const validCandidates = resolvedCandidates.filter(({ path, kind }) => {
@@ -976,7 +1256,7 @@ export class StartCompiler {
976
1256
  kind !== 'ClientOnlyJSX' &&
977
1257
  !isMethodChainCandidate(path.node, fileKinds)
978
1258
  ) {
979
- return isDirectCallCandidateForKind(kind)
1259
+ return isDirectCallCandidateForKind(kind, this.externalLookupSetup)
980
1260
  }
981
1261
 
982
1262
  return true
@@ -1062,9 +1342,16 @@ export class StartCompiler {
1062
1342
  root: this.options.root,
1063
1343
  framework: this.options.framework,
1064
1344
  providerEnvName: this.options.providerEnvName,
1345
+ types: t,
1346
+ parseExpression: (expressionCode) =>
1347
+ babel.template.expression(expressionCode, {
1348
+ placeholderPattern: false,
1349
+ })() as t.Expression,
1065
1350
 
1066
1351
  generateFunctionId: (opts) => this.generateFunctionId(opts),
1067
1352
  getKnownServerFns: this.options.getKnownServerFns,
1353
+ serverFnProviderModuleDirectives:
1354
+ this.options.serverFnProviderModuleDirectives,
1068
1355
  onServerFnsById: this.options.onServerFnsById,
1069
1356
  }
1070
1357
 
@@ -1084,12 +1371,19 @@ export class StartCompiler {
1084
1371
  }
1085
1372
  }
1086
1373
 
1087
- // Process each kind using its registered handler
1088
- for (const [kind, candidates] of candidatesByKind) {
1089
- const handler = KindHandlers[kind]
1374
+ // External transforms run before built-ins by default so they can augment
1375
+ // user handlers before server function extraction clones provider bodies.
1376
+ this.runExternalTransforms('pre', candidatesByKind, context)
1377
+
1378
+ for (const kind of BuiltInKindHandlerOrder) {
1379
+ const candidates = candidatesByKind.get(kind)
1380
+ if (!candidates) continue
1381
+ const handler = BuiltInKindHandlers[kind]
1090
1382
  handler(candidates, context, kind)
1091
1383
  }
1092
1384
 
1385
+ this.runExternalTransforms('post', candidatesByKind, context)
1386
+
1093
1387
  // Handle JSX candidates (e.g., <ClientOnly>)
1094
1388
  // Validation was already done during traversal - just call the handler
1095
1389
  for (const jsxPath of jsxCandidatePaths) {
@@ -1116,6 +1410,24 @@ export class StartCompiler {
1116
1410
  return result
1117
1411
  }
1118
1412
 
1413
+ private runExternalTransforms(
1414
+ order: 'pre' | 'post',
1415
+ candidatesByKind: Map<
1416
+ Exclude<LookupKind, 'ClientOnlyJSX'>,
1417
+ Array<RewriteCandidate>
1418
+ >,
1419
+ context: CompilationContext,
1420
+ ) {
1421
+ for (const [kind, transform] of this.externalTransformsByKind) {
1422
+ if ((transform.order ?? 'pre') !== order) continue
1423
+
1424
+ const candidates = candidatesByKind.get(kind)
1425
+ if (!candidates) continue
1426
+
1427
+ transform.transform(candidates, context)
1428
+ }
1429
+ }
1430
+
1119
1431
  private async resolveIdentifierKind(
1120
1432
  ident: string,
1121
1433
  id: string,
@@ -1293,7 +1605,8 @@ export class StartCompiler {
1293
1605
  // `const createSO = createServerOnlyFn` should still propagate the kind.
1294
1606
  if (
1295
1607
  isLookupKind(resolvedKind) &&
1296
- LookupSetup[resolvedKind].type === 'directCall' &&
1608
+ getLookupSetup(resolvedKind, this.externalLookupSetup)?.type ===
1609
+ 'directCall' &&
1297
1610
  binding.init &&
1298
1611
  t.isCallExpression(binding.init)
1299
1612
  ) {
@@ -1420,6 +1733,12 @@ export class StartCompiler {
1420
1733
  binding.type === 'import' &&
1421
1734
  binding.importedName === '*'
1422
1735
  ) {
1736
+ const knownExports = this.knownRootImports.get(binding.source)
1737
+ const knownKind = knownExports?.get(callee.property.name)
1738
+ if (knownKind) {
1739
+ return knownKind
1740
+ }
1741
+
1423
1742
  // resolve the property from the target module
1424
1743
  const targetModuleId = await this.resolveIdCached(
1425
1744
  binding.source,