@tanstack/start-plugin-core 1.161.3 → 1.162.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.
Files changed (32) hide show
  1. package/dist/esm/import-protection-plugin/defaults.d.ts +6 -4
  2. package/dist/esm/import-protection-plugin/defaults.js +3 -12
  3. package/dist/esm/import-protection-plugin/defaults.js.map +1 -1
  4. package/dist/esm/import-protection-plugin/plugin.d.ts +1 -1
  5. package/dist/esm/import-protection-plugin/plugin.js +488 -257
  6. package/dist/esm/import-protection-plugin/plugin.js.map +1 -1
  7. package/dist/esm/import-protection-plugin/postCompileUsage.d.ts +4 -2
  8. package/dist/esm/import-protection-plugin/postCompileUsage.js +31 -150
  9. package/dist/esm/import-protection-plugin/postCompileUsage.js.map +1 -1
  10. package/dist/esm/import-protection-plugin/rewriteDeniedImports.js +13 -9
  11. package/dist/esm/import-protection-plugin/rewriteDeniedImports.js.map +1 -1
  12. package/dist/esm/import-protection-plugin/sourceLocation.d.ts +32 -66
  13. package/dist/esm/import-protection-plugin/sourceLocation.js +129 -56
  14. package/dist/esm/import-protection-plugin/sourceLocation.js.map +1 -1
  15. package/dist/esm/import-protection-plugin/trace.d.ts +10 -0
  16. package/dist/esm/import-protection-plugin/trace.js +30 -44
  17. package/dist/esm/import-protection-plugin/trace.js.map +1 -1
  18. package/dist/esm/import-protection-plugin/utils.d.ts +8 -4
  19. package/dist/esm/import-protection-plugin/utils.js +43 -1
  20. package/dist/esm/import-protection-plugin/utils.js.map +1 -1
  21. package/dist/esm/import-protection-plugin/virtualModules.d.ts +7 -1
  22. package/dist/esm/import-protection-plugin/virtualModules.js +104 -135
  23. package/dist/esm/import-protection-plugin/virtualModules.js.map +1 -1
  24. package/package.json +8 -8
  25. package/src/import-protection-plugin/defaults.ts +8 -19
  26. package/src/import-protection-plugin/plugin.ts +776 -433
  27. package/src/import-protection-plugin/postCompileUsage.ts +57 -229
  28. package/src/import-protection-plugin/rewriteDeniedImports.ts +34 -42
  29. package/src/import-protection-plugin/sourceLocation.ts +184 -185
  30. package/src/import-protection-plugin/trace.ts +38 -49
  31. package/src/import-protection-plugin/utils.ts +62 -1
  32. package/src/import-protection-plugin/virtualModules.ts +163 -177
@@ -1,4 +1,3 @@
1
- import * as path from 'pathe'
2
1
  import { normalizePath } from 'vite'
3
2
 
4
3
  import { resolveViteId } from '../utils'
@@ -11,7 +10,15 @@ import {
11
10
  } from './defaults'
12
11
  import { findPostCompileUsagePos } from './postCompileUsage'
13
12
  import { compileMatchers, matchesAny } from './matchers'
14
- import { dedupePatterns, normalizeFilePath } from './utils'
13
+ import {
14
+ clearNormalizeFilePathCache,
15
+ dedupePatterns,
16
+ escapeRegExp,
17
+ extractImportSources,
18
+ getOrCreate,
19
+ normalizeFilePath,
20
+ relativizePath,
21
+ } from './utils'
15
22
  import { collectMockExportNamesBySource } from './rewriteDeniedImports'
