@tanstack/start-plugin-core 1.160.1 → 1.161.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 (58) hide show
  1. package/dist/esm/dev-server-plugin/plugin.js +1 -1
  2. package/dist/esm/dev-server-plugin/plugin.js.map +1 -1
  3. package/dist/esm/import-protection-plugin/defaults.d.ts +17 -0
  4. package/dist/esm/import-protection-plugin/defaults.js +36 -0
  5. package/dist/esm/import-protection-plugin/defaults.js.map +1 -0
  6. package/dist/esm/import-protection-plugin/matchers.d.ts +13 -0
  7. package/dist/esm/import-protection-plugin/matchers.js +31 -0
  8. package/dist/esm/import-protection-plugin/matchers.js.map +1 -0
  9. package/dist/esm/import-protection-plugin/plugin.d.ts +16 -0
  10. package/dist/esm/import-protection-plugin/plugin.js +699 -0
  11. package/dist/esm/import-protection-plugin/plugin.js.map +1 -0
  12. package/dist/esm/import-protection-plugin/postCompileUsage.d.ts +11 -0
  13. package/dist/esm/import-protection-plugin/postCompileUsage.js +177 -0
  14. package/dist/esm/import-protection-plugin/postCompileUsage.js.map +1 -0
  15. package/dist/esm/import-protection-plugin/rewriteDeniedImports.d.ts +27 -0
  16. package/dist/esm/import-protection-plugin/rewriteDeniedImports.js +51 -0
  17. package/dist/esm/import-protection-plugin/rewriteDeniedImports.js.map +1 -0
  18. package/dist/esm/import-protection-plugin/sourceLocation.d.ts +132 -0
  19. package/dist/esm/import-protection-plugin/sourceLocation.js +255 -0
  20. package/dist/esm/import-protection-plugin/sourceLocation.js.map +1 -0
  21. package/dist/esm/import-protection-plugin/trace.d.ts +67 -0
  22. package/dist/esm/import-protection-plugin/trace.js +204 -0
  23. package/dist/esm/import-protection-plugin/trace.js.map +1 -0
  24. package/dist/esm/import-protection-plugin/utils.d.ts +8 -0
  25. package/dist/esm/import-protection-plugin/utils.js +29 -0
  26. package/dist/esm/import-protection-plugin/utils.js.map +1 -0
  27. package/dist/esm/import-protection-plugin/virtualModules.d.ts +25 -0
  28. package/dist/esm/import-protection-plugin/virtualModules.js +235 -0
  29. package/dist/esm/import-protection-plugin/virtualModules.js.map +1 -0
  30. package/dist/esm/plugin.js +7 -0
  31. package/dist/esm/plugin.js.map +1 -1
  32. package/dist/esm/prerender.js +3 -3
  33. package/dist/esm/prerender.js.map +1 -1
  34. package/dist/esm/schema.d.ts +260 -0
  35. package/dist/esm/schema.js +35 -1
  36. package/dist/esm/schema.js.map +1 -1
  37. package/dist/esm/start-compiler-plugin/compiler.js +5 -1
  38. package/dist/esm/start-compiler-plugin/compiler.js.map +1 -1
  39. package/dist/esm/start-compiler-plugin/handleCreateServerFn.js +2 -2
  40. package/dist/esm/start-compiler-plugin/handleCreateServerFn.js.map +1 -1
  41. package/dist/esm/start-compiler-plugin/plugin.js.map +1 -1
  42. package/dist/esm/start-router-plugin/plugin.js +5 -5
  43. package/dist/esm/start-router-plugin/plugin.js.map +1 -1
  44. package/package.json +6 -3
  45. package/src/dev-server-plugin/plugin.ts +1 -1
  46. package/src/import-protection-plugin/defaults.ts +56 -0
  47. package/src/import-protection-plugin/matchers.ts +48 -0
  48. package/src/import-protection-plugin/plugin.ts +1173 -0
  49. package/src/import-protection-plugin/postCompileUsage.ts +266 -0
  50. package/src/import-protection-plugin/rewriteDeniedImports.ts +255 -0
  51. package/src/import-protection-plugin/sourceLocation.ts +524 -0
  52. package/src/import-protection-plugin/trace.ts +296 -0
  53. package/src/import-protection-plugin/utils.ts +32 -0
  54. package/src/import-protection-plugin/virtualModules.ts +300 -0
  55. package/src/plugin.ts +7 -0
  56. package/src/schema.ts +58 -0
  57. package/src/start-compiler-plugin/compiler.ts +12 -1
  58. package/src/start-compiler-plugin/plugin.ts +3 -3
