@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.
- package/dist/esm/dev-server-plugin/plugin.js +1 -1
- package/dist/esm/dev-server-plugin/plugin.js.map +1 -1
- package/dist/esm/import-protection-plugin/defaults.d.ts +17 -0
- package/dist/esm/import-protection-plugin/defaults.js +36 -0
- package/dist/esm/import-protection-plugin/defaults.js.map +1 -0
- package/dist/esm/import-protection-plugin/matchers.d.ts +13 -0
- package/dist/esm/import-protection-plugin/matchers.js +31 -0
- package/dist/esm/import-protection-plugin/matchers.js.map +1 -0
- package/dist/esm/import-protection-plugin/plugin.d.ts +16 -0
- package/dist/esm/import-protection-plugin/plugin.js +699 -0
- package/dist/esm/import-protection-plugin/plugin.js.map +1 -0
- package/dist/esm/import-protection-plugin/postCompileUsage.d.ts +11 -0
- package/dist/esm/import-protection-plugin/postCompileUsage.js +177 -0
- package/dist/esm/import-protection-plugin/postCompileUsage.js.map +1 -0
- package/dist/esm/import-protection-plugin/rewriteDeniedImports.d.ts +27 -0
- package/dist/esm/import-protection-plugin/rewriteDeniedImports.js +51 -0
- package/dist/esm/import-protection-plugin/rewriteDeniedImports.js.map +1 -0
- package/dist/esm/import-protection-plugin/sourceLocation.d.ts +132 -0
- package/dist/esm/import-protection-plugin/sourceLocation.js +255 -0
- package/dist/esm/import-protection-plugin/sourceLocation.js.map +1 -0
- package/dist/esm/import-protection-plugin/trace.d.ts +67 -0
- package/dist/esm/import-protection-plugin/trace.js +204 -0
- package/dist/esm/import-protection-plugin/trace.js.map +1 -0
- package/dist/esm/import-protection-plugin/utils.d.ts +8 -0
- package/dist/esm/import-protection-plugin/utils.js +29 -0
- package/dist/esm/import-protection-plugin/utils.js.map +1 -0
- package/dist/esm/import-protection-plugin/virtualModules.d.ts +25 -0
- package/dist/esm/import-protection-plugin/virtualModules.js +235 -0
- package/dist/esm/import-protection-plugin/virtualModules.js.map +1 -0
- package/dist/esm/plugin.js +7 -0
- package/dist/esm/plugin.js.map +1 -1
- package/dist/esm/prerender.js +3 -3
- package/dist/esm/prerender.js.map +1 -1
- package/dist/esm/schema.d.ts +260 -0
- package/dist/esm/schema.js +35 -1
- package/dist/esm/schema.js.map +1 -1
- package/dist/esm/start-compiler-plugin/compiler.js +5 -1
- package/dist/esm/start-compiler-plugin/compiler.js.map +1 -1
- package/dist/esm/start-compiler-plugin/handleCreateServerFn.js +2 -2
- package/dist/esm/start-compiler-plugin/handleCreateServerFn.js.map +1 -1
- package/dist/esm/start-compiler-plugin/plugin.js.map +1 -1
- package/dist/esm/start-router-plugin/plugin.js +5 -5
- package/dist/esm/start-router-plugin/plugin.js.map +1 -1
- package/package.json +6 -3
- package/src/dev-server-plugin/plugin.ts +1 -1
- package/src/import-protection-plugin/defaults.ts +56 -0
- package/src/import-protection-plugin/matchers.ts +48 -0
- package/src/import-protection-plugin/plugin.ts +1173 -0
- package/src/import-protection-plugin/postCompileUsage.ts +266 -0
- package/src/import-protection-plugin/rewriteDeniedImports.ts +255 -0
- package/src/import-protection-plugin/sourceLocation.ts +524 -0
- package/src/import-protection-plugin/trace.ts +296 -0
- package/src/import-protection-plugin/utils.ts +32 -0
- package/src/import-protection-plugin/virtualModules.ts +300 -0
- package/src/plugin.ts +7 -0
- package/src/schema.ts +58 -0
- package/src/start-compiler-plugin/compiler.ts +12 -1
- 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
|
+
}
|