16
23
  import {
17
24
  MARKER_PREFIX,
@@ -30,16 +37,18 @@ import {
30
37
  mockRuntimeModuleIdFromViolation,
31
38
  } from './virtualModules'
32
39
  import {
40
+ ImportLocCache,
33
41
  addTraceImportLocations,
34
42
  buildCodeSnippet,
35
43
  buildLineIndex,
44
+ clearImportPatternCache,
36
45
  findImportStatementLocationFromTransformed,
37
46
  findPostCompileUsageLocation,
38
47
  pickOriginalCodeFromSourcesContent,
39
48
  } from './sourceLocation'
40
- import type { PluginOption } from 'vite'
49
+ import type { PluginOption, ViteDevServer } from 'vite'
41
50
  import type { CompiledMatcher } from './matchers'
42
- import type { ViolationInfo } from './trace'
51
+ import type { Loc, TraceStep, ViolationInfo } from './trace'
43
52
  import type {
44
53
  SourceMapLike,
45
54
  TransformResult,
@@ -51,10 +60,30 @@ import type {
51
60
  } from '../schema'
52
61
  import type { CompileStartFrameworkOptions, GetConfigFn } from '../types'
53
62
 
54
- // Re-export public API that tests and other consumers depend on.
63
+ const SERVER_FN_LOOKUP_QUERY = '?' + SERVER_FN_LOOKUP
64
+ const RESOLVED_MARKER_SERVER_ONLY = resolveViteId(`${MARKER_PREFIX}server-only`)
65
+ const RESOLVED_MARKER_CLIENT_ONLY = resolveViteId(`${MARKER_PREFIX}client-only`)
66
+
67
+ const IMPORT_PROTECTION_DEBUG =
68
+ process.env.TSR_IMPORT_PROTECTION_DEBUG === '1' ||
69
+ process.env.TSR_IMPORT_PROTECTION_DEBUG === 'true'
70
+ const IMPORT_PROTECTION_DEBUG_FILTER =
71
+ process.env.TSR_IMPORT_PROTECTION_DEBUG_FILTER
72
+
73
+ function debugLog(...args: Array<unknown>) {
74
+ if (!IMPORT_PROTECTION_DEBUG) return
75
+ console.warn('[import-protection:debug]', ...args)
76
+ }
77
+
78
+ /** Check if a value matches the debug filter (when set). */
79
+ function matchesDebugFilter(...values: Array<string>): boolean {
80
+ if (!IMPORT_PROTECTION_DEBUG_FILTER) return true
81
+ return values.some((v) => v.includes(IMPORT_PROTECTION_DEBUG_FILTER))
82
+ }
83
+
55
84
  export { RESOLVED_MOCK_MODULE_ID } from './virtualModules'
56
85
  export { rewriteDeniedImports } from './rewriteDeniedImports'
57
- export { dedupePatterns } from './utils'
86
+ export { dedupePatterns, extractImportSources } from './utils'
58
87
  export type { Pattern } from './utils'
59
88
 
60
89
  /**
@@ -68,6 +97,9 @@ interface PluginConfig {
68
97
  srcDirectory: string
69
98
  framework: CompileStartFrameworkOptions
70
99
 
100
+ /** Absolute, query-free entry file ids used for trace roots. */
101
+ entryFiles: Array<string>
102
+
71
103
  effectiveBehavior: ImportProtectionBehavior
72
104
  mockAccess: 'error' | 'warn' | 'off'
73
105
  logMode: 'once' | 'always'
@@ -119,20 +151,65 @@ interface EnvState {
119
151
  resolveCacheByFile: Map<string, Set<string>>
120
152
 
121
153
  /** Import location cache. Key: `${importerFile}::${source}`. */
122
- importLocCache: Map<
123
- string,
124
- { file?: string; line: number; column: number } | null
125
- >
126
- /** Reverse index: file path → Set of importLocCache keys for that file. */
127
- importLocByFile: Map<string, Set<string>>
154
+ importLocCache: ImportLocCache
128
155
 
129
156
  /** Deduplication of logged violations (no env prefix in key). */
130
157
  seenViolations: Set<string>
131
158
 
159
+ /**
160
+ * Modules transitively loaded during a `fetchModule(?SERVER_FN_LOOKUP)` call.
161
+ * In dev mode the compiler calls `fetchModule(id + '?' + SERVER_FN_LOOKUP)` to
162
+ * analyse a module's exports. The direct target carries the query parameter so
163
+ * `isPreTransformResolve` is `true`. But Vite also resolves the target's own
164
+ * imports (and their imports, etc.) with the plain file path as the importer —
165
+ * those would otherwise fire false-positive violations.
166
+ *
167
+ * We record every module resolved while walking a SERVER_FN_LOOKUP chain so
168
+ * that their child imports are also treated as pre-transform resolves.
169
+ */
170
+ serverFnLookupModules: Set<string>
171
+
132
172
  /** Transform result cache (code + composed sourcemap + original source). */
133
173
  transformResultCache: Map<string, TransformResult>
134
174
  /** Reverse index: physical file path → Set of transformResultCache keys. */
135
175
  transformResultKeysByFile: Map<string, Set<string>>
176
+
177
+ /** Cached provider that reads from {@link transformResultCache}. */
178
+ transformResultProvider: TransformResultProvider
179
+
180
+ /**
181
+ * Post-transform resolved imports. Populated by the transform-cache hook
182
+ * after resolving every import source found in the transformed code.
183
+ * Key: transform cache key (normalised module ID incl. query params).
184
+ * Value: set of resolved child file paths.
185
+ */
186
+ postTransformImports: Map<string, Set<string>>
187
+
188
+ /**
189
+ * Whether a `resolveId` call without an importer has been observed for this
190
+ * environment since `buildStart`. Vite calls `resolveId(source, undefined)`
191
+ * for true entry modules during a cold start. On warm start (`.vite` cache
192
+ * exists), Vite reuses its module graph and does NOT call `resolveId` for
193
+ * entries, so this stays `false`.
194
+ *
195
+ * When `false`, the import graph is considered unreliable (edges may be
196
+ * missing) and violations are reported immediately instead of deferred.
197
+ */
198
+ hasSeenEntry: boolean
199
+
200
+ /**
201
+ * Violations deferred in dev mock mode. Keyed by the violating importer's
202
+ * normalized file path. Violations are confirmed or discarded by the
203
+ * transform-cache hook once enough post-transform data is available to
204
+ * determine whether the importer is still reachable from an entry point.
205
+ */
206
+ pendingViolations: Map<string, Array<PendingViolation>>
207
+ }
208
+
209
+ interface PendingViolation {
210
+ info: ViolationInfo
211
+ /** The mock module ID that resolveId already returned for this violation. */
212
+ mockReturnValue: string
136
213
  }
137
214
 
138
215
  /**
@@ -155,12 +232,115 @@ export interface ImportProtectionPluginOptions {
155
232
  export function importProtectionPlugin(
156
233
  opts: ImportProtectionPluginOptions,
157
234
  ): PluginOption {
235
+ let devServer: ViteDevServer | null = null
236
+
237
+ type ModuleGraphNode = {
238
+ id?: string | null
239
+ url?: string
240
+ importers: Set<ModuleGraphNode>
241
+ }
242
+
243
+ /**
244
+ * Build an import trace using Vite's per-environment module graph, which
245
+ * is authoritative even on warm starts when the plugin's own ImportGraph
246
+ * may be incomplete (Vite skips resolveId for cached modules).
247
+ */
248
+ function buildTraceFromModuleGraph(
249
+ envName: string,
250
+ env: EnvState,
251
+ targetFile: string,
252
+ ): Array<TraceStep> | null {
253
+ if (!devServer) return null
254
+ const environment = devServer.environments[envName]
255
+ if (!environment) return null
256
+
257
+ const file = normalizeFilePath(targetFile)
258
+ const start = environment.moduleGraph.getModuleById(file)
259
+ if (!start) return null
260
+
261
+ // Resolve a module graph node to its normalized file path once and
262
+ // cache the result so BFS + reconstruction don't recompute.
263
+ const nodeIds = new Map<ModuleGraphNode, string>()
264
+ function nodeId(n: ModuleGraphNode): string {
265
+ let cached = nodeIds.get(n)
266
+ if (cached === undefined) {
267
+ cached = n.id
268
+ ? normalizeFilePath(n.id)
269
+ : n.url
270
+ ? normalizeFilePath(n.url)
271
+ : ''
272
+ nodeIds.set(n, cached)
273
+ }
274
+ return cached
275
+ }
276
+
277
+ const queue: Array<ModuleGraphNode> = [start]
278
+ const visited = new Set<ModuleGraphNode>([start])
279
+ const parent = new Map<ModuleGraphNode, ModuleGraphNode>()
280
+
281
+ let entryRoot: ModuleGraphNode | null = null
282
+ let fallbackRoot: ModuleGraphNode | null = null
283
+ let qi = 0
284
+ while (qi < queue.length) {
285
+ const node = queue[qi++]!
286
+ const id = nodeId(node)
287
+
288
+ if (id && env.graph.entries.has(id)) {
289
+ entryRoot = node
290
+ break
291
+ }
292
+
293
+ const importers = node.importers
294
+ if (importers.size === 0) {
295
+ if (!fallbackRoot) fallbackRoot = node
296
+ continue
297
+ }
298
+
299
+ for (const imp of importers) {
300
+ if (visited.has(imp)) continue
301
+ visited.add(imp)
302
+ parent.set(imp, node)
303
+ queue.push(imp)
304
+ }
305
+ }
306
+
307
+ const root = entryRoot ?? fallbackRoot
308
+
309
+ if (!root) return null
310
+
311
+ // Reconstruct: root -> ... -> start
312
+ const chain: Array<ModuleGraphNode> = []
313
+ let cur: ModuleGraphNode | undefined = root
314
+ for (let i = 0; i < config.maxTraceDepth + 2 && cur; i++) {
315
+ chain.push(cur)
316
+ if (cur === start) break
317
+ cur = parent.get(cur)
318
+ }
319
+
320
+ const steps: Array<TraceStep> = []
321
+ for (let i = 0; i < chain.length; i++) {
322
+ const id = nodeId(chain[i]!)
323
+ if (!id) continue
324
+ let specifier: string | undefined
325
+ if (i + 1 < chain.length) {
326
+ const nextId = nodeId(chain[i + 1]!)
327
+ if (nextId) {
328
+ specifier = env.graph.reverseEdges.get(nextId)?.get(id)
329
+ }
330
+ }
331
+ steps.push(specifier ? { file: id, specifier } : { file: id })
332
+ }
333
+
334
+ return steps.length ? steps : null
335
+ }
336
+
158
337
  const config: PluginConfig = {
159
338
  enabled: true,
160
339
  root: '',
161
340
  command: 'build',
162
341
  srcDirectory: '',
163
342
  framework: opts.framework,
343
+ entryFiles: [],
164
344
  effectiveBehavior: 'error',
165
345
  mockAccess: 'error',
166
346
  logMode: 'once',
@@ -180,51 +360,6 @@ export function importProtectionPlugin(
180
360
  const envStates = new Map<string, EnvState>()
181
361
  const shared: SharedState = { fileMarkerKind: new Map() }
182
362
 
183
- // ---------------------------------------------------------------------------
184
- // Internal helpers
185
- // ---------------------------------------------------------------------------
186
-
187
- /**
188
- * Create a per-env `importLocCache` whose `.set` method automatically
189
- * maintains the reverse index (`importLocByFile`) for O(1) invalidation
190
- * in `hotUpdate`.
191
- *
192
- * Cache keys have the format `${importerFile}::${source}`.
193
- */
194
- function createImportLocCache(
195
- env: EnvState,
196
- ): Map<string, { file?: string; line: number; column: number } | null> {
197
- const cache = new Map<
198
- string,
199
- { file?: string; line: number; column: number } | null
200
- >()
201
- const originalSet = cache.set.bind(cache)
202
- cache.set = function (key, value) {
203
- originalSet(key, value)
204
- const sepIdx = key.indexOf('::')
205
- if (sepIdx !== -1) {
206
- const file = key.slice(0, sepIdx)
207
- let fileKeys = env.importLocByFile.get(file)
208
- if (!fileKeys) {
209
- fileKeys = new Set()
210
- env.importLocByFile.set(file, fileKeys)
211
- }
212
- fileKeys.add(key)
213
- }
214
- return this
215
- }
216
- return cache
217
- }
218
-
219
- function getMockEdgeExports(
220
- env: EnvState,
221
- importerId: string,
222
- source: string,
223
- ): Array<string> {
224
- const importerFile = normalizeFilePath(importerId)
225
- return env.mockExportsByImporter.get(importerFile)?.get(source) ?? []
226
- }
227
-
228
363
  function getMarkerKindForFile(
229
364
  fileId: string,
230
365
  ): 'server' | 'client' | undefined {
@@ -232,45 +367,61 @@ export function importProtectionPlugin(
232
367
  return shared.fileMarkerKind.get(file)
233
368
  }
234
369
 
370
+ type ViolationReporter = {
371
+ warn: (msg: string) => void
372
+ error: (msg: string) => never
373
+ }
374
+
235
375
  /**
236
- * Build a {@link TransformResultProvider} for the given environment.
376
+ * Build the best available trace for a module and enrich each step with
377
+ * line/column locations. Tries the plugin's own ImportGraph first, then
378
+ * Vite's moduleGraph (authoritative on warm start), keeping whichever is
379
+ * longer. Annotates the last step with the denied specifier + location.
237
380
  *
238
- * The provider reads from the transform result cache that is populated by
239
- * the `tanstack-start-core:import-protection-transform-cache` plugin's
240
- * transform hook.
381
+ * Shared by {@link buildViolationInfo} and {@link processPendingViolations}.
241
382
  */
242
- function getTransformResultProvider(env: EnvState): TransformResultProvider {
243
- return {
244
- getTransformResult(id: string) {
245
- // Try the full normalized ID first (preserves query params like
246
- // ?tsr-split=component for virtual modules).
247
- const fullKey = normalizePath(id)
248
- const exact = env.transformResultCache.get(fullKey)
249
- if (exact) return exact
250
-
251
- // Fall back to the query-stripped path for modules looked up by
252
- // their physical file path (e.g. trace steps, modules without
253
- // query params).
254
- const strippedKey = normalizeFilePath(id)
255
- return strippedKey !== fullKey
256
- ? env.transformResultCache.get(strippedKey)
257
- : undefined
258
- },
383
+ async function rebuildAndAnnotateTrace(
384
+ provider: TransformResultProvider,
385
+ env: EnvState,
386
+ envName: string,
387
+ normalizedImporter: string,
388
+ specifier: string,
389
+ importerLoc: Loc | undefined,
390
+ traceOverride?: Array<TraceStep>,
391
+ ): Promise<Array<TraceStep>> {
392
+ let trace =
393
+ traceOverride ??
394
+ buildTrace(env.graph, normalizedImporter, config.maxTraceDepth)
395
+
396
+ if (config.command === 'serve') {
397
+ const mgTrace = buildTraceFromModuleGraph(
398
+ envName,
399
+ env,
400
+ normalizedImporter,
401
+ )
402
+ if (mgTrace && mgTrace.length > trace.length) {
403
+ trace = mgTrace
404
+ }
259
405
  }
260
- }
406
+ await addTraceImportLocations(provider, trace, env.importLocCache)
261
407
 
262
- type ViolationReporter = {
263
- warn: (msg: string) => void
264
- error: (msg: string) => never
408
+ if (trace.length > 0) {
409
+ const last = trace[trace.length - 1]!
410
+ if (!last.specifier) last.specifier = specifier
411
+ if (importerLoc && last.line == null) {
412
+ last.line = importerLoc.line
413
+ last.column = importerLoc.column
414
+ }
415
+ }
416
+
417
+ return trace
265
418
  }
266
419
 
267
420
  /**
268
421
  * Build a complete {@link ViolationInfo} with trace, location, and snippet.
269
422
  *
270
423
  * This is the single path that all violation types go through: specifier,
271
- * file, and marker. Centralizing it eliminates the duplicated sequences of
272
- * `buildTrace` → `addTraceImportLocations` → location lookup → annotate →
273
- * snippet that previously appeared 5 times in the codebase.
424
+ * file, and marker.
274
425
  */
275
426
  async function buildViolationInfo(
276
427
  provider: TransformResultProvider,
@@ -291,14 +442,8 @@ export function importProtectionPlugin(
291
442
  | 'snippet'
292
443
  | 'importerLoc'
293
444
  >,
445
+ traceOverride?: Array<TraceStep>,
294
446
  ): Promise<ViolationInfo> {
295
- const trace = buildTrace(
296
- env.graph,
297
- normalizedImporter,
298
- config.maxTraceDepth,
299
- )
300
- await addTraceImportLocations(provider, trace, env.importLocCache)
301
-
302
447
  const loc =
303
448
  (await findPostCompileUsageLocation(
304
449
  provider,
@@ -313,16 +458,15 @@ export function importProtectionPlugin(
313
458
  env.importLocCache,
314
459
  ))
315
460
 
316
- // Annotate the last trace step with the denied import's specifier and
317
- // location so every trace step (including the leaf) gets file:line:col.
318
- if (trace.length > 0) {
319
- const last = trace[trace.length - 1]!
320
- if (!last.specifier) last.specifier = source
321
- if (loc && last.line == null) {
322
- last.line = loc.line
323
- last.column = loc.column
324
- }
325
- }
461
+ const trace = await rebuildAndAnnotateTrace(
462
+ provider,
463
+ env,
464
+ envName,
465
+ normalizedImporter,
466
+ source,
467
+ loc,
468
+ traceOverride,
469
+ )
326
470
 
327
471
  const snippet = loc ? buildCodeSnippet(provider, importer, loc) : undefined
328
472
 
@@ -339,8 +483,14 @@ export function importProtectionPlugin(
339
483
  }
340
484
  }
341
485
 
342
- async function maybeReportMarkerViolationFromResolvedImport(
343
- ctx: ViolationReporter,
486
+ /**
487
+ * Check if a resolved import violates marker restrictions (e.g. importing
488
+ * a server-only module in the client env). If so, build and return the
489
+ * {@link ViolationInfo} — the caller is responsible for reporting/deferring.
490
+ *
491
+ * Returns `undefined` when the resolved import has no marker conflict.
492
+ */
493
+ async function buildMarkerViolationFromResolvedImport(
344
494
  provider: TransformResultProvider,
345
495
  env: EnvState,
346
496
  envName: string,
@@ -349,8 +499,8 @@ export function importProtectionPlugin(
349
499
  source: string,
350
500
  resolvedId: string,
351
501
  relativePath: string,
352
- opts?: { silent?: boolean },
353
- ): Promise<ReturnType<typeof handleViolation> | undefined> {
502
+ traceOverride?: Array<TraceStep>,
503
+ ): Promise<ViolationInfo | undefined> {
354
504
  const markerKind = getMarkerKindForFile(resolvedId)
355
505
  const violates =
356
506
  (envType === 'client' && markerKind === 'server') ||
@@ -359,7 +509,7 @@ export function importProtectionPlugin(
359
509
 
360
510
  const normalizedImporter = normalizeFilePath(importer)
361
511
 
362
- const info = await buildViolationInfo(
512
+ return buildViolationInfo(
363
513
  provider,
364
514
  env,
365
515
  envName,
@@ -375,19 +525,8 @@ export function importProtectionPlugin(
375
525
  ? `Module "${relativePath}" is marked server-only but is imported in the client environment`
376
526
  : `Module "${relativePath}" is marked client-only but is imported in the server environment`,
377
527
  },
528
+ traceOverride,
378
529
  )
379
-
380
- return handleViolation.call(ctx, env, info, opts)
381
- }
382
-
383
- function buildMockEdgeModuleId(
384
- env: EnvState,
385
- importerId: string,
386
- source: string,
387
- runtimeId: string,
388
- ): string {
389
- const exports = getMockEdgeExports(env, importerId, source)
390
- return makeMockEdgeModuleId(exports, source, runtimeId)
391
530
  }
392
531
 
393
532
  function getEnvType(envName: string): 'client' | 'server' {
@@ -416,7 +555,7 @@ export function importProtectionPlugin(
416
555
  function getEnv(envName: string): EnvState {
417
556
  let envState = envStates.get(envName)
418
557
  if (!envState) {
419
- const importLocByFile = new Map<string, Set<string>>()
558
+ const transformResultCache = new Map<string, TransformResult>()
420
559
  envState = {
421
560
  graph: new ImportGraph(),
422
561
  deniedSources: new Set(),
@@ -424,50 +563,58 @@ export function importProtectionPlugin(
424
563
  mockExportsByImporter: new Map(),
425
564
  resolveCache: new Map(),
426
565
  resolveCacheByFile: new Map(),
427
- importLocCache: new Map(), // placeholder, replaced below
428
- importLocByFile,
566
+ importLocCache: new ImportLocCache(),
429
567
  seenViolations: new Set(),
430
- transformResultCache: new Map(),
568
+ transformResultCache,
431
569
  transformResultKeysByFile: new Map(),
570
+ transformResultProvider: {
571
+ getTransformResult(id: string) {
572
+ const fullKey = normalizePath(id)
573
+ const exact = transformResultCache.get(fullKey)
574
+ if (exact) return exact
575
+ const strippedKey = normalizeFilePath(id)
576
+ return strippedKey !== fullKey
577
+ ? transformResultCache.get(strippedKey)
578
+ : undefined
579
+ },
580
+ },
581
+ postTransformImports: new Map(),
582
+ hasSeenEntry: false,
583
+ serverFnLookupModules: new Set(),
584
+ pendingViolations: new Map(),
432
585
  }
433
- // Install reverse-index-maintaining importLocCache
434
- envState.importLocCache = createImportLocCache(envState)
435
586
  envStates.set(envName, envState)
436
587
  }
437
588
  return envState
438
589
  }
439
590
 
591
+ const shouldCheckImporterCache = new Map<string, boolean>()
440
592
  function shouldCheckImporter(importer: string): boolean {
441
- // Normalize for matching
442
- const relativePath = path.relative(config.root, importer)
593
+ let result = shouldCheckImporterCache.get(importer)
594
+ if (result !== undefined) return result
595
+
596
+ const relativePath = relativizePath(importer, config.root)
443
597
 
444
- // Check exclude first
445
598
  if (
446
599
  config.excludeMatchers.length > 0 &&
447
600
  matchesAny(relativePath, config.excludeMatchers)
448
601
  ) {
449
- return false
450
- }
451
-
452
- // Check ignore importers
453
- if (
602
+ result = false
603
+ } else if (
454
604
  config.ignoreImporterMatchers.length > 0 &&
455
605
  matchesAny(relativePath, config.ignoreImporterMatchers)
456
606
  ) {
457
- return false
458
- }
459
-
460
- // Check include
461
- if (config.includeMatchers.length > 0) {
462
- return !!matchesAny(relativePath, config.includeMatchers)
463
- }
464
-
465
- // Default: check if within srcDirectory
466
- if (config.srcDirectory) {
467
- return importer.startsWith(config.srcDirectory)
607
+ result = false
608
+ } else if (config.includeMatchers.length > 0) {
609
+ result = !!matchesAny(relativePath, config.includeMatchers)
610
+ } else if (config.srcDirectory) {
611
+ result = importer.startsWith(config.srcDirectory)
612
+ } else {
613
+ result = true
468
614
  }
469
615
 
470
- return true
616
+ shouldCheckImporterCache.set(importer, result)
617
+ return result
471
618
  }
472
619
 
473
620
  function dedupeKey(
@@ -487,12 +634,279 @@ export function importProtectionPlugin(
487
634
  }
488
635
 
489
636
  function getRelativePath(absolutePath: string): string {
490
- return normalizePath(path.relative(config.root, absolutePath))
637
+ return relativizePath(normalizePath(absolutePath), config.root)
638
+ }
639
+
640
+ /** Register known Start entrypoints as trace roots for all environments. */
641
+ function registerEntries(): void {
642
+ const { resolvedStartConfig } = opts.getConfig()
643
+ for (const envDef of opts.environments) {
644
+ const envState = getEnv(envDef.name)
645
+ if (resolvedStartConfig.routerFilePath) {
646
+ envState.graph.addEntry(
647
+ normalizePath(resolvedStartConfig.routerFilePath),
648
+ )
649
+ }
650
+ if (resolvedStartConfig.startFilePath) {
651
+ envState.graph.addEntry(
652
+ normalizePath(resolvedStartConfig.startFilePath),
653
+ )
654
+ }
655
+ }
656
+ }
657
+
658
+ function checkPostTransformReachability(
659
+ env: EnvState,
660
+ file: string,
661
+ ): 'reachable' | 'unreachable' | 'unknown' {
662
+ const visited = new Set<string>()
663
+ const queue: Array<string> = [file]
664
+ let hasUnknownEdge = false
665
+ let qi = 0
666
+
667
+ while (qi < queue.length) {
668
+ const current = queue[qi++]!
669
+ if (visited.has(current)) continue
670
+ visited.add(current)
671
+
672
+ if (env.graph.entries.has(current)) {
673
+ return 'reachable'
674
+ }
675
+
676
+ // Walk reverse edges
677
+ const importers = env.graph.reverseEdges.get(current)
678
+ if (!importers) continue
679
+
680
+ for (const [parent] of importers) {
681
+ if (visited.has(parent)) continue
682
+
683
+ // Check all code-split variants for this parent. The edge is
684
+ // live if ANY variant's resolved imports include `current`.
685
+ const keySet = env.transformResultKeysByFile.get(parent)
686
+ let anyVariantCached = false
687
+ let edgeLive = false
688
+
689
+ if (keySet) {
690
+ for (const k of keySet) {
691
+ const resolvedImports = env.postTransformImports.get(k)
692
+ if (resolvedImports) {
693
+ anyVariantCached = true
694
+ if (resolvedImports.has(current)) {
695
+ edgeLive = true
696
+ break
697
+ }
698
+ }
699
+ }
700
+ }
701
+
702
+ // Fallback: direct file-path key
703
+ if (!anyVariantCached) {
704
+ const resolvedImports = env.postTransformImports.get(parent)
705
+ if (resolvedImports) {
706
+ anyVariantCached = true
707
+ if (resolvedImports.has(current)) {
708
+ edgeLive = true
709
+ }
710
+ }
711
+ }
712
+
713
+ if (!anyVariantCached) {
714
+ const hasTransformResult =
715
+ env.transformResultCache.has(parent) ||
716
+ (keySet ? keySet.size > 0 : false)
717
+
718
+ if (hasTransformResult) {
719
+ // Transform ran but postTransformImports not yet populated
720
+ hasUnknownEdge = true
721
+ continue
722
+ }
723
+
724
+ // Transform never ran — Vite served from cache (warm start).
725
+ // Conservatively treat edge as live.
726
+ queue.push(parent)
727
+ continue
728
+ }
729
+
730
+ if (edgeLive) {
731
+ queue.push(parent)
732
+ }
733
+ }
734
+ }
735
+
736
+ return hasUnknownEdge ? 'unknown' : 'unreachable'
737
+ }
738
+
739
+ /**
740
+ * Process pending violations for the given environment. Called from the
741
+ * transform-cache hook after each module transform is cached, because new
742
+ * transform data may allow us to confirm or discard pending violations.
743
+ *
744
+ * @param warnFn - `this.warn` from the transform hook context
745
+ */
746
+ async function processPendingViolations(
747
+ env: EnvState,
748
+ warnFn: (msg: string) => void,
749
+ ): Promise<void> {
750
+ if (env.pendingViolations.size === 0) return
751
+
752
+ const toDelete: Array<string> = []
753
+
754
+ for (const [file, violations] of env.pendingViolations) {
755
+ // On warm start, skip graph reachability — confirm immediately.
756
+ const status = env.hasSeenEntry
757
+ ? checkPostTransformReachability(env, file)
758
+ : 'reachable'
759
+
760
+ if (status === 'reachable') {
761
+ for (const pv of violations) {
762
+ const key = dedupeKey(
763
+ pv.info.type,
764
+ pv.info.importer,
765
+ pv.info.specifier,
766
+ pv.info.resolved,
767
+ )
768
+ if (!hasSeen(env, key)) {
769
+ const freshTrace = await rebuildAndAnnotateTrace(
770
+ env.transformResultProvider,
771
+ env,
772
+ pv.info.env,
773
+ pv.info.importer,
774
+ pv.info.specifier,
775
+ pv.info.importerLoc,
776
+ )
777
+ if (freshTrace.length > pv.info.trace.length) {
778
+ pv.info.trace = freshTrace
779
+ }
780
+
781
+ if (config.onViolation) {
782
+ const result = config.onViolation(pv.info)
783
+ if (result === false) continue
784
+ }
785
+ warnFn(formatViolation(pv.info, config.root))
786
+ }
787
+ }
788
+ toDelete.push(file)
789
+ } else if (status === 'unreachable') {
790
+ toDelete.push(file)
791
+ }
792
+ // 'unknown' — keep pending for next transform-cache invocation.
793
+ }
794
+
795
+ for (const file of toDelete) {
796
+ env.pendingViolations.delete(file)
797
+ }
491
798
  }
492
799
 
493
- // ---------------------------------------------------------------------------
494
- // Vite plugins
495
- // ---------------------------------------------------------------------------
800
+ /**
801
+ * Record a violation as pending for later confirmation via graph
802
+ * reachability. Called from `resolveId` when `shouldDefer` is true.
803
+ */
804
+ function deferViolation(
805
+ env: EnvState,
806
+ importerFile: string,
807
+ info: ViolationInfo,
808
+ mockReturnValue:
809
+ | { id: string; syntheticNamedExports: boolean }
810
+ | string
811
+ | undefined,
812
+ ): void {
813
+ getOrCreate(env.pendingViolations, importerFile, () => []).push({
814
+ info,
815
+ mockReturnValue:
816
+ typeof mockReturnValue === 'string'
817
+ ? mockReturnValue
818
+ : (mockReturnValue?.id ?? ''),
819
+ })
820
+ }
821
+
822
+ function handleViolation(
823
+ ctx: ViolationReporter,
824
+ env: EnvState,
825
+ info: ViolationInfo,
826
+ violationOpts?: { silent?: boolean },
827
+ ): { id: string; syntheticNamedExports: boolean } | string | undefined {
828
+ const key = dedupeKey(
829
+ info.type,
830
+ info.importer,
831
+ info.specifier,
832
+ info.resolved,
833
+ )
834
+
835
+ if (!violationOpts?.silent) {
836
+ if (config.onViolation) {
837
+ const result = config.onViolation(info)
838
+ if (result === false) {
839
+ return undefined
840
+ }
841
+ }
842
+
843
+ const seen = hasSeen(env, key)
844
+
845
+ if (config.effectiveBehavior === 'error') {
846
+ if (!seen) ctx.error(formatViolation(info, config.root))
847
+ return undefined
848
+ }
849
+
850
+ if (!seen) {
851
+ ctx.warn(formatViolation(info, config.root))
852
+ }
853
+ } else {
854
+ if (config.effectiveBehavior === 'error') {
855
+ return undefined
856
+ }
857
+ }
858
+
859
+ env.deniedSources.add(info.specifier)
860
+ getOrCreate(env.deniedEdges, info.importer, () => new Set<string>()).add(
861
+ info.specifier,
862
+ )
863
+
864
+ if (config.command === 'serve') {
865
+ const runtimeId = mockRuntimeModuleIdFromViolation(
866
+ info,
867
+ config.mockAccess,
868
+ config.root,
869
+ )
870
+ const importerFile = normalizeFilePath(info.importer)
871
+ const exports =
872
+ env.mockExportsByImporter.get(importerFile)?.get(info.specifier) ?? []
873
+ return resolveViteId(
874
+ makeMockEdgeModuleId(exports, info.specifier, runtimeId),
875
+ )
876
+ }
877
+
878
+ // Build: Rollup uses syntheticNamedExports
879
+ return { id: RESOLVED_MOCK_MODULE_ID, syntheticNamedExports: true }
880
+ }
881
+
882
+ /**
883
+ * Unified violation dispatch: either defers or reports immediately.
884
+ *
885
+ * When `shouldDefer` is true, calls `handleViolation` silently to obtain
886
+ * the mock module ID, stores the violation as pending, and triggers
887
+ * `processPendingViolations`. Otherwise reports (or silences for
888
+ * pre-transform resolves) immediately.
889
+ *
890
+ * Returns the mock module ID / resolve result from `handleViolation`.
891
+ */
892
+ async function reportOrDeferViolation(
893
+ ctx: ViolationReporter,
894
+ env: EnvState,
895
+ importerFile: string,
896
+ info: ViolationInfo,
897
+ shouldDefer: boolean,
898
+ isPreTransformResolve: boolean,
899
+ ): Promise<ReturnType<typeof handleViolation>> {
900
+ if (shouldDefer) {
901
+ const result = handleViolation(ctx, env, info, { silent: true })
902
+ deferViolation(env, importerFile, info, result)
903
+ await processPendingViolations(env, ctx.warn.bind(ctx))
904
+ return result
905
+ }
906
+ return handleViolation(ctx, env, info, {
907
+ silent: isPreTransformResolve,
908
+ })
909
+ }
496
910
 
497
911
  return [
498
912
  {
@@ -513,10 +927,14 @@ export function importProtectionPlugin(
513
927
  const { startConfig, resolvedStartConfig } = opts.getConfig()
514
928
  config.srcDirectory = resolvedStartConfig.srcDirectory
515
929
 
930
+ config.entryFiles = [
931
+ resolvedStartConfig.routerFilePath,
932
+ resolvedStartConfig.startFilePath,
933
+ ].filter((f): f is string => Boolean(f))
934
+
516
935
  const userOpts: ImportProtectionOptions | undefined =
517
936
  startConfig.importProtection
518
937
 
519
- // Determine if plugin is enabled
520
938
  if (userOpts?.enabled === false) {
521
939
  config.enabled = false
522
940
  return
@@ -524,7 +942,6 @@ export function importProtectionPlugin(
524
942
 
525
943
  config.enabled = true
526
944
 
527
- // Determine effective behavior
528
945
  if (userOpts?.behavior) {
529
946
  if (typeof userOpts.behavior === 'string') {
530
947
  config.effectiveBehavior = userOpts.behavior
@@ -535,38 +952,27 @@ export function importProtectionPlugin(
535
952
  : (userOpts.behavior.build ?? 'error')
536
953
  }
537
954
  } else {
538
- // Defaults: dev='mock', build='error'
539
955
  config.effectiveBehavior =
540
956
  viteConfig.command === 'serve' ? 'mock' : 'error'
541
957
  }
542
958
 
543
- // Log mode
544
959
  config.logMode = userOpts?.log ?? 'once'
545
-
546
- // Mock runtime access diagnostics
547
960
  config.mockAccess = userOpts?.mockAccess ?? 'error'
548
-
549
- // Max trace depth
550
961
  config.maxTraceDepth = userOpts?.maxTraceDepth ?? 20
962
+ if (userOpts?.onViolation) {
963
+ const fn = userOpts.onViolation
964
+ config.onViolation = (info) => fn(info)
965
+ }
551
966
 
552
- // User callback
553
- config.onViolation = userOpts?.onViolation as
554
- | ((info: ViolationInfo) => boolean | void)
555
- | undefined
556
-
557
- // Get default rules
558
- const defaults = getDefaultImportProtectionRules(opts.framework)
967
+ const defaults = getDefaultImportProtectionRules()
559
968
 
560
- // Merge user rules with defaults and compile matchers per env.
561
- // IMPORTANT: client specifier denies for Start server entrypoints must
562
- // always include the framework defaults even when the user provides a
563
- // custom list.
969
+ // Client specifier denies always include framework defaults even
970
+ // when the user provides a custom list.
564
971
  const clientSpecifiers = dedupePatterns([
565
972
  ...defaults.client.specifiers,
566
973
  ...(userOpts?.client?.specifiers ?? []),
567
974
  ])
568
975
 
569
- // For file patterns, user config overrides defaults.
570
976
  const clientFiles = userOpts?.client?.files
571
977
  ? [...userOpts.client.files]
572
978
  : [...defaults.client.files]
@@ -600,41 +1006,35 @@ export function importProtectionPlugin(
600
1006
  }
601
1007
 
602
1008
  // Marker specifiers
603
- const markers = getMarkerSpecifiers(opts.framework)
1009
+ const markers = getMarkerSpecifiers()
604
1010
  config.markerSpecifiers = {
605
1011
  serverOnly: new Set(markers.serverOnly),
606
1012
  clientOnly: new Set(markers.clientOnly),
607
1013
  }
1014
+ },
608
1015
 
609
- // Use known Start env entrypoints as trace roots.
610
- // This makes traces deterministic and prevents 1-line traces.
611
- for (const envDef of opts.environments) {
612
- const envState = getEnv(envDef.name)
613
-
614
- if (resolvedStartConfig.routerFilePath) {
615
- envState.graph.addEntry(
616
- normalizePath(resolvedStartConfig.routerFilePath),
617
- )
618
- }
619
- if (resolvedStartConfig.startFilePath) {
620
- envState.graph.addEntry(
621
- normalizePath(resolvedStartConfig.startFilePath),
622
- )
623
- }
624
- }
1016
+ configureServer(server) {
1017
+ devServer = server
625
1018
  },
626
1019
 
627
1020
  buildStart() {
628
1021
  if (!config.enabled) return
1022
+ // Clear memoization caches that grow unboundedly across builds
1023
+ clearNormalizeFilePathCache()
1024
+ clearImportPatternCache()
1025
+ shouldCheckImporterCache.clear()
1026
+
629
1027
  // Clear per-env caches
630
1028
  for (const envState of envStates.values()) {
631
1029
  envState.resolveCache.clear()
632
1030
  envState.resolveCacheByFile.clear()
633
1031
  envState.importLocCache.clear()
634
- envState.importLocByFile.clear()
635
1032
  envState.seenViolations.clear()
636
1033
  envState.transformResultCache.clear()
637
1034
  envState.transformResultKeysByFile.clear()
1035
+ envState.postTransformImports.clear()
1036
+ envState.hasSeenEntry = false
1037
+ envState.serverFnLookupModules.clear()
638
1038
  envState.graph.clear()
639
1039
  envState.deniedSources.clear()
640
1040
  envState.deniedEdges.clear()
@@ -644,21 +1044,7 @@ export function importProtectionPlugin(
644
1044
  // Clear shared state
645
1045
  shared.fileMarkerKind.clear()
646
1046
 
647
- // Re-add known entries after clearing.
648
- for (const envDef of opts.environments) {
649
- const envState = getEnv(envDef.name)
650
- const { resolvedStartConfig } = opts.getConfig()
651
- if (resolvedStartConfig.routerFilePath) {
652
- envState.graph.addEntry(
653
- normalizePath(resolvedStartConfig.routerFilePath),
654
- )
655
- }
656
- if (resolvedStartConfig.startFilePath) {
657
- envState.graph.addEntry(
658
- normalizePath(resolvedStartConfig.startFilePath),
659
- )
660
- }
661
- }
1047
+ registerEntries()
662
1048
  },
663
1049
 
664
1050
  hotUpdate(ctx) {
@@ -672,14 +1058,7 @@ export function importProtectionPlugin(
672
1058
 
673
1059
  // Invalidate per-env caches
674
1060
  for (const envState of envStates.values()) {
675
- // Invalidate cached import locations using reverse index
676
- const locKeys = envState.importLocByFile.get(importerFile)
677
- if (locKeys) {
678
- for (const key of locKeys) {
679
- envState.importLocCache.delete(key)
680
- }
681
- envState.importLocByFile.delete(importerFile)
682
- }
1061
+ envState.importLocCache.deleteByFile(importerFile)
683
1062
 
684
1063
  // Invalidate resolve cache using reverse index
685
1064
  const resolveKeys = envState.resolveCacheByFile.get(importerFile)
@@ -694,6 +1073,8 @@ export function importProtectionPlugin(
694
1073
  envState.graph.invalidate(importerFile)
695
1074
  envState.deniedEdges.delete(importerFile)
696
1075
  envState.mockExportsByImporter.delete(importerFile)
1076
+ envState.serverFnLookupModules.delete(importerFile)
1077
+ envState.pendingViolations.delete(importerFile)
697
1078
 
698
1079
  // Invalidate transform result cache for this file.
699
1080
  const transformKeys =
@@ -701,11 +1082,13 @@ export function importProtectionPlugin(
701
1082
  if (transformKeys) {
702
1083
  for (const key of transformKeys) {
703
1084
  envState.transformResultCache.delete(key)
1085
+ envState.postTransformImports.delete(key)
704
1086
  }
705
1087
  envState.transformResultKeysByFile.delete(importerFile)
706
1088
  } else {
707
1089
  // Fallback: at least clear the physical-file entry.
708
1090
  envState.transformResultCache.delete(importerFile)
1091
+ envState.postTransformImports.delete(importerFile)
709
1092
  }
710
1093
  }
711
1094
  }
@@ -713,13 +1096,36 @@ export function importProtectionPlugin(
713
1096
  },
714
1097
 
715
1098
  async resolveId(source, importer, _options) {
716
- if (!config.enabled) return undefined
717
1099
  const envName = this.environment.name
718
1100
  const env = getEnv(envName)
719
1101
  const envType = getEnvType(envName)
720
- const provider = getTransformResultProvider(env)
1102
+ const provider = env.transformResultProvider
1103
+ const isScanResolve = !!(_options as Record<string, unknown>).scan
1104
+
1105
+ if (IMPORT_PROTECTION_DEBUG) {
1106
+ const importerPath = importer
1107
+ ? normalizeFilePath(importer)
1108
+ : '(entry)'
1109
+ const isEntryResolve = !importer
1110
+ const filtered =
1111
+ IMPORT_PROTECTION_DEBUG_FILTER === 'entry'
1112
+ ? isEntryResolve
1113
+ : matchesDebugFilter(source, importerPath)
1114
+ if (filtered) {
1115
+ debugLog('resolveId', {
1116
+ env: envName,
1117
+ envType,
1118
+ source,
1119
+ importer: importerPath,
1120
+ isEntryResolve,
1121
+ hasSeenEntry: env.hasSeenEntry,
1122
+ command: config.command,
1123
+ behavior: config.effectiveBehavior,
1124
+ })
1125
+ }
1126
+ }
721
1127
 
722
- // Internal virtual modules must resolve in dev.
1128
+ // Internal virtual modules
723
1129
  if (source === MOCK_MODULE_ID) {
724
1130
  return RESOLVED_MOCK_MODULE_ID
725
1131
  }
@@ -733,115 +1139,95 @@ export function importProtectionPlugin(
733
1139
  return resolveViteId(source)
734
1140
  }
735
1141
 
736
- // Skip if no importer (entry points)
737
1142
  if (!importer) {
738
- // Track entry-ish modules so traces can terminate.
739
- // Vite may pass virtual ids here; normalize but keep them.
740
1143
  env.graph.addEntry(source)
1144
+ env.hasSeenEntry = true
741
1145
  return undefined
742
1146
  }
743
1147
 
744
- // Skip virtual modules
745
1148
  if (source.startsWith('\0') || source.startsWith('virtual:')) {
746
1149
  return undefined
747
1150
  }
748
1151
 
749
- // Two code paths resolve imports from raw (pre-compiler-transform)
750
- // source in dev mode:
751
- //
752
- // 1. The Start compiler calls `fetchModule(id + '?' + SERVER_FN_LOOKUP)`
753
- // to inspect a child module's exports. The compiler's own transform
754
- // is excluded for these requests, so Vite sees the original imports.
755
- //
756
- // 2. Vite's dep-optimizer scanner (`options.scan === true`) uses esbuild
757
- // to discover bare imports for pre-bundling. esbuild reads raw source
758
- // without running Vite transform hooks, so it also sees imports that
759
- // the compiler would normally strip.
760
- //
761
- // In both cases the imports are NOT real client-side imports. We must
762
- // suppress violation *reporting* (no warnings / errors) but still return
763
- // mock module IDs so that transitive resolution doesn't blow up.
1152
+ const normalizedImporter = normalizeFilePath(importer)
1153
+ const isDirectLookup = importer.includes(SERVER_FN_LOOKUP_QUERY)
1154
+
1155
+ if (isDirectLookup) {
1156
+ env.serverFnLookupModules.add(normalizedImporter)
1157
+ }
1158
+
764
1159
  const isPreTransformResolve =
765
- importer.includes('?' + SERVER_FN_LOOKUP) ||
766
- !!(_options as Record<string, unknown>).scan
1160
+ isDirectLookup ||
1161
+ env.serverFnLookupModules.has(normalizedImporter) ||
1162
+ isScanResolve
1163
+
1164
+ // Dev mock mode: defer violations until post-transform data is
1165
+ // available, then confirm/discard via graph reachability.
1166
+ const isDevMock =
1167
+ config.command === 'serve' && config.effectiveBehavior === 'mock'
1168
+
1169
+ const shouldDefer = isDevMock && !isPreTransformResolve
767
1170
 
768
1171
  // Check if this is a marker import
769
- if (config.markerSpecifiers.serverOnly.has(source)) {
770
- // Record importer as server-only
771
- const resolvedImporter = normalizeFilePath(importer)
772
- const existing = shared.fileMarkerKind.get(resolvedImporter)
773
- if (existing && existing !== 'server') {
1172
+ const markerKind = config.markerSpecifiers.serverOnly.has(source)
1173
+ ? ('server' as const)
1174
+ : config.markerSpecifiers.clientOnly.has(source)
1175
+ ? ('client' as const)
1176
+ : undefined
1177
+
1178
+ if (markerKind) {
1179
+ const existing = shared.fileMarkerKind.get(normalizedImporter)
1180
+ if (existing && existing !== markerKind) {
774
1181
  this.error(
775
- `[import-protection] File "${getRelativePath(resolvedImporter)}" has both server-only and client-only markers. This is not allowed.`,
1182
+ `[import-protection] File "${getRelativePath(normalizedImporter)}" has both server-only and client-only markers. This is not allowed.`,
776
1183
  )
777
1184
  }
778
- shared.fileMarkerKind.set(resolvedImporter, 'server')
1185
+ shared.fileMarkerKind.set(normalizedImporter, markerKind)
1186
+
1187
+ const violatesEnv =
1188
+ (envType === 'client' && markerKind === 'server') ||
1189
+ (envType === 'server' && markerKind === 'client')
779
1190
 
780
- // If we're in the client environment, this is a violation
781
- if (envType === 'client') {
1191
+ if (violatesEnv) {
782
1192
  const info = await buildViolationInfo(
783
1193
  provider,
784
1194
  env,
785
1195
  envName,
786
1196
  envType,
787
1197
  importer,
788
- resolvedImporter,
1198
+ normalizedImporter,
789
1199
  source,
790
1200
  {
791
1201
  type: 'marker',
792
- message: `Module "${getRelativePath(resolvedImporter)}" is marked server-only but is imported in the client environment`,
1202
+ message:
1203
+ markerKind === 'server'
1204
+ ? `Module "${getRelativePath(normalizedImporter)}" is marked server-only but is imported in the client environment`
1205
+ : `Module "${getRelativePath(normalizedImporter)}" is marked client-only but is imported in the server environment`,
793
1206
  },
794
1207
  )
795
- handleViolation.call(this, env, info, {
796
- silent: isPreTransformResolve,
797
- })
798
- }
799
-
800
- // Return virtual empty module
801
- return resolveViteId(`${MARKER_PREFIX}server-only`)
802
- }
803
-
804
- if (config.markerSpecifiers.clientOnly.has(source)) {
805
- const resolvedImporter = normalizeFilePath(importer)
806
- const existing = shared.fileMarkerKind.get(resolvedImporter)
807
- if (existing && existing !== 'client') {
808
- this.error(
809
- `[import-protection] File "${getRelativePath(resolvedImporter)}" has both server-only and client-only markers. This is not allowed.`,
810
- )
811
- }
812
- shared.fileMarkerKind.set(resolvedImporter, 'client')
813
-
814
- if (envType === 'server') {
815
- const info = await buildViolationInfo(
816
- provider,
1208
+ await reportOrDeferViolation(
1209
+ this,
817
1210
  env,
818
- envName,
819
- envType,
820
- importer,
821
- resolvedImporter,
822
- source,
823
- {
824
- type: 'marker',
825
- message: `Module "${getRelativePath(resolvedImporter)}" is marked client-only but is imported in the server environment`,
826
- },
1211
+ normalizedImporter,
1212
+ info,
1213
+ shouldDefer,
1214
+ isPreTransformResolve,
827
1215
  )
828
- handleViolation.call(this, env, info, {
829
- silent: isPreTransformResolve,
830
- })
831
1216
  }
832
1217
 
833
- return resolveViteId(`${MARKER_PREFIX}client-only`)
1218
+ return markerKind === 'server'
1219
+ ? RESOLVED_MARKER_SERVER_ONLY
1220
+ : RESOLVED_MARKER_CLIENT_ONLY
834
1221
  }
835
1222
 
836
1223
  // Check if the importer is within our scope
837
- const normalizedImporter = normalizeFilePath(importer)
838
1224
  if (!shouldCheckImporter(normalizedImporter)) {
839
1225
  return undefined
840
1226
  }
841
1227
 
842
1228
  const matchers = getRulesForEnvironment(envName)
843
1229
 
844
- // 1. Specifier-based denial (fast, no resolution needed)
1230
+ // 1. Specifier-based denial
845
1231
  const specifierMatch = matchesAny(source, matchers.specifiers)
846
1232
  if (specifierMatch) {
847
1233
  env.graph.addEdge(source, normalizedImporter, source)
@@ -856,46 +1242,48 @@ export function importProtectionPlugin(
856
1242
  {
857
1243
  type: 'specifier',
858
1244
  pattern: specifierMatch.pattern,
859
- message: `Import "${source}" is denied in the "${envName}" environment`,
1245
+ message: `Import "${source}" is denied in the ${envType} environment`,
860
1246
  },
861
1247
  )
862
- return handleViolation.call(this, env, info, {
863
- silent: isPreTransformResolve,
864
- })
1248
+ return reportOrDeferViolation(
1249
+ this,
1250
+ env,
1251
+ normalizedImporter,
1252
+ info,
1253
+ shouldDefer,
1254
+ isPreTransformResolve,
1255
+ )
865
1256
  }
866
1257
 
867
- // 2. Resolve the import (cached) — needed for file-based denial,
868
- // marker checks, and graph edge tracking.
1258
+ // 2. Resolve the import (cached)
869
1259
  const cacheKey = `${normalizedImporter}:${source}`
870
1260
  let resolved: string | null
871
1261
 
872
1262
  if (env.resolveCache.has(cacheKey)) {
873
- resolved = env.resolveCache.get(cacheKey) || null
1263
+ resolved = env.resolveCache.get(cacheKey) ?? null
874
1264
  } else {
875
1265
  const result = await this.resolve(source, importer, {
876
1266
  skipSelf: true,
877
1267
  })
878
1268
  resolved = result ? normalizeFilePath(result.id) : null
879
1269
  env.resolveCache.set(cacheKey, resolved)
880
-
881
- // Maintain reverse index for O(1) hotUpdate invalidation.
882
- // Index by the importer so that when a file changes, all resolve
883
- // cache entries where it was the importer are cleared.
884
- let fileKeys = env.resolveCacheByFile.get(normalizedImporter)
885
- if (!fileKeys) {
886
- fileKeys = new Set()
887
- env.resolveCacheByFile.set(normalizedImporter, fileKeys)
888
- }
889
- fileKeys.add(cacheKey)
1270
+ getOrCreate(
1271
+ env.resolveCacheByFile,
1272
+ normalizedImporter,
1273
+ () => new Set(),
1274
+ ).add(cacheKey)
890
1275
  }
891
1276
 
892
1277
  if (resolved) {
893
1278
  const relativePath = getRelativePath(resolved)
894
1279
 
895
- // Always record the edge for trace building, even when not denied.
1280
+ // Propagate pre-transform status transitively
1281
+ if (isPreTransformResolve && !isScanResolve) {
1282
+ env.serverFnLookupModules.add(resolved)
1283
+ }
1284
+
896
1285
  env.graph.addEdge(resolved, normalizedImporter, source)
897
1286
 
898
- // File-based denial check
899
1287
  const fileMatch =
900
1288
  matchers.files.length > 0
901
1289
  ? matchesAny(relativePath, matchers.files)
@@ -914,17 +1302,20 @@ export function importProtectionPlugin(
914
1302
  type: 'file',
915
1303
  pattern: fileMatch.pattern,
916
1304
  resolved,
917
- message: `Import "${source}" (resolved to "${relativePath}") is denied in the "${envName}" environment`,
1305
+ message: `Import "${source}" (resolved to "${relativePath}") is denied in the ${envType} environment`,
918
1306
  },
919
1307
  )
920
- return handleViolation.call(this, env, info, {
921
- silent: isPreTransformResolve,
922
- })
1308
+ return reportOrDeferViolation(
1309
+ this,
1310
+ env,
1311
+ normalizedImporter,
1312
+ info,
1313
+ shouldDefer,
1314
+ isPreTransformResolve,
1315
+ )
923
1316
  }
924
1317
 
925
- // Marker restrictions apply regardless of explicit deny rules.
926
- const markerRes = await maybeReportMarkerViolationFromResolvedImport(
927
- this,
1318
+ const markerInfo = await buildMarkerViolationFromResolvedImport(
928
1319
  provider,
929
1320
  env,
930
1321
  envName,
@@ -933,10 +1324,16 @@ export function importProtectionPlugin(
933
1324
  source,
934
1325
  resolved,
935
1326
  relativePath,
936
- { silent: isPreTransformResolve },
937
1327
  )
938
- if (markerRes !== undefined) {
939
- return markerRes
1328
+ if (markerInfo) {
1329
+ return reportOrDeferViolation(
1330
+ this,
1331
+ env,
1332
+ normalizedImporter,
1333
+ markerInfo,
1334
+ shouldDefer,
1335
+ isPreTransformResolve,
1336
+ )
940
1337
  }
941
1338
  }
942
1339
 
@@ -946,11 +1343,26 @@ export function importProtectionPlugin(
946
1343
  load: {
947
1344
  filter: {
948
1345
  id: new RegExp(
949
- `(${RESOLVED_MOCK_MODULE_ID.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}|${RESOLVED_MARKER_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}|${RESOLVED_MOCK_EDGE_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}|${RESOLVED_MOCK_RUNTIME_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`,
1346
+ [
1347
+ RESOLVED_MOCK_MODULE_ID,
1348
+ RESOLVED_MARKER_PREFIX,
1349
+ RESOLVED_MOCK_EDGE_PREFIX,
1350
+ RESOLVED_MOCK_RUNTIME_PREFIX,
1351
+ ]
1352
+ .map(escapeRegExp)
1353
+ .join('|'),
950
1354
  ),
951
1355
  },
952
1356
  handler(id) {
953
- if (!config.enabled) return undefined
1357
+ if (IMPORT_PROTECTION_DEBUG) {
1358
+ if (matchesDebugFilter(id)) {
1359
+ debugLog('load:handler', {
1360
+ env: this.environment.name,
1361
+ id: normalizePath(id),
1362
+ })
1363
+ }
1364
+ }
1365
+
954
1366
  if (id === RESOLVED_MOCK_MODULE_ID) {
955
1367
  return loadSilentMockModule()
956
1368
  }
@@ -976,25 +1388,9 @@ export function importProtectionPlugin(
976
1388
  },
977
1389
  },
978
1390
  {
979
- // This plugin runs WITHOUT `enforce` so it executes after all
980
- // `enforce: 'pre'` transform hooks (including the Start compiler).
981
- // It captures the transformed code + composed sourcemap for every module
982
- // so that the `resolveId` hook (in the main plugin above) can look up
983
- // the importer's transform result and map violation locations back to
984
- // original source.
985
- //
986
- // Why not use `ctx.load()` in `resolveId`?
987
- // - Vite dev: `this.load()` returns a ModuleInfo proxy that throws on
988
- // `.code` access — code is not exposed.
989
- // - Rollup build: `ModuleInfo` has `.code` but NOT `.map`, so we
990
- // can't map generated positions back to original source.
991
- //
992
- // By caching in the transform hook we get both code and the composed
993
- // sourcemap that chains all the way back to the original file.
994
- //
995
- // Performance: only files under `srcDirectory` are cached because only
996
- // those can be importers in a violation. Third-party code in
997
- // node_modules is never checked.
1391
+ // Captures transformed code + composed sourcemap for location mapping.
1392
+ // Runs after all `enforce: 'pre'` hooks (including the Start compiler).
1393
+ // Only files under `srcDirectory` are cached.
998
1394
  name: 'tanstack-start-core:import-protection-transform-cache',
999
1395
 
1000
1396
  applyToEnvironment(env) {
@@ -1008,33 +1404,32 @@ export function importProtectionPlugin(
1008
1404
  include: [/\.[cm]?[tj]sx?($|\?)/],
1009
1405
  },
1010
1406
  },
1011
- handler(code, id) {
1012
- if (!config.enabled) return undefined
1407
+ async handler(code, id) {
1013
1408
  const envName = this.environment.name
1014
1409
  const file = normalizeFilePath(id)
1015
1410
 
1016
- // Only cache files that could ever be checked as an importer.
1017
- // This reuses the same include/exclude/ignoreImporters predicate as
1018
- // the main import-protection resolveId hook.
1411
+ if (IMPORT_PROTECTION_DEBUG) {
1412
+ if (matchesDebugFilter(file)) {
1413
+ debugLog('transform-cache', {
1414
+ env: envName,
1415
+ id: normalizePath(id),
1416
+ file,
1417
+ })
1418
+ }
1419
+ }
1420
+
1019
1421
  if (!shouldCheckImporter(file)) {
1020
1422
  return undefined
1021
1423
  }
1022
1424
 
1023
- // getCombinedSourcemap() returns the composed sourcemap of all
1024
- // transform hooks that ran before this one. It includes
1025
- // sourcesContent so we can extract original source later.
1425
+ // getCombinedSourcemap() returns the composed sourcemap
1026
1426
  let map: SourceMapLike | undefined
1027
1427
  try {
1028
1428
  map = this.getCombinedSourcemap()
1029
1429
  } catch {
1030
- // No sourcemap available (e.g. virtual modules or modules
1031
- // that no prior plugin produced a map for).
1032
1430
  map = undefined
1033
1431
  }
1034
1432
 
1035
- // Extract the original source from sourcesContent right here.
1036
- // Composed sourcemaps can contain multiple sources; try to pick the
1037
- // entry that best matches this importer.
1038
1433
  let originalCode: string | undefined
1039
1434
  if (map?.sourcesContent) {
1040
1435
  originalCode = pickOriginalCodeFromSourcesContent(
@@ -1044,15 +1439,16 @@ export function importProtectionPlugin(
1044
1439
  )
1045
1440
  }
1046
1441
 
1047
- // Precompute a line index for fast index->line/col conversions.
1048
1442
  const lineIndex = buildLineIndex(code)
1049
-
1050
- // Key by the full normalized module ID including query params
1051
- // (e.g. "src/routes/index.tsx?tsr-split=component") so that
1052
- // virtual modules derived from the same physical file each get
1053
- // their own cache entry.
1054
1443
  const cacheKey = normalizePath(id)
1444
+
1055
1445
  const envState = getEnv(envName)
1446
+
1447
+ // Propagate SERVER_FN_LOOKUP status before import-analysis
1448
+ if (id.includes(SERVER_FN_LOOKUP_QUERY)) {
1449
+ envState.serverFnLookupModules.add(file)
1450
+ }
1451
+
1056
1452
  envState.transformResultCache.set(cacheKey, {
1057
1453
  code,
1058
1454
  map,
@@ -1060,19 +1456,14 @@ export function importProtectionPlugin(
1060
1456
  lineIndex,
1061
1457
  })
1062
1458
 
1063
- // Maintain reverse index so hotUpdate invalidation is O(keys for file).
1064
- let keySet = envState.transformResultKeysByFile.get(file)
1065
- if (!keySet) {
1066
- keySet = new Set<string>()
1067
- envState.transformResultKeysByFile.set(file, keySet)
1068
- }
1459
+ const keySet = getOrCreate(
1460
+ envState.transformResultKeysByFile,
1461
+ file,
1462
+ () => new Set<string>(),
1463
+ )
1069
1464
  keySet.add(cacheKey)
1070
1465
 
1071
- // Also store/update the stripped-path entry so that lookups by
1072
- // physical file path (e.g. from trace steps in the import graph,
1073
- // which normalize away query params) still find a result.
1074
- // The last variant transformed wins, which is acceptable — trace
1075
- // lookups are best-effort for line numbers.
1466
+ // Also store stripped-path entry for physical-file lookups.
1076
1467
  if (cacheKey !== file) {
1077
1468
  envState.transformResultCache.set(file, {
1078
1469
  code,
@@ -1083,7 +1474,29 @@ export function importProtectionPlugin(
1083
1474
  keySet.add(file)
1084
1475
  }
1085
1476
 
1086
- // Return nothing we don't modify the code.
1477
+ // Resolve import sources to canonical paths for reachability checks.
1478
+ const importSources = extractImportSources(code)
1479
+ const resolvedChildren = new Set<string>()
1480
+ for (const src of importSources) {
1481
+ try {
1482
+ const resolved = await this.resolve(src, id, { skipSelf: true })
1483
+ if (resolved && !resolved.external) {
1484
+ const resolvedPath = normalizeFilePath(resolved.id)
1485
+ resolvedChildren.add(resolvedPath)
1486
+ // Populate import graph edges for warm-start trace accuracy
1487
+ envState.graph.addEdge(resolvedPath, file, src)
1488
+ }
1489
+ } catch {
1490
+ // Non-fatal
1491
+ }
1492
+ }
1493
+ envState.postTransformImports.set(cacheKey, resolvedChildren)
1494
+ if (cacheKey !== file) {
1495
+ envState.postTransformImports.set(file, resolvedChildren)
1496
+ }
1497
+
1498
+ await processPendingViolations(envState, this.warn.bind(this))
1499
+
1087
1500
  return undefined
1088
1501
  },
1089
1502
  },
@@ -1115,7 +1528,6 @@ export function importProtectionPlugin(
1115
1528
  },
1116
1529
  },
1117
1530
  handler(code, id) {
1118
- if (!config.enabled) return undefined
1119
1531
  const envName = this.environment.name
1120
1532
  const envState = envStates.get(envName)
1121
1533
  if (!envState) return undefined
@@ -1141,73 +1553,4 @@ export function importProtectionPlugin(
1141
1553
  },
1142
1554
  },
1143
1555
  ] satisfies Array<PluginOption>
1144
-
1145
- // ---------------------------------------------------------------------------
1146
- // Violation handling
1147
- // ---------------------------------------------------------------------------
1148
-
1149
- function handleViolation(
1150
- this: { warn: (msg: string) => void; error: (msg: string) => never },
1151
- env: EnvState,
1152
- info: ViolationInfo,
1153
- opts?: { silent?: boolean },
1154
- ): { id: string; syntheticNamedExports: boolean } | string | undefined {
1155
- const key = dedupeKey(
1156
- info.type,
1157
- info.importer,
1158
- info.specifier,
1159
- info.resolved,
1160
- )
1161
-
1162
- if (!opts?.silent) {
1163
- // Call user callback
1164
- if (config.onViolation) {
1165
- const result = config.onViolation(info)
1166
- if (result === false) {
1167
- return undefined
1168
- }
1169
- }
1170
-
1171
- const seen = hasSeen(env, key)
1172
-
1173
- if (config.effectiveBehavior === 'error') {
1174
- if (!seen) this.error(formatViolation(info, config.root))
1175
- return undefined
1176
- }
1177
-
1178
- // Mock mode: log once, but always return the mock module.
1179
- if (!seen) {
1180
- this.warn(formatViolation(info, config.root))
1181
- }
1182
- } else {
1183
- // Silent mode: in error behavior, skip entirely (no mock needed
1184
- // for compiler-internal lookups); in mock mode, fall through to
1185
- // return the mock module ID without logging.
1186
- if (config.effectiveBehavior === 'error') {
1187
- return undefined
1188
- }
1189
- }
1190
-
1191
- env.deniedSources.add(info.specifier)
1192
- let edgeSet = env.deniedEdges.get(info.importer)
1193
- if (!edgeSet) {
1194
- edgeSet = new Set<string>()
1195
- env.deniedEdges.set(info.importer, edgeSet)
1196
- }
1197
- edgeSet.add(info.specifier)
1198
-
1199
- if (config.command === 'serve') {
1200
- const runtimeId = mockRuntimeModuleIdFromViolation(
1201
- info,
1202
- config.mockAccess,
1203
- config.root,
1204
- )
1205
- return resolveViteId(
1206
- buildMockEdgeModuleId(env, info.importer, info.specifier, runtimeId),
1207
- )
1208
- }
1209
-
1210
- // Build: Rollup can synthesize named exports.
1211
- return { id: RESOLVED_MOCK_MODULE_ID, syntheticNamedExports: true }
1212
- }
1213
1556
  }