@tanstack/start-plugin-core 1.163.3 → 1.163.4

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 (47) hide show
  1. package/dist/esm/constants.d.ts +1 -0
  2. package/dist/esm/constants.js +2 -0
  3. package/dist/esm/constants.js.map +1 -1
  4. package/dist/esm/import-protection-plugin/ast.d.ts +3 -0
  5. package/dist/esm/import-protection-plugin/ast.js +8 -0
  6. package/dist/esm/import-protection-plugin/ast.js.map +1 -0
  7. package/dist/esm/import-protection-plugin/constants.d.ts +6 -0
  8. package/dist/esm/import-protection-plugin/constants.js +24 -0
  9. package/dist/esm/import-protection-plugin/constants.js.map +1 -0
  10. package/dist/esm/import-protection-plugin/extensionlessAbsoluteIdResolver.d.ts +22 -0
  11. package/dist/esm/import-protection-plugin/extensionlessAbsoluteIdResolver.js +95 -0
  12. package/dist/esm/import-protection-plugin/extensionlessAbsoluteIdResolver.js.map +1 -0
  13. package/dist/esm/import-protection-plugin/plugin.d.ts +2 -13
  14. package/dist/esm/import-protection-plugin/plugin.js +684 -299
  15. package/dist/esm/import-protection-plugin/plugin.js.map +1 -1
  16. package/dist/esm/import-protection-plugin/postCompileUsage.js +4 -2
  17. package/dist/esm/import-protection-plugin/postCompileUsage.js.map +1 -1
  18. package/dist/esm/import-protection-plugin/rewriteDeniedImports.d.ts +4 -5
  19. package/dist/esm/import-protection-plugin/rewriteDeniedImports.js +225 -3
  20. package/dist/esm/import-protection-plugin/rewriteDeniedImports.js.map +1 -1
  21. package/dist/esm/import-protection-plugin/sourceLocation.d.ts +4 -7
  22. package/dist/esm/import-protection-plugin/sourceLocation.js +18 -73
  23. package/dist/esm/import-protection-plugin/sourceLocation.js.map +1 -1
  24. package/dist/esm/import-protection-plugin/types.d.ts +94 -0
  25. package/dist/esm/import-protection-plugin/utils.d.ts +33 -1
  26. package/dist/esm/import-protection-plugin/utils.js +69 -3
  27. package/dist/esm/import-protection-plugin/utils.js.map +1 -1
  28. package/dist/esm/import-protection-plugin/virtualModules.d.ts +30 -2
  29. package/dist/esm/import-protection-plugin/virtualModules.js +66 -23
  30. package/dist/esm/import-protection-plugin/virtualModules.js.map +1 -1
  31. package/dist/esm/start-compiler-plugin/plugin.d.ts +2 -1
  32. package/dist/esm/start-compiler-plugin/plugin.js +1 -2
  33. package/dist/esm/start-compiler-plugin/plugin.js.map +1 -1
  34. package/package.json +4 -4
  35. package/src/constants.ts +2 -0
  36. package/src/import-protection-plugin/INTERNALS.md +462 -60
  37. package/src/import-protection-plugin/ast.ts +7 -0
  38. package/src/import-protection-plugin/constants.ts +25 -0
  39. package/src/import-protection-plugin/extensionlessAbsoluteIdResolver.ts +121 -0
  40. package/src/import-protection-plugin/plugin.ts +1080 -597
  41. package/src/import-protection-plugin/postCompileUsage.ts +8 -2
  42. package/src/import-protection-plugin/rewriteDeniedImports.ts +141 -9
  43. package/src/import-protection-plugin/sourceLocation.ts +19 -89
  44. package/src/import-protection-plugin/types.ts +103 -0
  45. package/src/import-protection-plugin/utils.ts +123 -4
  46. package/src/import-protection-plugin/virtualModules.ts +117 -31
  47. package/src/start-compiler-plugin/plugin.ts +7 -2
@@ -16,7 +16,7 @@ The plugin must handle **two axes of configuration**:
16
16
 
17
17
  ## Plugin Architecture
18
18
 
19
- `importProtectionPlugin()` returns **three** Vite plugins:
19
+ `importProtectionPlugin()` returns **two** Vite plugins:
20
20
 
21
21
  ### 1. `tanstack-start-core:import-protection` (enforce: `'pre'`)
22
22
 
@@ -31,10 +31,18 @@ transformed code and composed sourcemaps for accurate source-location mapping
31
31
  in violation messages. Also resolves post-transform imports and triggers
32
32
  `processPendingViolations()` for the dev mock deferral path.
33
33
 
34
- ### 3. `tanstack-start-core:import-protection-mock-rewrite` (enforce: `'pre'`, dev only)
34
+ In both build and dev modes, this plugin performs **self-denial**: when a file
35
+ matches a deny pattern in the current environment (e.g. a `.server.ts` file
36
+ transformed in the client environment), its entire content is replaced with a
37
+ mock module. This is the core mechanism for preventing cross-environment cache
38
+ contamination — `resolveId` never returns virtual module IDs for file-based
39
+ violations, so there is nothing for third-party resolver caches
40
+ (e.g. `vite-tsconfig-paths`) to leak across environments. In dev mode, the
41
+ mock imports a `mock-runtime` module for runtime diagnostics; in build mode,
42
+ the mock is fully self-contained.
35
43
 