@@ -0,0 +1,1173 @@
1
+ import * as path from 'pathe'
2
+ import { normalizePath } from 'vite'
3
+
4
+ import { resolveViteId } from '../utils'
5
+ import { VITE_ENVIRONMENT_NAMES } from '../constants'
6
+ import { ImportGraph, buildTrace, formatViolation } from './trace'
7
+ import {
8
+ getDefaultImportProtectionRules,
9
+ getMarkerSpecifiers,
10
+ } from './defaults'
11
+ import { findPostCompileUsagePos } from './postCompileUsage'
12
+ import { compileMatchers, matchesAny } from './matchers'
13
+ import { dedupePatterns, normalizeFilePath } from './utils'
14
+ import { collectMockExportNamesBySource } from './rewriteDeniedImports'
15
+ import {
16
+ MARKER_PREFIX,
17
+ MOCK_EDGE_PREFIX,
18
+ MOCK_MODULE_ID,
19
+ MOCK_RUNTIME_PREFIX,
20
+ RESOLVED_MARKER_PREFIX,
21
+ RESOLVED_MOCK_EDGE_PREFIX,
22
+ RESOLVED_MOCK_MODULE_ID,
23
+ RESOLVED_MOCK_RUNTIME_PREFIX,
24
+ loadMarkerModule,
25
+ loadMockEdgeModule,
26
+ loadMockRuntimeModule,
27
+ loadSilentMockModule,
28
+ makeMockEdgeModuleId,
29
+ mockRuntimeModuleIdFromViolation,
30
+ } from './virtualModules'
31
+ import {
32
+ addTraceImportLocations,
33
+ buildCodeSnippet,
34
+ buildLineIndex,
35
+ findImportStatementLocationFromTransformed,
36
+ findPostCompileUsageLocation,
37
+ pickOriginalCodeFromSourcesContent,
38
+ } from './sourceLocation'
39
+ import type { PluginOption } from 'vite'
40
+ import type { CompiledMatcher } from './matchers'
41
+ import type { ViolationInfo } from './trace'
42
+ import type {
43
+ SourceMapLike,
44
+ TransformResult,
45
+ TransformResultProvider,
46
+ } from './sourceLocation'
47
+ import type {
48
+ ImportProtectionBehavior,
49
+ ImportProtectionOptions,
50
+ } from '../schema'
51
+ import type { CompileStartFrameworkOptions, GetConfigFn } from '../types'
52
+
53
+ // Re-export public API that tests and other consumers depend on.
54
+ export { RESOLVED_MOCK_MODULE_ID } from './virtualModules'
55
+ export { rewriteDeniedImports } from './rewriteDeniedImports'
56
+ export { dedupePatterns } from './utils'
57
+ export type { Pattern } from './utils'
58
+
59
+ /**
60
+ * Immutable plugin configuration — set once in `configResolved`, never mutated
61
+ * per-env or per-request afterward.
62
+ */
63
+ interface PluginConfig {
64
+ enabled: boolean
65
+ root: string
66
+ command: 'build' | 'serve'
67
+ srcDirectory: string
68
+ framework: CompileStartFrameworkOptions
69
+
70
+ effectiveBehavior: ImportProtectionBehavior
71
+ mockAccess: 'error' | 'warn' | 'off'
72
+ logMode: 'once' | 'always'
73
+ maxTraceDepth: number
74
+
75
+ compiledRules: {
76
+ client: {
77
+ specifiers: Array<CompiledMatcher>
78
+ files: Array<CompiledMatcher>
79
+ }
80
+ server: {
81
+ specifiers: Array<CompiledMatcher>
82
+ files: Array<CompiledMatcher>
83
+ }
84
+ }
85
+ includeMatchers: Array<CompiledMatcher>
86
+ excludeMatchers: Array<CompiledMatcher>
87
+ ignoreImporterMatchers: Array<CompiledMatcher>
88
+
89
+ markerSpecifiers: { serverOnly: Set<string>; clientOnly: Set<string> }
90
+ envTypeMap: Map<string, 'client' | 'server'>
91
+
92
+ onViolation?: (info: ViolationInfo) => boolean | void
93
+ }
94
+
95
+ /**
96
+ * Per-Vite-environment mutable state. One instance per environment name,
97
+ * stored in `envStates: Map<string, EnvState>`.
98
+ *
99
+ * All caches that previously lived on `PluginState` with `${envName}:` key
100
+ * prefixes now live here without any prefix.
101
+ */
102
+ interface EnvState {
103
+ graph: ImportGraph
104
+ /** Specifiers that resolved to the mock module (for transform-time rewriting). */
105
+ deniedSources: Set<string>
106
+ /** Per-importer denied edges (for dev ESM mock modules). */
107
+ deniedEdges: Map<string, Set<string>>
108
+ /**
109
+ * During `vite dev` in mock mode, we generate a per-importer mock module that
110
+ * exports the names the importer expects.
111
+ * Populated in the transform hook (no disk reads).
112
+ */
113
+ mockExportsByImporter: Map<string, Map<string, Array<string>>>
114
+
115
+ /** Resolve cache. Key: `${normalizedImporter}:${source}` (no env prefix). */
116
+ resolveCache: Map<string, string | null>
117
+ /** Reverse index: file path → Set of resolveCache keys involving that file. */
118
+ resolveCacheByFile: Map<string, Set<string>>
119
+
120
+ /** Import location cache. Key: `${importerFile}::${source}`. */
121
+ importLocCache: Map<
122
+ string,
123
+ { file?: string; line: number; column: number } | null
124
+ >
125
+ /** Reverse index: file path → Set of importLocCache keys for that file. */
126
+ importLocByFile: Map<string, Set<string>>
127
+
128
+ /** Deduplication of logged violations (no env prefix in key). */
129
+ seenViolations: Set<string>
130
+
131
+ /** Transform result cache (code + composed sourcemap + original source). */
132
+ transformResultCache: Map<string, TransformResult>
133
+ /** Reverse index: physical file path → Set of transformResultCache keys. */
134
+ transformResultKeysByFile: Map<string, Set<string>>
135
+ }
136
+
137
+ /**
138
+ * Intentionally cross-env shared mutable state.
139
+ *
140
+ * A file's `'use server'`/`'use client'` directive is inherent to the file
141
+ * content, not the environment that happens to discover it first.
142
+ */
143
+ interface SharedState {
144
+ fileMarkerKind: Map<string, 'server' | 'client'>
145
+ }
146
+
147
+ export interface ImportProtectionPluginOptions {
148
+ getConfig: GetConfigFn
149
+ framework: CompileStartFrameworkOptions
150
+ environments: Array<{ name: string; type: 'client' | 'server' }>
151
+ providerEnvName: string
152
+ }
153
+
154
+ export function importProtectionPlugin(
155
+ opts: ImportProtectionPluginOptions,
156
+ ): PluginOption {
157
+ const config: PluginConfig = {
158
+ enabled: true,
159
+ root: '',
160
+ command: 'build',
161
+ srcDirectory: '',
162
+ framework: opts.framework,
163
+ effectiveBehavior: 'error',
164
+ mockAccess: 'error',
165
+ logMode: 'once',
166
+ maxTraceDepth: 20,
167
+ compiledRules: {
168
+ client: { specifiers: [], files: [] },
169
+ server: { specifiers: [], files: [] },
170
+ },
171
+ includeMatchers: [],
172
+ excludeMatchers: [],
173
+ ignoreImporterMatchers: [],
174
+ markerSpecifiers: { serverOnly: new Set(), clientOnly: new Set() },
175
+ envTypeMap: new Map(opts.environments.map((e) => [e.name, e.type])),
176
+ onViolation: undefined,
177
+ }
178
+
179
+ const envStates = new Map<string, EnvState>()
180
+ const shared: SharedState = { fileMarkerKind: new Map() }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // Internal helpers
184
+ // ---------------------------------------------------------------------------
185
+
186
+ /**
187
+ * Create a per-env `importLocCache` whose `.set` method automatically
188
+ * maintains the reverse index (`importLocByFile`) for O(1) invalidation
189
+ * in `hotUpdate`.
190
+ *
191
+ * Cache keys have the format `${importerFile}::${source}`.
192
+ */
193
+ function createImportLocCache(
194
+ env: EnvState,
195
+ ): Map<string, { file?: string; line: number; column: number } | null> {
196
+ const cache = new Map<
197
+ string,
198
+ { file?: string; line: number; column: number } | null
199
+ >()
200
+ const originalSet = cache.set.bind(cache)
201
+ cache.set = function (key, value) {
202
+ originalSet(key, value)
203
+ const sepIdx = key.indexOf('::')
204
+ if (sepIdx !== -1) {
205
+ const file = key.slice(0, sepIdx)
206
+ let fileKeys = env.importLocByFile.get(file)
207
+ if (!fileKeys) {
208
+ fileKeys = new Set()
209
+ env.importLocByFile.set(file, fileKeys)
210
+ }
211
+ fileKeys.add(key)
212
+ }
213
+ return this
214
+ }
215
+ return cache
216
+ }
217
+
218
+ function getMockEdgeExports(
219
+ env: EnvState,
220
+ importerId: string,
221
+ source: string,
222
+ ): Array<string> {
223
+ const importerFile = normalizeFilePath(importerId)
224
+ return env.mockExportsByImporter.get(importerFile)?.get(source) ?? []
225
+ }
226
+
227
+ function getMarkerKindForFile(
228
+ fileId: string,
229
+ ): 'server' | 'client' | undefined {
230
+ const file = normalizeFilePath(fileId)
231
+ return shared.fileMarkerKind.get(file)
232
+ }
233
+
234
+ /**
235
+ * Build a {@link TransformResultProvider} for the given environment.
236
+ *
237
+ * The provider reads from the transform result cache that is populated by
238
+ * the `tanstack-start-core:import-protection-transform-cache` plugin's
239
+ * transform hook.
240
+ */
241
+ function getTransformResultProvider(env: EnvState): TransformResultProvider {
242
+ return {
243
+ getTransformResult(id: string) {
244
+ // Try the full normalized ID first (preserves query params like
245
+ // ?tsr-split=component for virtual modules).
246
+ const fullKey = normalizePath(id)
247
+ const exact = env.transformResultCache.get(fullKey)
248
+ if (exact) return exact
249
+
250
+ // Fall back to the query-stripped path for modules looked up by
251
+ // their physical file path (e.g. trace steps, modules without
252
+ // query params).
253
+ const strippedKey = normalizeFilePath(id)
254
+ return strippedKey !== fullKey
255
+ ? env.transformResultCache.get(strippedKey)
256
+ : undefined
257
+ },
258
+ }
259
+ }
260
+
261
+ type ViolationReporter = {
262
+ warn: (msg: string) => void
263
+ error: (msg: string) => never
264
+ }
265
+
266
+ /**
267
+ * Build a complete {@link ViolationInfo} with trace, location, and snippet.
268
+ *
269
+ * This is the single path that all violation types go through: specifier,
270
+ * file, and marker. Centralizing it eliminates the duplicated sequences of
271
+ * `buildTrace` → `addTraceImportLocations` → location lookup → annotate →
272
+ * snippet that previously appeared 5 times in the codebase.
273
+ */
274
+ async function buildViolationInfo(
275
+ provider: TransformResultProvider,
276
+ env: EnvState,
277
+ envName: string,
278
+ envType: 'client' | 'server',
279
+ importer: string,
280
+ normalizedImporter: string,
281
+ source: string,
282
+ overrides: Omit<
283
+ ViolationInfo,
284
+ | 'env'
285
+ | 'envType'
286
+ | 'behavior'
287
+ | 'specifier'
288
+ | 'importer'
289
+ | 'trace'
290
+ | 'snippet'
291
+ | 'importerLoc'
292
+ >,
293
+ ): Promise<ViolationInfo> {
294
+ const trace = buildTrace(
295
+ env.graph,
296
+ normalizedImporter,
297
+ config.maxTraceDepth,
298
+ )
299
+ await addTraceImportLocations(provider, trace, env.importLocCache)
300
+
301
+ const loc =
302
+ (await findPostCompileUsageLocation(
303
+ provider,
304
+ importer,
305
+ source,
306
+ findPostCompileUsagePos,
307
+ )) ||
308
+ (await findImportStatementLocationFromTransformed(
309
+ provider,
310
+ importer,
311
+ source,
312
+ env.importLocCache,
313
+ ))
314
+
315
+ // Annotate the last trace step with the denied import's specifier and
316
+ // location so every trace step (including the leaf) gets file:line:col.
317
+ if (trace.length > 0) {
318
+ const last = trace[trace.length - 1]!
319
+ if (!last.specifier) last.specifier = source
320
+ if (loc && last.line == null) {
321
+ last.line = loc.line
322
+ last.column = loc.column
323
+ }
324
+ }
325
+
326
+ const snippet = loc ? buildCodeSnippet(provider, importer, loc) : undefined
327
+
328
+ return {
329
+ env: envName,
330
+ envType,
331
+ behavior: config.effectiveBehavior,
332
+ specifier: source,
333
+ importer: normalizedImporter,
334
+ ...(loc ? { importerLoc: loc } : {}),
335
+ trace,
336
+ snippet,
337
+ ...overrides,
338
+ }
339
+ }
340
+
341
+ async function maybeReportMarkerViolationFromResolvedImport(
342
+ ctx: ViolationReporter,
343
+ provider: TransformResultProvider,
344
+ env: EnvState,
345
+ envName: string,
346
+ envType: 'client' | 'server',
347
+ importer: string,
348
+ source: string,
349
+ resolvedId: string,
350
+ relativePath: string,
351
+ ): Promise<ReturnType<typeof handleViolation> | undefined> {
352
+ const markerKind = getMarkerKindForFile(resolvedId)
353
+ const violates =
354
+ (envType === 'client' && markerKind === 'server') ||
355
+ (envType === 'server' && markerKind === 'client')
356
+ if (!violates) return undefined
357
+
358
+ const normalizedImporter = normalizeFilePath(importer)
359
+
360
+ const info = await buildViolationInfo(
361
+ provider,
362
+ env,
363
+ envName,
364
+ envType,
365
+ importer,
366
+ normalizedImporter,
367
+ source,
368
+ {
369
+ type: 'marker',
370
+ resolved: normalizeFilePath(resolvedId),
371
+ message:
372
+ markerKind === 'server'
373
+ ? `Module "${relativePath}" is marked server-only but is imported in the client environment`
374
+ : `Module "${relativePath}" is marked client-only but is imported in the server environment`,
375
+ },
376
+ )
377
+
378
+ return handleViolation.call(ctx, env, info)
379
+ }
380
+
381
+ function buildMockEdgeModuleId(
382
+ env: EnvState,
383
+ importerId: string,
384
+ source: string,
385
+ runtimeId: string,
386
+ ): string {
387
+ const exports = getMockEdgeExports(env, importerId, source)
388
+ return makeMockEdgeModuleId(exports, source, runtimeId)
389
+ }
390
+
391
+ function getEnvType(envName: string): 'client' | 'server' {
392
+ return config.envTypeMap.get(envName) ?? 'server'
393
+ }
394
+
395
+ function getRulesForEnvironment(envName: string): {
396
+ specifiers: Array<CompiledMatcher>
397
+ files: Array<CompiledMatcher>
398
+ } {
399
+ const type = getEnvType(envName)
400
+ return type === 'client'
401
+ ? config.compiledRules.client
402
+ : config.compiledRules.server
403
+ }
404
+
405
+ const environmentNames = new Set<string>([
406
+ VITE_ENVIRONMENT_NAMES.client,
407
+ VITE_ENVIRONMENT_NAMES.server,
408
+ ])
409
+ if (opts.providerEnvName !== VITE_ENVIRONMENT_NAMES.server) {
410
+ environmentNames.add(opts.providerEnvName)
411
+ }
412
+
413
+ /** Get (or lazily create) the per-env state for the given environment name. */
414
+ function getEnv(envName: string): EnvState {
415
+ let envState = envStates.get(envName)
416
+ if (!envState) {
417
+ const importLocByFile = new Map<string, Set<string>>()
418
+ envState = {
419
+ graph: new ImportGraph(),
420
+ deniedSources: new Set(),
421
+ deniedEdges: new Map(),
422
+ mockExportsByImporter: new Map(),
423
+ resolveCache: new Map(),
424
+ resolveCacheByFile: new Map(),
425
+ importLocCache: new Map(), // placeholder, replaced below
426
+ importLocByFile,
427
+ seenViolations: new Set(),
428
+ transformResultCache: new Map(),
429
+ transformResultKeysByFile: new Map(),
430
+ }
431
+ // Install reverse-index-maintaining importLocCache
432
+ envState.importLocCache = createImportLocCache(envState)
433
+ envStates.set(envName, envState)
434
+ }
435
+ return envState
436
+ }
437
+
438
+ function shouldCheckImporter(importer: string): boolean {
439
+ // Normalize for matching
440
+ const relativePath = path.relative(config.root, importer)
441
+
442
+ // Check exclude first
443
+ if (
444
+ config.excludeMatchers.length > 0 &&
445
+ matchesAny(relativePath, config.excludeMatchers)
446
+ ) {
447
+ return false
448
+ }
449
+
450
+ // Check ignore importers
451
+ if (
452
+ config.ignoreImporterMatchers.length > 0 &&
453
+ matchesAny(relativePath, config.ignoreImporterMatchers)
454
+ ) {
455
+ return false
456
+ }
457
+
458
+ // Check include
459
+ if (config.includeMatchers.length > 0) {
460
+ return !!matchesAny(relativePath, config.includeMatchers)
461
+ }
462
+
463
+ // Default: check if within srcDirectory
464
+ if (config.srcDirectory) {
465
+ return importer.startsWith(config.srcDirectory)
466
+ }
467
+
468
+ return true
469
+ }
470
+
471
+ function dedupeKey(
472
+ type: string,
473
+ importer: string,
474
+ specifier: string,
475
+ resolved?: string,
476
+ ): string {
477
+ return `${type}:${importer}:${specifier}:${resolved ?? ''}`
478
+ }
479
+
480
+ function hasSeen(env: EnvState, key: string): boolean {
481
+ if (config.logMode === 'always') return false
482
+ if (env.seenViolations.has(key)) return true
483
+ env.seenViolations.add(key)
484
+ return false
485
+ }
486
+
487
+ function getRelativePath(absolutePath: string): string {
488
+ return normalizePath(path.relative(config.root, absolutePath))
489
+ }
490
+
491
+ // ---------------------------------------------------------------------------
492
+ // Vite plugins
493
+ // ---------------------------------------------------------------------------
494
+
495
+ return [
496
+ {
497
+ name: 'tanstack-start-core:import-protection',
498
+ enforce: 'pre',
499
+
500
+ applyToEnvironment(env) {
501
+ if (!config.enabled) return false
502
+ // Start's environments are named `client` and `ssr` (not `server`), plus
503
+ // an optional serverFn provider environment (eg `rsc`) when configured.
504
+ return environmentNames.has(env.name)
505
+ },
506
+
507
+ configResolved(viteConfig) {
508
+ config.root = viteConfig.root
509
+ config.command = viteConfig.command
510
+
511
+ const { startConfig, resolvedStartConfig } = opts.getConfig()
512
+ config.srcDirectory = resolvedStartConfig.srcDirectory
513
+
514
+ const userOpts: ImportProtectionOptions | undefined =
515
+ startConfig.importProtection
516
+
517
+ // Determine if plugin is enabled
518
+ if (userOpts?.enabled === false) {
519
+ config.enabled = false
520
+ return
521
+ }
522
+
523
+ config.enabled = true
524
+
525
+ // Determine effective behavior
526
+ if (userOpts?.behavior) {
527
+ if (typeof userOpts.behavior === 'string') {
528
+ config.effectiveBehavior = userOpts.behavior
529
+ } else {
530
+ config.effectiveBehavior =
531
+ viteConfig.command === 'serve'
532
+ ? (userOpts.behavior.dev ?? 'mock')
533
+ : (userOpts.behavior.build ?? 'error')
534
+ }
535
+ } else {
536
+ // Defaults: dev='mock', build='error'
537
+ config.effectiveBehavior =
538
+ viteConfig.command === 'serve' ? 'mock' : 'error'
539
+ }
540
+
541
+ // Log mode
542
+ config.logMode = userOpts?.log ?? 'once'
543
+
544
+ // Mock runtime access diagnostics
545
+ config.mockAccess = userOpts?.mockAccess ?? 'error'
546
+
547
+ // Max trace depth
548
+ config.maxTraceDepth = userOpts?.maxTraceDepth ?? 20
549
+
550
+ // User callback
551
+ config.onViolation = userOpts?.onViolation as
552
+ | ((info: ViolationInfo) => boolean | void)
553
+ | undefined
554
+
555
+ // Get default rules
556
+ const defaults = getDefaultImportProtectionRules(opts.framework)
557
+
558
+ // Merge user rules with defaults and compile matchers per env.
559
+ // IMPORTANT: client specifier denies for Start server entrypoints must
560
+ // always include the framework defaults even when the user provides a
561
+ // custom list.
562
+ const clientSpecifiers = dedupePatterns([
563
+ ...defaults.client.specifiers,
564
+ ...(userOpts?.client?.specifiers ?? []),
565
+ ])
566
+
567
+ // For file patterns, user config overrides defaults.
568
+ const clientFiles = userOpts?.client?.files
569
+ ? [...userOpts.client.files]
570
+ : [...defaults.client.files]
571
+ const serverSpecifiers = userOpts?.server?.specifiers
572
+ ? dedupePatterns([...userOpts.server.specifiers])
573
+ : dedupePatterns([...defaults.server.specifiers])
574
+ const serverFiles = userOpts?.server?.files
575
+ ? [...userOpts.server.files]
576
+ : [...defaults.server.files]
577
+
578
+ config.compiledRules.client = {
579
+ specifiers: compileMatchers(clientSpecifiers),
580
+ files: compileMatchers(clientFiles),
581
+ }
582
+ config.compiledRules.server = {
583
+ specifiers: compileMatchers(serverSpecifiers),
584
+ files: compileMatchers(serverFiles),
585
+ }
586
+
587
+ // Include/exclude
588
+ if (userOpts?.include) {
589
+ config.includeMatchers = compileMatchers(userOpts.include)
590
+ }
591
+ if (userOpts?.exclude) {
592
+ config.excludeMatchers = compileMatchers(userOpts.exclude)
593
+ }
594
+ if (userOpts?.ignoreImporters) {
595
+ config.ignoreImporterMatchers = compileMatchers(
596
+ userOpts.ignoreImporters,
597
+ )
598
+ }
599
+
600
+ // Marker specifiers
601
+ const markers = getMarkerSpecifiers(opts.framework)
602
+ config.markerSpecifiers = {
603
+ serverOnly: new Set(markers.serverOnly),
604
+ clientOnly: new Set(markers.clientOnly),
605
+ }
606
+
607
+ // Use known Start env entrypoints as trace roots.
608
+ // This makes traces deterministic and prevents 1-line traces.
609
+ for (const envDef of opts.environments) {
610
+ const envState = getEnv(envDef.name)
611
+
612
+ if (resolvedStartConfig.routerFilePath) {
613
+ envState.graph.addEntry(
614
+ normalizePath(resolvedStartConfig.routerFilePath),
615
+ )
616
+ }
617
+ if (resolvedStartConfig.startFilePath) {
618
+ envState.graph.addEntry(
619
+ normalizePath(resolvedStartConfig.startFilePath),
620
+ )
621
+ }
622
+ }
623
+ },
624
+
625
+ buildStart() {
626
+ if (!config.enabled) return
627
+ // Clear per-env caches
628
+ for (const envState of envStates.values()) {
629
+ envState.resolveCache.clear()
630
+ envState.resolveCacheByFile.clear()
631
+ envState.importLocCache.clear()
632
+ envState.importLocByFile.clear()
633
+ envState.seenViolations.clear()
634
+ envState.transformResultCache.clear()
635
+ envState.transformResultKeysByFile.clear()
636
+ envState.graph.clear()
637
+ envState.deniedSources.clear()
638
+ envState.deniedEdges.clear()
639
+ envState.mockExportsByImporter.clear()
640
+ }
641
+
642
+ // Clear shared state
643
+ shared.fileMarkerKind.clear()
644
+
645
+ // Re-add known entries after clearing.
646
+ for (const envDef of opts.environments) {
647
+ const envState = getEnv(envDef.name)
648
+ const { resolvedStartConfig } = opts.getConfig()
649
+ if (resolvedStartConfig.routerFilePath) {
650
+ envState.graph.addEntry(
651
+ normalizePath(resolvedStartConfig.routerFilePath),
652
+ )
653
+ }
654
+ if (resolvedStartConfig.startFilePath) {
655
+ envState.graph.addEntry(
656
+ normalizePath(resolvedStartConfig.startFilePath),
657
+ )
658
+ }
659
+ }
660
+ },
661
+
662
+ hotUpdate(ctx) {
663
+ if (!config.enabled) return
664
+ // Invalidate caches for updated files
665
+ for (const mod of ctx.modules) {
666
+ if (mod.id) {
667
+ const id = mod.id
668
+ const importerFile = normalizeFilePath(id)
669
+ shared.fileMarkerKind.delete(importerFile)
670
+
671
+ // Invalidate per-env caches
672
+ for (const envState of envStates.values()) {
673
+ // Invalidate cached import locations using reverse index
674
+ const locKeys = envState.importLocByFile.get(importerFile)
675
+ if (locKeys) {
676
+ for (const key of locKeys) {
677
+ envState.importLocCache.delete(key)
678
+ }
679
+ envState.importLocByFile.delete(importerFile)
680
+ }
681
+
682
+ // Invalidate resolve cache using reverse index
683
+ const resolveKeys = envState.resolveCacheByFile.get(importerFile)
684
+ if (resolveKeys) {
685
+ for (const key of resolveKeys) {
686
+ envState.resolveCache.delete(key)
687
+ }
688
+ envState.resolveCacheByFile.delete(importerFile)
689
+ }
690
+
691
+ // Invalidate graph edges
692
+ envState.graph.invalidate(importerFile)
693
+ envState.deniedEdges.delete(importerFile)
694
+ envState.mockExportsByImporter.delete(importerFile)
695
+
696
+ // Invalidate transform result cache for this file.
697
+ const transformKeys =
698
+ envState.transformResultKeysByFile.get(importerFile)
699
+ if (transformKeys) {
700
+ for (const key of transformKeys) {
701
+ envState.transformResultCache.delete(key)
702
+ }
703
+ envState.transformResultKeysByFile.delete(importerFile)
704
+ } else {
705
+ // Fallback: at least clear the physical-file entry.
706
+ envState.transformResultCache.delete(importerFile)
707
+ }
708
+ }
709
+ }
710
+ }
711
+ },
712
+
713
+ async resolveId(source, importer, _options) {
714
+ if (!config.enabled) return undefined
715
+ const envName = this.environment.name
716
+ const env = getEnv(envName)
717
+ const envType = getEnvType(envName)
718
+ const provider = getTransformResultProvider(env)
719
+
720
+ // Internal virtual modules must resolve in dev.
721
+ if (source === MOCK_MODULE_ID) {
722
+ return RESOLVED_MOCK_MODULE_ID
723
+ }
724
+ if (source.startsWith(MOCK_EDGE_PREFIX)) {
725
+ return resolveViteId(source)
726
+ }
727
+ if (source.startsWith(MOCK_RUNTIME_PREFIX)) {
728
+ return resolveViteId(source)
729
+ }
730
+ if (source.startsWith(MARKER_PREFIX)) {
731
+ return resolveViteId(source)
732
+ }
733
+
734
+ // Skip if no importer (entry points)
735
+ if (!importer) {
736
+ // Track entry-ish modules so traces can terminate.
737
+ // Vite may pass virtual ids here; normalize but keep them.
738
+ env.graph.addEntry(source)
739
+ return undefined
740
+ }
741
+
742
+ // Skip virtual modules
743
+ if (source.startsWith('\0') || source.startsWith('virtual:')) {
744
+ return undefined
745
+ }
746
+
747
+ // Check if this is a marker import
748
+ if (config.markerSpecifiers.serverOnly.has(source)) {
749
+ // Record importer as server-only
750
+ const resolvedImporter = normalizeFilePath(importer)
751
+ const existing = shared.fileMarkerKind.get(resolvedImporter)
752
+ if (existing && existing !== 'server') {
753
+ this.error(
754
+ `[import-protection] File "${getRelativePath(resolvedImporter)}" has both server-only and client-only markers. This is not allowed.`,
755
+ )
756
+ }
757
+ shared.fileMarkerKind.set(resolvedImporter, 'server')
758
+
759
+ // If we're in the client environment, this is a violation
760
+ if (envType === 'client') {
761
+ const info = await buildViolationInfo(
762
+ provider,
763
+ env,
764
+ envName,
765
+ envType,
766
+ importer,
767
+ resolvedImporter,
768
+ source,
769
+ {
770
+ type: 'marker',
771
+ message: `Module "${getRelativePath(resolvedImporter)}" is marked server-only but is imported in the client environment`,
772
+ },
773
+ )
774
+ handleViolation.call(this, env, info)
775
+ }
776
+
777
+ // Return virtual empty module
778
+ return resolveViteId(`${MARKER_PREFIX}server-only`)
779
+ }
780
+
781
+ if (config.markerSpecifiers.clientOnly.has(source)) {
782
+ const resolvedImporter = normalizeFilePath(importer)
783
+ const existing = shared.fileMarkerKind.get(resolvedImporter)
784
+ if (existing && existing !== 'client') {
785
+ this.error(
786
+ `[import-protection] File "${getRelativePath(resolvedImporter)}" has both server-only and client-only markers. This is not allowed.`,
787
+ )
788
+ }
789
+ shared.fileMarkerKind.set(resolvedImporter, 'client')
790
+
791
+ if (envType === 'server') {
792
+ const info = await buildViolationInfo(
793
+ provider,
794
+ env,
795
+ envName,
796
+ envType,
797
+ importer,
798
+ resolvedImporter,
799
+ source,
800
+ {
801
+ type: 'marker',
802
+ message: `Module "${getRelativePath(resolvedImporter)}" is marked client-only but is imported in the server environment`,
803
+ },
804
+ )
805
+ handleViolation.call(this, env, info)
806
+ }
807
+
808
+ return resolveViteId(`${MARKER_PREFIX}client-only`)
809
+ }
810
+
811
+ // Check if the importer is within our scope
812
+ const normalizedImporter = normalizeFilePath(importer)
813
+ if (!shouldCheckImporter(normalizedImporter)) {
814
+ return undefined
815
+ }
816
+
817
+ const matchers = getRulesForEnvironment(envName)
818
+
819
+ // 1. Specifier-based denial (fast, no resolution needed)
820
+ const specifierMatch = matchesAny(source, matchers.specifiers)
821
+ if (specifierMatch) {
822
+ env.graph.addEdge(source, normalizedImporter, source)
823
+ const info = await buildViolationInfo(
824
+ provider,
825
+ env,
826
+ envName,
827
+ envType,
828
+ importer,
829
+ normalizedImporter,
830
+ source,
831
+ {
832
+ type: 'specifier',
833
+ pattern: specifierMatch.pattern,
834
+ message: `Import "${source}" is denied in the "${envName}" environment`,
835
+ },
836
+ )
837
+ return handleViolation.call(this, env, info)
838
+ }
839
+
840
+ // 2. Resolve the import (cached) — needed for file-based denial,
841
+ // marker checks, and graph edge tracking.
842
+ const cacheKey = `${normalizedImporter}:${source}`
843
+ let resolved: string | null
844
+
845
+ if (env.resolveCache.has(cacheKey)) {
846
+ resolved = env.resolveCache.get(cacheKey) || null
847
+ } else {
848
+ const result = await this.resolve(source, importer, {
849
+ skipSelf: true,
850
+ })
851
+ resolved = result ? normalizeFilePath(result.id) : null
852
+ env.resolveCache.set(cacheKey, resolved)
853
+
854
+ // Maintain reverse index for O(1) hotUpdate invalidation.
855
+ // Index by the importer so that when a file changes, all resolve
856
+ // cache entries where it was the importer are cleared.
857
+ let fileKeys = env.resolveCacheByFile.get(normalizedImporter)
858
+ if (!fileKeys) {
859
+ fileKeys = new Set()
860
+ env.resolveCacheByFile.set(normalizedImporter, fileKeys)
861
+ }
862
+ fileKeys.add(cacheKey)
863
+ }
864
+
865
+ if (resolved) {
866
+ const relativePath = getRelativePath(resolved)
867
+
868
+ // Always record the edge for trace building, even when not denied.
869
+ env.graph.addEdge(resolved, normalizedImporter, source)
870
+
871
+ // File-based denial check
872
+ const fileMatch =
873
+ matchers.files.length > 0
874
+ ? matchesAny(relativePath, matchers.files)
875
+ : undefined
876
+
877
+ if (fileMatch) {
878
+ const info = await buildViolationInfo(
879
+ provider,
880
+ env,
881
+ envName,
882
+ envType,
883
+ importer,
884
+ normalizedImporter,
885
+ source,
886
+ {
887
+ type: 'file',
888
+ pattern: fileMatch.pattern,
889
+ resolved,
890
+ message: `Import "${source}" (resolved to "${relativePath}") is denied in the "${envName}" environment`,
891
+ },
892
+ )
893
+ return handleViolation.call(this, env, info)
894
+ }
895
+
896
+ // Marker restrictions apply regardless of explicit deny rules.
897
+ const markerRes = await maybeReportMarkerViolationFromResolvedImport(
898
+ this,
899
+ provider,
900
+ env,
901
+ envName,
902
+ envType,
903
+ importer,
904
+ source,
905
+ resolved,
906
+ relativePath,
907
+ )
908
+ if (markerRes !== undefined) {
909
+ return markerRes
910
+ }
911
+ }
912
+
913
+ return undefined
914
+ },
915
+
916
+ load: {
917
+ filter: {
918
+ id: new RegExp(
919
+ `(${RESOLVED_MOCK_MODULE_ID.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}|${RESOLVED_MARKER_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}|${RESOLVED_MOCK_EDGE_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}|${RESOLVED_MOCK_RUNTIME_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`,
920
+ ),
921
+ },
922
+ handler(id) {
923
+ if (!config.enabled) return undefined
924
+ if (id === RESOLVED_MOCK_MODULE_ID) {
925
+ return loadSilentMockModule()
926
+ }
927
+
928
+ if (id.startsWith(RESOLVED_MOCK_EDGE_PREFIX)) {
929
+ return loadMockEdgeModule(
930
+ id.slice(RESOLVED_MOCK_EDGE_PREFIX.length),
931
+ )
932
+ }
933
+
934
+ if (id.startsWith(RESOLVED_MOCK_RUNTIME_PREFIX)) {
935
+ return loadMockRuntimeModule(
936
+ id.slice(RESOLVED_MOCK_RUNTIME_PREFIX.length),
937
+ )
938
+ }
939
+
940
+ if (id.startsWith(RESOLVED_MARKER_PREFIX)) {
941
+ return loadMarkerModule()
942
+ }
943
+
944
+ return undefined
945
+ },
946
+ },
947
+ },
948
+ {
949
+ // This plugin runs WITHOUT `enforce` so it executes after all
950
+ // `enforce: 'pre'` transform hooks (including the Start compiler).
951
+ // It captures the transformed code + composed sourcemap for every module
952
+ // so that the `resolveId` hook (in the main plugin above) can look up
953
+ // the importer's transform result and map violation locations back to
954
+ // original source.
955
+ //
956
+ // Why not use `ctx.load()` in `resolveId`?
957
+ // - Vite dev: `this.load()` returns a ModuleInfo proxy that throws on
958
+ // `.code` access — code is not exposed.
959
+ // - Rollup build: `ModuleInfo` has `.code` but NOT `.map`, so we
960
+ // can't map generated positions back to original source.
961
+ //
962
+ // By caching in the transform hook we get both code and the composed
963
+ // sourcemap that chains all the way back to the original file.
964
+ //
965
+ // Performance: only files under `srcDirectory` are cached because only
966
+ // those can be importers in a violation. Third-party code in
967
+ // node_modules is never checked.
968
+ name: 'tanstack-start-core:import-protection-transform-cache',
969
+
970
+ applyToEnvironment(env) {
971
+ if (!config.enabled) return false
972
+ return environmentNames.has(env.name)
973
+ },
974
+
975
+ transform: {
976
+ filter: {
977
+ id: {
978
+ include: [/\.[cm]?[tj]sx?($|\?)/],
979
+ },
980
+ },
981
+ handler(code, id) {
982
+ if (!config.enabled) return undefined
983
+ const envName = this.environment.name
984
+ const file = normalizeFilePath(id)
985
+
986
+ // Only cache files that could ever be checked as an importer.
987
+ // This reuses the same include/exclude/ignoreImporters predicate as
988
+ // the main import-protection resolveId hook.
989
+ if (!shouldCheckImporter(file)) {
990
+ return undefined
991
+ }
992
+
993
+ // getCombinedSourcemap() returns the composed sourcemap of all
994
+ // transform hooks that ran before this one. It includes
995
+ // sourcesContent so we can extract original source later.
996
+ let map: SourceMapLike | undefined
997
+ try {
998
+ map = this.getCombinedSourcemap()
999
+ } catch {
1000
+ // No sourcemap available (e.g. virtual modules or modules
1001
+ // that no prior plugin produced a map for).
1002
+ map = undefined
1003
+ }
1004
+
1005
+ // Extract the original source from sourcesContent right here.
1006
+ // Composed sourcemaps can contain multiple sources; try to pick the
1007
+ // entry that best matches this importer.
1008
+ let originalCode: string | undefined
1009
+ if (map?.sourcesContent) {
1010
+ originalCode = pickOriginalCodeFromSourcesContent(
1011
+ map,
1012
+ file,
1013
+ config.root,
1014
+ )
1015
+ }
1016
+
1017
+ // Precompute a line index for fast index->line/col conversions.
1018
+ const lineIndex = buildLineIndex(code)
1019
+
1020
+ // Key by the full normalized module ID including query params
1021
+ // (e.g. "src/routes/index.tsx?tsr-split=component") so that
1022
+ // virtual modules derived from the same physical file each get
1023
+ // their own cache entry.
1024
+ const cacheKey = normalizePath(id)
1025
+ const envState = getEnv(envName)
1026
+ envState.transformResultCache.set(cacheKey, {
1027
+ code,
1028
+ map,
1029
+ originalCode,
1030
+ lineIndex,
1031
+ })
1032
+
1033
+ // Maintain reverse index so hotUpdate invalidation is O(keys for file).
1034
+ let keySet = envState.transformResultKeysByFile.get(file)
1035
+ if (!keySet) {
1036
+ keySet = new Set<string>()
1037
+ envState.transformResultKeysByFile.set(file, keySet)
1038
+ }
1039
+ keySet.add(cacheKey)
1040
+
1041
+ // Also store/update the stripped-path entry so that lookups by
1042
+ // physical file path (e.g. from trace steps in the import graph,
1043
+ // which normalize away query params) still find a result.
1044
+ // The last variant transformed wins, which is acceptable — trace
1045
+ // lookups are best-effort for line numbers.
1046
+ if (cacheKey !== file) {
1047
+ envState.transformResultCache.set(file, {
1048
+ code,
1049
+ map,
1050
+ originalCode,
1051
+ lineIndex,
1052
+ })
1053
+ keySet.add(file)
1054
+ }
1055
+
1056
+ // Return nothing — we don't modify the code.
1057
+ return undefined
1058
+ },
1059
+ },
1060
+ },
1061
+ {
1062
+ // Separate plugin so the transform can be enabled/disabled per-environment.
1063
+ name: 'tanstack-start-core:import-protection-mock-rewrite',
1064
+ enforce: 'pre',
1065
+
1066
+ // Only needed during dev. In build, we rely on Rollup's syntheticNamedExports.
1067
+ apply: 'serve',
1068
+
1069
+ applyToEnvironment(env) {
1070
+ if (!config.enabled) return false
1071
+ // Only needed in mock mode — when not mocking, there is nothing to
1072
+ // record. applyToEnvironment runs after configResolved, so
1073
+ // config.effectiveBehavior is already set.
1074
+ if (config.effectiveBehavior !== 'mock') return false
1075
+ // We record expected named exports per importer in all Start Vite
1076
+ // environments during dev so mock-edge modules can provide explicit
1077
+ // ESM named exports.
1078
+ return environmentNames.has(env.name)
1079
+ },
1080
+
1081
+ transform: {
1082
+ filter: {
1083
+ id: {
1084
+ include: [/\.[cm]?[tj]sx?($|\?)/],
1085
+ },
1086
+ },
1087
+ handler(code, id) {
1088
+ if (!config.enabled) return undefined
1089
+ const envName = this.environment.name
1090
+ const envState = envStates.get(envName)
1091
+ if (!envState) return undefined
1092
+
1093
+ // Record export names per source for this importer so we can generate
1094
+ // dev mock-edge modules without any disk reads.
1095
+ try {
1096
+ const importerFile = normalizeFilePath(id)
1097
+ envState.mockExportsByImporter.set(
1098
+ importerFile,
1099
+ collectMockExportNamesBySource(code),
1100
+ )
1101
+ } catch {
1102
+ // Best-effort only
1103
+ }
1104
+
1105
+ // Note: we no longer rewrite imports here.
1106
+ // Dev uses per-importer mock-edge modules in resolveId so native ESM
1107
+ // has explicit named exports, and runtime diagnostics are handled by
1108
+ // the mock runtime proxy when those mocks are actually invoked.
1109
+ return undefined
1110
+ },
1111
+ },
1112
+ },
1113
+ ] satisfies Array<PluginOption>
1114
+
1115
+ // ---------------------------------------------------------------------------
1116
+ // Violation handling
1117
+ // ---------------------------------------------------------------------------
1118
+
1119
+ function handleViolation(
1120
+ this: { warn: (msg: string) => void; error: (msg: string) => never },
1121
+ env: EnvState,
1122
+ info: ViolationInfo,
1123
+ ): { id: string; syntheticNamedExports: boolean } | string | undefined {
1124
+ const key = dedupeKey(
1125
+ info.type,
1126
+ info.importer,
1127
+ info.specifier,
1128
+ info.resolved,
1129
+ )
1130
+
1131
+ // Call user callback
1132
+ if (config.onViolation) {
1133
+ const result = config.onViolation(info)
1134
+ if (result === false) {
1135
+ return undefined
1136
+ }
1137
+ }
1138
+
1139
+ const seen = hasSeen(env, key)
1140
+
1141
+ if (config.effectiveBehavior === 'error') {
1142
+ if (!seen) this.error(formatViolation(info, config.root))
1143
+ return undefined
1144
+ }
1145
+
1146
+ // Mock mode: log once, but always return the mock module.
1147
+ if (!seen) {
1148
+ this.warn(formatViolation(info, config.root))
1149
+ }
1150
+
1151
+ env.deniedSources.add(info.specifier)
1152
+ let edgeSet = env.deniedEdges.get(info.importer)
1153
+ if (!edgeSet) {
1154
+ edgeSet = new Set<string>()
1155
+ env.deniedEdges.set(info.importer, edgeSet)
1156
+ }
1157
+ edgeSet.add(info.specifier)
1158
+
1159
+ if (config.command === 'serve') {
1160
+ const runtimeId = mockRuntimeModuleIdFromViolation(
1161
+ info,
1162
+ config.mockAccess,
1163
+ config.root,
1164
+ )
1165
+ return resolveViteId(
1166
+ buildMockEdgeModuleId(env, info.importer, info.specifier, runtimeId),
1167
+ )
1168
+ }
1169
+
1170
+ // Build: Rollup can synthesize named exports.
1171
+ return { id: RESOLVED_MOCK_MODULE_ID, syntheticNamedExports: true }
1172
+ }
1173
+ }