36
- Records expected named exports per importer so that dev mock-edge modules can
37
- provide explicit ESM named exports. Only active in dev + mock mode.
44
+ See the [Self-Denial Transform](#self-denial-transform-in-detail) section below
45
+ for a detailed walkthrough with code examples.
38
46
 
39
47
  ## Violation Types
40
48
 
@@ -71,15 +79,20 @@ and Rollup's tree-shaking then eliminates the `./db.server` dependency entirely.
71
79
 
72
80
  During dev, Vite's `fetchModule(?SERVER_FN_LOOKUP)` call triggers resolves for
73
81
  analysing a module's exports. These are tracked via `serverFnLookupModules` and
74
- `isPreTransformResolve`, and violations are silenced for them.
82
+ `isPreTransformResolve`. In dev mock mode, pre-transform violations are
83
+ deferred like all other violations (verified via edge-survival and graph
84
+ reachability). In dev error mode, they are silenced because no deferred
85
+ verification path exists.
75
86
 
76
87
  ## Violation Handling Flow
77
88
 
78
89
  ### Central functions
79
90
 
80
91
  - **`handleViolation()`**: Formats + reports (or silences) the violation. Returns
81
- a mock-edge module ID (string) so `resolveId` can substitute the offending
82
- import. May also return `undefined` (suppressed by `onViolation` or
92
+ a resolve result so `resolveId` can substitute the offending import: for
93
+ file-based violations, returns the physical file path (self-denial handles
94
+ the rest in transform); for specifier/marker violations, returns a mock-edge
95
+ module ID. May also return `undefined` (suppressed by `onViolation` or
83
96
  silent+error in dev) or throw via `ctx.error()` (dev+error).
84
97
  - **`reportOrDeferViolation()`**: Dispatch layer. Either defers (stores for later
85
98
  verification) or reports immediately, depending on `shouldDefer`.
@@ -87,11 +100,14 @@ analysing a module's exports. These are tracked via `serverFnLookupModules` and
87
100
  ### `shouldDefer` logic
88
101
 
89
102
  ```ts
90
- shouldDefer = (isDevMock && !isPreTransformResolve) || isBuild
103
+ shouldDefer = isBuild || isDevMock
91
104
  ```
92
105
 
93
- - **Dev mock**: Defer to `pendingViolations` verify via post-transform graph
94
- reachability in `processPendingViolations()`.
106
+ - **Dev mock**: ALL violations (including pre-transform resolves) are deferred
107
+ to `pendingViolations` → verified via edge-survival and post-transform graph
108
+ reachability in `processPendingViolations()`. Pre-transform violations are
109
+ tagged with `fromPreTransformResolve` so the pending-violation processor
110
+ knows to wait for post-transform data before emitting.
95
111
  - **Build (both mock and error)**: Defer to `deferredBuildViolations` → verify
96
112
  via tree-shaking survival in `generateBundle`.
97
113
 
@@ -112,18 +128,43 @@ Violations fire immediately via `ctx.error()` in `resolveId`. No tree-shaking
112
128
  is available, so false positives for barrel patterns are expected and accepted.
113
129
  (Dev + error is typically used only during explicit validation.)
114
130
 
131
+ Pre-transform resolves (e.g. server-fn-lookup) are silenced in error mode
132
+ because they fire before the Start compiler runs — imports inside `.server()`
133
+ callbacks haven't been stripped yet, and error mode has no deferred verification
134
+ path.
135
+
115
136
  ### Dev + Mock
116
137
 
117
- 1. `resolveId` calls `handleViolation({ silent: true })` — no warning emitted.
118
- 2. The mock module ID is returned so the dev server can serve a Proxy-based
119
- mock instead of the real server module.
120
- 3. The violation is stored in `pendingViolations` keyed by the importer's file
138
+ 1. `resolveId` detects the violation and calls `reportOrDeferViolation()`.
139
+ 2. For **file-based violations**: `handleViolation()` returns the **physical
140
+ file path** (same as build mode). The self-denial transform in the
141
+ transform-cache plugin will replace the file's content with a dev mock
142
+ module that imports `mock-runtime` for runtime diagnostics.
143
+ 3. For **specifier/marker violations**: `handleViolation()` returns a mock-edge
144
+ module ID so the dev server can serve a Proxy-based mock.
145
+ 4. The violation is stored in `pendingViolations` keyed by the importer's file
121
146
  path.
122
- 4. The transform-cache plugin, after resolving post-transform imports, calls
147
+ 5. The transform-cache plugin, after resolving post-transform imports, calls
123
148
  `processPendingViolations()`.
124
- 5. `processPendingViolations()` checks graph reachability from entry points
125
- using only post-transform edges. If the violating importer is reachable
126
- confirm (warn). If unreachable discard. If unknown → keep pending.
149
+ 6. `processPendingViolations()` first applies **edge-survival**: if
150
+ post-transform import data is available for the importer, it checks whether
151
+ the denied import survived the Start compiler transform. Imports stripped
152
+ by the compiler (e.g. inside `.server()` callbacks) are discarded. For
153
+ pre-transform violations (`fromPreTransformResolve`), the function waits
154
+ until post-transform data is available before proceeding.
155
+ 7. After edge-survival, `processPendingViolations()` checks graph reachability
156
+ from entry points using only post-transform edges. If the violating
157
+ importer is reachable → confirm (warn). If unreachable → discard. If
158
+ unknown → keep pending or emit conservatively (warm-start fallback).
159
+
160
+ Warm-start stability guardrails:
161
+
162
+ - Resolve-time edges discovered through pre-transform paths
163
+ (`isPreTransformResolve`, especially `SERVER_FN_LOOKUP`) are **not** recorded
164
+ into the reachability graph.
165
+ - In `'unknown'` reachability status, pre-transform pending violations are
166
+ kept pending (not fallback-emitted) until non-lookup transform evidence is
167
+ available.
127
168
 
128
169
  This approach can't fully eliminate barrel false-positives in dev because
129
170
  there's no tree-shaking. The barrel's import of `.server` always resolves,
@@ -131,18 +172,163 @@ and the barrel is reachable. This is a known and accepted limitation.
131
172
 
132
173
  ### Dev mock modules
133
174
 
134
- In dev, each violation gets a **per-importer mock edge module** that:
175
+ Dev violations are handled differently depending on their type:
176
+
177
+ - **File-based violations** use **self-denial** (same mechanism as build mode):
178
+ the denied file's content is replaced by the transform-cache plugin with a
179
+ mock that imports `mock-runtime` for runtime diagnostics. The export list
180
+ comes from the **denied file's AST** (what it exports), generated by
181
+ `generateDevSelfDenialModule()`. This approach avoids the cold-start problem
182
+ where the importer's AST is unavailable at `resolveId` time, and prevents
183
+ cross-environment cache contamination from third-party resolver plugins.
184
+
185
+ - **Specifier/marker violations** use **mock-edge modules**: each violation
186
+ gets a per-importer mock edge module that explicitly exports the names the
187
+ importer expects (extracted lazily by `resolveExportsForDeniedSpecifier()`
188
+ which parses the importer's AST) and delegates to a **runtime mock module**
189
+ that contains a recursive Proxy and optional runtime diagnostics.
190
+
191
+ ### Mock edge modules (in detail)
192
+
193
+ A **mock edge module** is a lightweight, auto-generated virtual module that sits
194
+ between an importing file and a base mock module. Its purpose is to provide
195
+ **explicit ESM named exports** so that bundlers (Rollup, Rolldown) and Vite's dev
196
+ server can correctly resolve `import { foo } from './denied.server'` — even
197
+ though the real module has been replaced by a mock.
198
+
199
+ #### Why they exist
200
+
201
+ The base mock module (`\0tanstack-start-import-protection:mock`) exports only a
202
+ `default` export — a recursive Proxy. But consumers of the denied module may use
203
+ named imports:
204
+
205
+ ```typescript
206
+ import { getSecret, initDb } from './credentials.server'
207
+ ```
208
+
209
+ Without explicit named exports, this would fail: the bundler would complain that
210
+ `getSecret` and `initDb` don't exist on the mock module. Using
211
+ `syntheticNamedExports: true` in Rollup could solve this, but Rolldown (which
212
+ Vite can now use) doesn't support it. Mock edge modules solve the problem
213
+ portably by generating real ESM export statements.
214
+
215
+ #### Structure
216
+
217
+ Each mock edge module is identified by a virtual ID:
218
+
219
+ ```text
220
+ \0tanstack-start-import-protection:mock-edge:<BASE64_PAYLOAD>
221
+ ```
222
+
223
+ The Base64URL payload encodes two fields:
224
+
225
+ - `exports` — the list of named export identifiers the importer needs
226
+ - `runtimeId` — the module ID of the backing mock to import from
227
+
228
+ When Vite's `load` hook encounters this virtual ID, `loadMockEdgeModule()`
229
+ decodes the payload and generates code like this:
230
+
231
+ ```typescript
232
+ // Generated mock edge module for exports: ["getSecret", "initDb"]
233
+ import mock from '\0tanstack-start-import-protection:mock'
234
+ export const getSecret = mock.getSecret
235
+ export const initDb = mock.initDb
236
+ export default mock
237
+ ```
238
+
239
+ Each `mock.getSecret` access returns the Proxy itself (the Proxy's `get` trap
240
+ returns `mock` for any property), so the named exports are valid callable/
241
+ constructable values that won't crash at runtime.
242
+
243
+ #### How exports are determined
244
+
245
+ The export list comes from **parsing the importer's AST** — not the denied
246
+ file's AST. The function `resolveExportsForDeniedSpecifier()` performs this:
247
+
248
+ 1. Gets the importer's code from the transform cache or `getModuleInfo`.
249
+ 2. Calls `collectMockExportNamesBySource()` to extract which names the
250
+ importer uses from each import source (parses the AST internally):
251
+ - **Named imports**: `import { getSecret, initDb } from './creds.server'`
252
+ → `['getSecret', 'initDb']`
253
+ - **Namespace member access**: `import * as creds from './creds.server'` then
254
+ `creds.getSecret()` → `['getSecret']`
255
+ - **Default import member access**: `import creds from './creds.server'` then
256
+ `creds.getSecret` → `['getSecret']`
257
+ - **Re-exports**: `export { getSecret } from './creds.server'`
258
+ → `['getSecret']`
259
+ 3. Caches the result per importer so multiple violations from the same file
260
+ don't re-parse.
261
+
262
+ This "importer-driven" approach means the mock edge module only exports the
263
+ names the consumer actually references — not all exports from the denied file.
264
+
265
+ #### Dev vs build mock edge modules
266
+
267
+ | Aspect | Dev mock edge | Build mock edge |
268
+ | ---------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------- |
269
+ | **Backing mock** | `mock-runtime:BASE64` (runtime diagnostics) or `mock` (silent) | `mock:build:N` (per-violation unique, silent) |
270
+ | **Purpose** | Serve mocks in dev server + runtime warnings | Track tree-shaking survival in `generateBundle` |
271
+ | **Created for** | Specifier/marker violations only (file uses self-denial instead) | Specifier/marker violations only (file uses self-denial instead) |
272
+ | **Uniqueness** | Per-importer per-specifier | Per-violation (unique counter) |
273
+
274
+ In **dev**, the backing `runtimeId` is a `mock-runtime:BASE64` module that
275
+ includes diagnostic metadata (environment, import path, trace). When the mock
276
+ is accessed in the browser, it logs a console warning or error. For SSR or
277
+ when `mockAccess` is `'off'`, the backing mock is the shared silent
278
+ `MOCK_MODULE_ID`.
279
+
280
+ In **build**, the backing mock uses a unique counter ID
281
+ (`mock:build:0`, `mock:build:1`, ...) so `generateBundle` can check whether
282
+ each specific violation's mock survived tree-shaking.
283
+
284
+ #### Handling non-identifier export names
285
+
286
+ ES2022 allows string-keyed exports like `export { x as "foo-bar" }`. Mock edge
287
+ modules handle these via an intermediate variable:
288
+
289
+ ```typescript
290
+ import mock from '\0tanstack-start-import-protection:mock'
291
+ export const validName = mock.validName
292
+ const __tss_str_0 = mock['foo-bar']
293
+ export { __tss_str_0 as 'foo-bar' }
294
+ ```
295
+
296
+ The `default` export name is always filtered out (handled separately as
297
+ `export default mock`).
298
+
299
+ ### File-based violations: self-denial (dev and build)
300
+
301
+ File-based violations (e.g. `import './db.server'` in client env) use
302
+ self-denial in **both** dev and build modes. `handleViolation()` returns the
303
+ physical file path, and the transform-cache plugin replaces the file's
304
+ contents with a mock module.
305
+
306
+ - **Build mode**: Uses `generateSelfContainedMockModule()` — a fully
307
+ self-contained mock with an inlined Proxy factory (no `import` statements).
308
+ This is important because build-mode mocks must be tree-shakeable.
309
+
310
+ - **Dev mode**: Uses `generateDevSelfDenialModule()` — a mock that imports
311
+ `mock-runtime` for runtime diagnostics (console warnings/errors when the
312
+ mock is accessed in the browser). The `mock-runtime` module ID encodes
313
+ violation metadata (environment, import path, trace).
314
+
315
+ Self-denial avoids creating virtual mock-edge module IDs that could
316
+ contaminate third-party resolver caches across Vite environments.
317
+
318
+ ### Non-file violations: mock-edge modules (dev and build)
135
319
 
136
- - Explicitly exports the names the importer expects (extracted by the
137
- mock-rewrite plugin).
138
- - Delegates to a **runtime mock module** that contains a recursive Proxy and
139
- optional runtime diagnostics (console warnings when mocked values are used).
320
+ Specifier and marker violations use **mock-edge modules** because the denied
321
+ specifier doesn't resolve to a physical file that could be "self-denied."
140
322
 
141
- This differs from build mode, where each violation gets a **per-violation mock
142
- edge module** wrapping a unique base mock module
143
- (`\0tanstack-start-import-protection:mock:build:N`). The edge module re-exports
144
- the named exports the importer expects, just like in dev, ensuring compatibility
145
- with both Rollup and Rolldown (which doesn't support `syntheticNamedExports`).
323
+ - **Build mode**: Each violation gets a **per-violation mock edge module**
324
+ wrapping a unique base mock module
325
+ (`\0tanstack-start-import-protection:mock:build:N`). The edge module
326
+ re-exports the named exports the importer expects, ensuring compatibility
327
+ with both Rollup and Rolldown (which doesn't support
328
+ `syntheticNamedExports`).
329
+
330
+ - **Dev mode**: Each violation gets a **per-importer mock edge module**
331
+ wrapping a `mock-runtime` module for runtime diagnostics.
146
332
 
147
333
  ## Build Mode Strategy
148
334
 
@@ -150,22 +336,27 @@ with both Rollup and Rolldown (which doesn't support `syntheticNamedExports`).
150
336
 
151
337
  Both mock and error build modes follow the same pattern:
152
338
 
153
- 1. **`resolveId`**: Call `handleViolation({ silent: true })`. Generate a
154
- **unique per-violation mock-edge module** that wraps a base mock module
155
- (`\0tanstack-start-import-protection:mock:build:N`) and provides explicit
156
- named exports matching the importer's import bindings. Store the violation +
157
- mock-edge ID in `env.deferredBuildViolations`. Return the mock-edge ID so the
158
- bundler substitutes the offending import.
159
-
160
- 2. **`load`**: For the base mock module, return a silent Proxy-based mock. For
161
- the mock-edge module, return code that imports from the base mock and
339
+ 1. **`resolveId`**: Call `handleViolation({ silent: true })`.
340
+ - For **file-based violations**: Returns the physical file path. The
341
+ self-denial transform will replace its content.
342
+ - For **specifier/marker violations**: Generates a **unique per-violation
343
+ mock-edge module** wrapping a base mock module
344
+ (`\0tanstack-start-import-protection:mock:build:N`). Stores the violation +
345
+ mock-edge ID in `env.deferredBuildViolations`. Returns the mock-edge ID.
346
+
347
+ 2. **`transform`** (self-denial): For file-based violations, the transform-cache
348
+ plugin detects that the current file is denied in this environment and
349
+ replaces its content with a self-contained mock module
350
+ (`generateSelfContainedMockModule()`).
351
+
352
+ 3. **`load`**: For base mock modules, returns a silent Proxy-based mock. For
353
+ mock-edge modules, returns code that imports from the base mock and
162
354
  re-exports the expected named bindings (e.g. `export const Foo = mock.Foo`).
163
355
 
164
- 3. **Tree-shaking**: The bundler processes the bundle normally. If no binding from
165
- the mock-edge module is actually used at runtime, both the edge and base
166
- modules are eliminated.
356
+ 4. **Tree-shaking**: The bundler processes the bundle normally. If no binding from
357
+ the mock module is actually used at runtime, the modules are eliminated.
167
358
 
168
- 4. **`generateBundle`**: Inspect the output chunks. For each deferred violation,
359
+ 5. **`generateBundle`**: Inspect the output chunks. For each deferred violation,
169
360
  check whether its unique mock module ID appears in any chunk's `modules`.
170
361
  - **Survived** → real violation (the import wasn't tree-shaken away).
171
362
  - Error mode: `ctx.error()` — fail the build.
@@ -199,19 +390,19 @@ per-environment verification.
199
390
 
200
391
  ## Marker violations vs. file/specifier violations
201
392
 
202
- | | Marker | File / Specifier |
203
- | ----------------------------- | --------------------------------------------------- | -------------------------------------------- |
204
- | **What triggers it** | The _importer_ has a conflicting directive | The _import target_ matches a deny rule |
205
- | **resolveId returns (dev)** | Marker module ID (`RESOLVED_MARKER_*`) \* | Mock edge module ID (per-importer) |
206
- | **resolveId returns (build)** | Unique build mock ID (same as file/specifier) | Unique build mock ID |
207
- | **Can be tree-shaken away?** | Yes — if the importer is eliminated by tree-shaking | Yes — if no binding from the target survives |
208
- | **Deferred in build?** | Yes — deferred to `generateBundle` | Yes — deferred to `generateBundle` |
209
- | **Deferred in dev mock?** | Yes — deferred to pending graph | Yes — deferred to pending graph |
393
+ | | Marker | Specifier | File |
394
+ | ----------------------------- | --------------------------------------------------- | -------------------------------------------- | --------------------------------------------- |
395
+ | **What triggers it** | The _importer_ has a conflicting directive | The _import specifier_ matches a deny rule | The _resolved path_ matches a deny glob |
396
+ | **resolveId returns (dev)** | Marker module ID (`RESOLVED_MARKER_*`) \* | Mock edge module ID (per-importer) | Physical file path (self-denial in transform) |
397
+ | **resolveId returns (build)** | Unique build mock ID | Unique build mock ID | Physical file path (self-denial in transform) |
398
+ | **Can be tree-shaken away?** | Yes — if the importer is eliminated by tree-shaking | Yes — if no binding from the target survives | Yes — if no binding from the target survives |
399
+ | **Deferred in build?** | Yes — deferred to `generateBundle` | Yes — deferred to `generateBundle` | Yes — deferred to `generateBundle` |
400
+ | **Deferred in dev mock?** | Yes — deferred to pending graph | Yes — deferred to pending graph | Yes — deferred to pending graph |
210
401
 
211
- \* In dev mock, `handleViolation` internally returns a mock edge module ID
402
+ \* In dev mock, `handleViolation` internally returns the physical file path
212
403
  (stored as the deferred result in `pendingViolations`), but `resolveId` ignores
213
- it for markers and falls through to return the marker module ID. The mock edge
214
- ID is only used for deferral bookkeeping, not for module resolution.
404
+ it for markers and falls through to return the marker module ID. The file path
405
+ is only used for deferral bookkeeping, not for module resolution.
215
406
 
216
407
  ## State Management
217
408
 
@@ -239,13 +430,13 @@ Per-build/watch iteration, `buildStart` clears `pendingViolations` and resets
239
430
 
240
431
  ## Virtual Module IDs
241
432
 
242
- | ID Pattern | Usage |
243
- | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
244
- | `\0tanstack-start-import-protection:mock` | Shared silent mock (dev mock only) |
245
- | `\0tanstack-start-import-protection:mock:build:N` | Per-violation build mock (unique counter) |
246
- | `\0tanstack-start-import-protection:mock-edge:BASE64` | Per-importer dev mock with explicit named exports |
247
- | `\0tanstack-start-import-protection:mock-runtime:BASE64` | Runtime diagnostic mock (dev client, console warnings) |
248
- | `\0tanstack-start-import-protection:marker:*` | Marker module (empty `export {}`). Suffixed `server-only` or `client-only`; derived from `MARKER_PREFIX` in `plugin.ts` |
433
+ | ID Pattern | Usage |
434
+ | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
435
+ | `\0tanstack-start-import-protection:mock` | Shared silent mock (dev mock only) |
436
+ | `\0tanstack-start-import-protection:mock:build:N` | Per-violation build mock (unique counter) |
437
+ | `\0tanstack-start-import-protection:mock-edge:BASE64` | Per-importer mock with explicit named exports (specifier/marker violations, dev + build) |
438
+ | `\0tanstack-start-import-protection:mock-runtime:BASE64` | Runtime diagnostic mock (dev client, console warnings) |
439
+ | `\0tanstack-start-import-protection:marker:*` | Marker module (empty `export {}`). Suffixed `server-only` or `client-only`; derived from `MARKER_PREFIX` in `virtualModules.ts` |
249
440
 
250
441
  ## Key Design Decisions
251
442
 
@@ -284,7 +475,10 @@ build mode provides definitive answers via tree-shaking.
284
475
 
285
476
  ## E2E Test Structure
286
477
 
287
- Tests live in `e2e/react-start/import-protection/`:
478
+ Tests live in `e2e/react-start/import-protection/` and
479
+ `e2e/react-start/import-protection-custom-config/`:
480
+
481
+ ### Main test suite (`import-protection/`)
288
482
 
289
483
  - **Mock mode** (default): `globalSetup` builds the app, captures build warnings
290
484
  to `violations.build.json`, starts a dev server capturing to
@@ -296,3 +490,211 @@ Tests live in `e2e/react-start/import-protection/`:
296
490
  re-exporting from both a `.server` file and a marker-protected file (`foo.ts`
297
491
  with `import 'server-only'`), where all server-only bindings are tree-shaken
298
492
  away, produces **zero** violations in the build log.
493
+
494
+ ### Custom-config test suite (`import-protection-custom-config/`)
495
+
496
+ Uses non-default deny patterns (`**/*.backend.*` / `**/*.frontend.*`) to verify
497
+ that import protection works with user-configured file patterns. This ensures
498
+ the plugin doesn't hardcode any assumption about `.server`/`.client` naming
499
+ conventions. The `vite.config.ts` provides custom `client.files` and
500
+ `server.files` arrays; no `vite-tsconfig-paths` is used.
501
+
502
+ ## Self-Denial Transform (In Detail)
503
+
504
+ The self-denial transform is the mechanism by which the plugin prevents cross-
505
+ environment contamination without creating virtual module IDs that could leak
506
+ through third-party resolver caches. It applies to **file-based violations in
507
+ both dev and build modes**.
508
+
509
+ ### The problem it solves
510
+
511
+ In Vite 7+, client and SSR environments run within the same Vite process. Some
512
+ plugins (e.g. `vite-tsconfig-paths`) maintain a global resolution cache shared
513
+ across environments. If the import-protection plugin resolves a specifier to a
514
+ virtual mock module ID (e.g.
515
+ `\0tanstack-start-import-protection:mock-edge:...`) in the client environment,
516
+ that virtual ID can leak into the SSR environment's cache. When the SSR
517
+ environment later resolves the same specifier, it finds the cached virtual ID
518
+ instead of the real file — breaking the server.
519
+
520
+ In dev mode, self-denial also solves a **cold-start problem**: on cold start
521
+ (no `.vite` cache), the importer's AST is unavailable when `resolveId` runs
522
+ (neither the transform cache nor `getModuleInfo` have content yet). If mock-edge
523
+ module IDs were generated at `resolveId` time, the export list would be empty
524
+ (since it's derived from parsing the importer), producing a mock with no named
525
+ exports — causing runtime errors like
526
+ `does not provide an export named: 'getSecret'`.
527
+
528
+ ### How it works
529
+
530
+ Instead of returning a virtual module ID from `resolveId`, the self-denial
531
+ transform works at the **transform** stage:
532
+
533
+ 1. **`resolveId`**: For file-based violations, `handleViolation()` returns the
534
+ **physical file path**. The import resolves normally. No virtual ID is
535
+ created.
536
+
537
+ 2. **transform-cache plugin**: When the transform-cache hook processes a file,
538
+ it checks whether the file matches any deny pattern for the current
539
+ environment using `checkFileDenial()`. If the file is denied, the plugin:
540
+
541
+ a. Extracts the file's named exports using `collectNamedExports()` (parses
542
+ the AST internally).
543
+ b. **Build mode**: Generates a self-contained mock module via
544
+ `generateSelfContainedMockModule()` — no imports, inlined Proxy factory.
545
+ c. **Dev mode**: Generates a dev mock module via
546
+ `generateDevSelfDenialModule()` — imports `mock-runtime` for runtime
547
+ diagnostics (console warnings/errors when the mock is accessed).
548
+ d. Returns the mock code as the transform result, completely replacing the
549
+ original file content.
550
+
551
+ 3. **Fallback**: If AST parsing fails (e.g. due to syntax errors in the denied
552
+ file), `exportNames` defaults to `[]` (empty) and the mock module has no
553
+ named exports:
554
+ - Build: `generateSelfContainedMockModule([])`.
555
+ - Dev: `generateDevSelfDenialModule([], runtimeId)` where `runtimeId` is
556
+ computed via `mockRuntimeModuleIdFromViolation()` (which may internally
557
+ fall back to the shared silent `MOCK_MODULE_ID` for SSR or when
558
+ `mockAccess` is `'off'`).
559
+
560
+ ### Code transformation example
561
+
562
+ **Original file** (`src/lib/credentials.server.ts`):
563
+
564
+ ```typescript
565
+ import { db } from './database'
566
+
567
+ export function getSecret(): string {
568
+ return db.query('SELECT secret FROM config LIMIT 1')
569
+ }
570
+
571
+ export const API_KEY = process.env.API_KEY!
572
+
573
+ export default { getSecret, API_KEY }
574
+ ```
575
+
576
+ **After self-denial transform in build mode** (client environment):
577
+
578
+ ```typescript
579
+ /* @__NO_SIDE_EFFECTS__ */
580
+ function createMock() {
581
+ const handler = {
582
+ get: (_, prop) => {
583
+ if (prop === Symbol.toPrimitive) return () => 'MOCK'
584
+ if (typeof prop === 'symbol') return undefined
585
+ return mock
586
+ },
587
+ apply: () => mock,
588
+ construct: () => mock,
589
+ }
590
+ const mock = /* @__PURE__ */ new Proxy(function () {}, handler)
591
+ return mock
592
+ }
593
+ const mock = /* @__PURE__ */ createMock()
594
+ export const getSecret = mock.getSecret
595
+ export const API_KEY = mock.API_KEY
596
+ export default mock
597
+ ```
598
+
599
+ **After self-denial transform in dev mode** (client environment):
600
+
601
+ ```typescript
602
+ import mock from '\0tanstack-start-import-protection:mock-runtime:eyJ...'
603
+ export const getSecret = mock.getSecret
604
+ export const API_KEY = mock.API_KEY
605
+ export default mock
606
+ ```
607
+
608
+ The `mock-runtime` module ID encodes violation metadata (environment, importer,
609
+ specifier, trace) as a Base64URL payload. When the mock is accessed in the
610
+ browser, it logs a console warning or error with this metadata.
611
+
612
+ ### Key properties
613
+
614
+ **Build-mode mock** (`generateSelfContainedMockModule`):
615
+
616
+ - **Self-contained**: No `import` statements — the Proxy factory is inlined.
617
+ - **Tree-shakeable**: All exports are marked pure (`@__NO_SIDE_EFFECTS__`,
618
+ `@__PURE__`), so the bundler can eliminate unused exports.
619
+
620
+ **Dev-mode mock** (`generateDevSelfDenialModule`):
621
+
622
+ - **Runtime diagnostics**: Imports `mock-runtime` which provides console
623
+ warnings/errors when the mock is accessed in the browser.
624
+ - **Lightweight**: No inlined Proxy factory — delegates to the mock-runtime
625
+ virtual module.
626
+
627
+ **Both modes**:
628
+
629
+ - **Same export interface**: Named exports match the original file's exports,
630
+ so consumers' import bindings remain valid.
631
+ - **Safe at runtime**: Every access returns the same Proxy, which is callable,
632
+ constructable, and returns "MOCK" when coerced to a string.
633
+ - **Export accuracy from denied file**: Unlike mock-edge modules (which parse
634
+ the importer's AST), self-denial parses the **denied file's own AST**.
635
+ This provides a safe over-approximation — all exports the file offers — and
636
+ avoids the cold-start problem where the importer hasn't been transformed yet.
637
+
638
+ ### Why self-denial instead of virtual modules for file-based violations
639
+
640
+ | Approach | Virtual mock ID | Self-denial |
641
+ | ------------------ | -------------------------------------------------------------- | -------------------------------------------------------------- |
642
+ | `resolveId` return | `\0mock-edge:BASE64` (virtual) | Physical file path (resolves normally) |
643
+ | Cache risk | Virtual ID stored in shared resolver cache → leaks to SSR | Physical path cached → correct in both environments |
644
+ | Module identity | New virtual module per violation | Same physical file, different content per environment |
645
+ | Export accuracy | From importer's AST (what it imports) | From denied file's AST (what it exports) |
646
+ | Cold-start safety | Fails — importer AST unavailable on cold start → empty exports | Safe — denied file's source code always available in transform |
647
+
648
+ ### When self-denial is NOT used
649
+
650
+ Self-denial only applies to **file-based** violations.
651
+
652
+ - **Specifier violations** (e.g. `import '@tanstack/react-start/server'`):
653
+ These are bare specifiers, not file paths. They use virtual mock-edge module
654
+ IDs because the specifier doesn't resolve to a physical file that could be
655
+ "self-denied."
656
+ - **Marker violations**: Use virtual mock IDs in build mode for
657
+ `generateBundle` tracking (see marker section above). In dev mode, markers
658
+ resolve to the marker module ID directly.
659
+
660
+ ## Module Dependency Architecture
661
+
662
+ The import-protection plugin is structured to avoid heavy transitive
663
+ dependencies in its utility modules:
664
+
665
+ ```text
666
+ constants.ts ← lightweight (no heavy imports)
667
+ ├── IMPORT_PROTECTION_DEBUG
668
+ ├── KNOWN_SOURCE_EXTENSIONS
669
+ ├── SERVER_FN_LOOKUP_QUERY ← imports SERVER_FN_LOOKUP from parent constants.ts
670
+ └── VITE_BROWSER_VIRTUAL_PREFIX
671
+
672
+ defaults.ts ← lightweight (type-only imports)
673
+ ├── getDefaultImportProtectionRules
674
+ └── getMarkerSpecifiers
675
+
676
+ matchers.ts ← picomatch only
677
+ ├── compileMatcher
678
+ ├── compileMatchers
679
+ └── matchesAny
680
+
681
+ utils.ts ← vite + node:path + local constants
682
+ ├── normalizeFilePath, stripViteQuery, ...
683
+ └── extractImportSources, dedupePatterns, ...
684
+
685
+ trace.ts ← imports from utils only
686
+ ├── ImportGraph
687
+ ├── buildTrace
688
+ └── formatViolation
689
+
690
+ virtualModules.ts ← imports ../utils (parent), ../constants (parent)
691
+ ├── generateSelfContainedMockModule, generateDevSelfDenialModule
692
+ ├── loadMockEdgeModule, loadMockRuntimeModule
693
+ └── MOCK_MODULE_ID, MOCK_EDGE_PREFIX, ...
694
+
695
+ plugin.ts ← main plugin, imports everything above
696
+ ```
697
+
698
+ The `SERVER_FN_LOOKUP` constant lives in the shared parent `constants.ts`
699
+ (not in `start-compiler-plugin/plugin.ts`) to avoid pulling in
700
+ `@tanstack/start-server-core` when unit-testing import-protection modules.
@@ -0,0 +1,7 @@
1
+ import { parseAst } from '@tanstack/router-utils'
2
+
3
+ export type ParsedAst = ReturnType<typeof parseAst>
4
+
5
+ export function parseImportProtectionAst(code: string): ParsedAst {
6
+ return parseAst({ code })
7
+ }
@@ -0,0 +1,25 @@
1
+ import { SERVER_FN_LOOKUP } from '../constants'
2
+
3
+ export const SERVER_FN_LOOKUP_QUERY = `?${SERVER_FN_LOOKUP}`
4
+
5
+ export const IMPORT_PROTECTION_DEBUG =
6
+ process.env.TSR_IMPORT_PROTECTION_DEBUG === '1' ||
7
+ process.env.TSR_IMPORT_PROTECTION_DEBUG === 'true'
8
+
9
+ export const IMPORT_PROTECTION_DEBUG_FILTER =
10
+ process.env.TSR_IMPORT_PROTECTION_DEBUG_FILTER
11
+
12
+ export const KNOWN_SOURCE_EXTENSIONS = new Set([
13
+ '.ts',
14
+ '.tsx',
15
+ '.mts',
16
+ '.cts',
17
+ '.js',
18
+ '.jsx',
19
+ '.mjs',
20
+ '.cjs',
21
+ '.json',
22
+ ])
23
+
24
+ /** Vite's browser-visible prefix for virtual modules (replaces `\0`). */
25
+ export const VITE_BROWSER_VIRTUAL_PREFIX = '/@id/__x00__'