@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,524 @@
1
+ import { SourceMapConsumer } from 'source-map'
2
+ import * as path from 'pathe'
3
+
4
+ import { normalizeFilePath } from './utils'
5
+ import type { Loc } from './trace'
6
+ import type { RawSourceMap } from 'source-map'
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Source-map type compatible with both Rollup's SourceMap and source-map's
10
+ // RawSourceMap. We define our own structural type so that the value returned
11
+ // by `getCombinedSourcemap()` (version: number) flows seamlessly into
12
+ // `SourceMapConsumer` (version: string) without requiring a cast.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /**
16
+ * Minimal source-map shape used throughout the import-protection plugin.
17
+ *
18
+ * Structurally compatible with both Rollup's `SourceMap` (version: number)
19
+ * and the `source-map` package's `RawSourceMap` (version: string).
20
+ */
21
+ export interface SourceMapLike {
22
+ file?: string
23
+ sourceRoot?: string
24
+ version: number | string
25
+ sources: Array<string>
26
+ names: Array<string>
27
+ sourcesContent?: Array<string | null>
28
+ mappings: string
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Transform result provider (replaces ctx.load() which doesn't work in dev)
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * A cached transform result for a single module.
37
+ *
38
+ * - `code` – fully-transformed source (after all plugins).
39
+ * - `map` – composed sourcemap (chains back to the original file).
40
+ * - `originalCode` – the untransformed source, extracted from the
41
+ * sourcemap's `sourcesContent[0]` during the transform
42
+ * hook. Used by {@link buildCodeSnippet} so we never
43
+ * have to re-derive it via a flaky `sourceContentFor`
44
+ * lookup at display time.
45
+ */
46
+ export interface TransformResult {
47
+ code: string
48
+ map: SourceMapLike | undefined
49
+ originalCode: string | undefined
50
+ /** Precomputed line index for `code` (index → line/col). */
51
+ lineIndex?: LineIndex
52
+ }
53
+
54
+ /**
55
+ * Provides the transformed code and composed sourcemap for a module.
56
+ *
57
+ * During `resolveId`, Vite's `this.load()` does NOT return code/map in dev
58
+ * mode (the ModuleInfo proxy throws on `.code` access). Even in build mode,
59
+ * Rollup's `ModuleInfo` has `.code` but not `.map`.
60
+ *
61
+ * Instead, we populate this cache from a late-running transform hook that
62
+ * stores `{ code, map, originalCode }` for every module as it passes through
63
+ * the pipeline. By the time `resolveId` fires for an import, the importer
64
+ * has already been fully transformed, so the cache always has the data we
65
+ * need.
66
+ *
67
+ * The `id` parameter is the **raw** module ID (may include Vite query
68
+ * parameters like `?tsr-split=component`). Implementations should look up
69
+ * with the full ID first, then fall back to the query-stripped path so that
70
+ * virtual-module variants are resolved correctly without losing the base-file
71
+ * fallback.
72
+ */
73
+ export interface TransformResultProvider {
74
+ getTransformResult: (id: string) => TransformResult | undefined
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Index → line/column conversion
79
+ // ---------------------------------------------------------------------------
80
+
81
+ export type LineIndex = {
82
+ offsets: Array<number>
83
+ }
84
+
85
+ export function buildLineIndex(code: string): LineIndex {
86
+ const offsets: Array<number> = [0]
87
+ for (let i = 0; i < code.length; i++) {
88
+ if (code.charCodeAt(i) === 10) {
89
+ offsets.push(i + 1)
90
+ }
91
+ }
92
+ return { offsets }
93
+ }
94
+
95
+ function upperBound(values: Array<number>, x: number): number {
96
+ let lo = 0
97
+ let hi = values.length
98
+ while (lo < hi) {
99
+ const mid = (lo + hi) >> 1
100
+ if (values[mid]! <= x) lo = mid + 1
101
+ else hi = mid
102
+ }
103
+ return lo
104
+ }
105
+
106
+ function indexToLineColWithIndex(
107
+ lineIndex: LineIndex,
108
+ idx: number,
109
+ ): { line: number; column0: number } {
110
+ let line = 1
111
+
112
+ const offsets = lineIndex.offsets
113
+ const ub = upperBound(offsets, idx)
114
+ const lineIdx = Math.max(0, ub - 1)
115
+ line = lineIdx + 1
116
+
117
+ const lineStart = offsets[lineIdx] ?? 0
118
+ return { line, column0: Math.max(0, idx - lineStart) }
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Pick the best original source from sourcesContent
123
+ // ---------------------------------------------------------------------------
124
+
125
+ function suffixSegmentScore(a: string, b: string): number {
126
+ const aSeg = a.split('/').filter(Boolean)
127
+ const bSeg = b.split('/').filter(Boolean)
128
+ let score = 0
129
+ for (
130
+ let i = aSeg.length - 1, j = bSeg.length - 1;
131
+ i >= 0 && j >= 0;
132
+ i--, j--
133
+ ) {
134
+ if (aSeg[i] !== bSeg[j]) break
135
+ score++
136
+ }
137
+ return score
138
+ }
139
+
140
+ function normalizeSourceCandidate(
141
+ source: string,
142
+ root: string,
143
+ sourceRoot: string | undefined,
144
+ ): string {
145
+ // Prefer resolving relative source paths against root/sourceRoot when present.
146
+ if (!source) return ''
147
+ if (path.isAbsolute(source)) return normalizeFilePath(source)
148
+ const base = sourceRoot ? path.resolve(root, sourceRoot) : root
149
+ return normalizeFilePath(path.resolve(base, source))
150
+ }
151
+
152
+ /**
153
+ * Pick the most-likely original source text for `importerFile`.
154
+ *
155
+ * Sourcemaps can contain multiple sources (composed maps), so `sourcesContent[0]`
156
+ * is not guaranteed to represent the importer.
157
+ */
158
+ export function pickOriginalCodeFromSourcesContent(
159
+ map: SourceMapLike | undefined,
160
+ importerFile: string,
161
+ root: string,
162
+ ): string | undefined {
163
+ if (!map?.sourcesContent || map.sources.length === 0) {
164
+ return undefined
165
+ }
166
+
167
+ const file = normalizeFilePath(importerFile)
168
+ const sourceRoot = map.sourceRoot
169
+
170
+ let bestIdx = -1
171
+ let bestScore = -1
172
+
173
+ for (let i = 0; i < map.sources.length; i++) {
174
+ const content = map.sourcesContent[i]
175
+ if (typeof content !== 'string') continue
176
+
177
+ const src = map.sources[i] ?? ''
178
+
179
+ // Exact match via raw normalized source.
180
+ const normalizedSrc = normalizeFilePath(src)
181
+ if (normalizedSrc === file) {
182
+ return content
183
+ }
184
+
185
+ // Exact match via resolved absolute candidate.
186
+ const resolved = normalizeSourceCandidate(src, root, sourceRoot)
187
+ if (resolved === file) {
188
+ return content
189
+ }
190
+
191
+ const score = Math.max(
192
+ suffixSegmentScore(normalizedSrc, file),
193
+ suffixSegmentScore(resolved, file),
194
+ )
195
+
196
+ if (score > bestScore) {
197
+ bestScore = score
198
+ bestIdx = i
199
+ }
200
+ }
201
+
202
+ // Require at least a basename match; otherwise fall back to index 0.
203
+ if (bestIdx !== -1 && bestScore >= 1) {
204
+ const best = map.sourcesContent[bestIdx]
205
+ return typeof best === 'string' ? best : undefined
206
+ }
207
+
208
+ const fallback = map.sourcesContent[0]
209
+ return typeof fallback === 'string' ? fallback : undefined
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Sourcemap: generated → original mapping
214
+ // ---------------------------------------------------------------------------
215
+
216
+ export async function mapGeneratedToOriginal(
217
+ map: SourceMapLike | undefined,
218
+ generated: { line: number; column0: number },
219
+ fallbackFile: string,
220
+ ): Promise<Loc> {
221
+ const fallback: Loc = {
222
+ file: fallbackFile,
223
+ line: generated.line,
224
+ column: generated.column0 + 1,
225
+ }
226
+
227
+ if (!map) {
228
+ return fallback
229
+ }
230
+
231
+ const consumer = await getSourceMapConsumer(map)
232
+ if (!consumer) return fallback
233
+
234
+ try {
235
+ const orig = consumer.originalPositionFor({
236
+ line: generated.line,
237
+ column: generated.column0,
238
+ })
239
+ if (orig.line != null && orig.column != null) {
240
+ return {
241
+ file: orig.source ? normalizeFilePath(orig.source) : fallbackFile,
242
+ line: orig.line,
243
+ column: orig.column + 1,
244
+ }
245
+ }
246
+ } catch {
247
+ // Invalid or malformed sourcemap — fall through to fallback.
248
+ }
249
+
250
+ return fallback
251
+ }
252
+
253
+ // Cache SourceMapConsumer per sourcemap object.
254
+ const consumerCache = new WeakMap<object, Promise<SourceMapConsumer | null>>()
255
+
256
+ async function getSourceMapConsumer(
257
+ map: SourceMapLike,
258
+ ): Promise<SourceMapConsumer | null> {
259
+ // WeakMap requires an object key; SourceMapLike should be an object in all
260
+ // real cases, but guard anyway.
261
+ // (TypeScript already guarantees `map` is an object here.)
262
+
263
+ const cached = consumerCache.get(map)
264
+ if (cached) return cached
265
+
266
+ const promise = (async () => {
267
+ try {
268
+ return await new SourceMapConsumer(map as unknown as RawSourceMap)
269
+ } catch {
270
+ return null
271
+ }
272
+ })()
273
+
274
+ consumerCache.set(map, promise)
275
+ return promise
276
+ }
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // Import specifier search (regex-based, no AST needed)
280
+ // ---------------------------------------------------------------------------
281
+
282
+ export function findFirstImportSpecifierIndex(
283
+ code: string,
284
+ source: string,
285
+ ): number {
286
+ const escaped = source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
287
+
288
+ const patterns: Array<RegExp> = [
289
+ // import 'x'
290
+ new RegExp(`\\bimport\\s+(['"])${escaped}\\1`),
291
+ // import ... from 'x' / export ... from 'x'
292
+ new RegExp(`\\bfrom\\s+(['"])${escaped}\\1`),
293
+ // import('x')
294
+ new RegExp(`\\bimport\\s*\\(\\s*(['"])${escaped}\\1\\s*\\)`),
295
+ ]
296
+
297
+ let best = -1
298
+ for (const re of patterns) {
299
+ const m = re.exec(code)
300
+ if (!m) continue
301
+ const idx = m.index + m[0].indexOf(source)
302
+ if (idx === -1) continue
303
+ if (best === -1 || idx < best) best = idx
304
+ }
305
+ return best
306
+ }
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // High-level location finders (use transform result cache, no disk reads)
310
+ // ---------------------------------------------------------------------------
311
+
312
+ /**
313
+ * Find the location of an import statement in a transformed module.
314
+ *
315
+ * Looks up the module's transformed code + composed sourcemap from the
316
+ * {@link TransformResultProvider}, finds the import specifier in the
317
+ * transformed code, and maps back to the original source via the sourcemap.
318
+ *
319
+ * Results are cached in `importLocCache`.
320
+ */
321
+ export async function findImportStatementLocationFromTransformed(
322
+ provider: TransformResultProvider,
323
+ importerId: string,
324
+ source: string,
325
+ importLocCache: Map<
326
+ string,
327
+ { file?: string; line: number; column: number } | null
328
+ >,
329
+ ): Promise<Loc | undefined> {
330
+ const importerFile = normalizeFilePath(importerId)
331
+ const cacheKey = `${importerFile}::${source}`
332
+ if (importLocCache.has(cacheKey)) {
333
+ return importLocCache.get(cacheKey) || undefined
334
+ }
335
+
336
+ try {
337
+ // Pass the raw importerId so the provider can look up the exact virtual
338
+ // module variant (e.g. ?tsr-split=component) before falling back to the
339
+ // base file path.
340
+ const res = provider.getTransformResult(importerId)
341
+ if (!res) {
342
+ importLocCache.set(cacheKey, null)
343
+ return undefined
344
+ }
345
+
346
+ const { code, map } = res
347
+ if (typeof code !== 'string') {
348
+ importLocCache.set(cacheKey, null)
349
+ return undefined
350
+ }
351
+
352
+ const lineIndex = res.lineIndex ?? buildLineIndex(code)
353
+
354
+ const idx = findFirstImportSpecifierIndex(code, source)
355
+ if (idx === -1) {
356
+ importLocCache.set(cacheKey, null)
357
+ return undefined
358
+ }
359
+
360
+ const generated = indexToLineColWithIndex(lineIndex, idx)
361
+ const loc = await mapGeneratedToOriginal(map, generated, importerFile)
362
+ importLocCache.set(cacheKey, loc)
363
+ return loc
364
+ } catch {
365
+ importLocCache.set(cacheKey, null)
366
+ return undefined
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Find the first post-compile usage location for a denied import specifier.
372
+ *
373
+ * Best-effort: looks up the module's transformed output from the
374
+ * {@link TransformResultProvider}, finds the first non-import usage of
375
+ * an imported binding, and maps back to original source via sourcemap.
376
+ */
377
+ export async function findPostCompileUsageLocation(
378
+ provider: TransformResultProvider,
379
+ importerId: string,
380
+ source: string,
381
+ findPostCompileUsagePos: (
382
+ code: string,
383
+ source: string,
384
+ ) => { line: number; column0: number } | undefined,
385
+ ): Promise<Loc | undefined> {
386
+ try {
387
+ const importerFile = normalizeFilePath(importerId)
388
+ // Pass the raw importerId so the provider can look up the exact virtual
389
+ // module variant (e.g. ?tsr-split=component) before falling back to the
390
+ // base file path.
391
+ const res = provider.getTransformResult(importerId)
392
+ if (!res) return undefined
393
+ const { code, map } = res
394
+ if (typeof code !== 'string') return undefined
395
+
396
+ // Ensure we have a line index ready for any downstream mapping.
397
+ // (We don't currently need it here, but keeping it hot improves locality
398
+ // for callers that also need import-statement mapping.)
399
+ if (!res.lineIndex) {
400
+ res.lineIndex = buildLineIndex(code)
401
+ }
402
+
403
+ const pos = findPostCompileUsagePos(code, source)
404
+ if (!pos) return undefined
405
+
406
+ return await mapGeneratedToOriginal(map, pos, importerFile)
407
+ } catch {
408
+ return undefined
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Annotate each trace hop with the location of the import that created the
414
+ * edge (file:line:col). Skips steps that already have a location.
415
+ */
416
+ export async function addTraceImportLocations(
417
+ provider: TransformResultProvider,
418
+ trace: Array<{
419
+ file: string
420
+ specifier?: string
421
+ line?: number
422
+ column?: number
423
+ }>,
424
+ importLocCache: Map<
425
+ string,
426
+ { file?: string; line: number; column: number } | null
427
+ >,
428
+ ): Promise<void> {
429
+ for (const step of trace) {
430
+ if (!step.specifier) continue
431
+ if (step.line != null && step.column != null) continue
432
+ const loc = await findImportStatementLocationFromTransformed(
433
+ provider,
434
+ step.file,
435
+ step.specifier,
436
+ importLocCache,
437
+ )
438
+ if (!loc) continue
439
+ step.line = loc.line
440
+ step.column = loc.column
441
+ }
442
+ }
443
+
444
+ // ---------------------------------------------------------------------------
445
+ // Code snippet extraction (vitest-style context around a location)
446
+ // ---------------------------------------------------------------------------
447
+
448
+ export interface CodeSnippet {
449
+ /** Source lines with line numbers, e.g. `[" 6 | import { getSecret } from './secret.server'", ...]` */
450
+ lines: Array<string>
451
+ /** The highlighted line (1-indexed original line number) */
452
+ highlightLine: number
453
+ /** Clickable file:line reference */
454
+ location: string
455
+ }
456
+
457
+ /**
458
+ * Build a vitest-style code snippet showing the lines surrounding a location.
459
+ *
460
+ * Uses the `originalCode` stored in the transform result cache (extracted from
461
+ * `sourcesContent[0]` of the composed sourcemap at transform time). This is
462
+ * reliable regardless of how the sourcemap names its sources.
463
+ *
464
+ * Falls back to the transformed code only when `originalCode` is unavailable
465
+ * (e.g. a virtual module with no sourcemap).
466
+ *
467
+ * @param contextLines Number of lines to show above/below the target line (default 2).
468
+ */
469
+ export function buildCodeSnippet(
470
+ provider: TransformResultProvider,
471
+ moduleId: string,
472
+ loc: Loc,
473
+ contextLines: number = 2,
474
+ ): CodeSnippet | undefined {
475
+ try {
476
+ const importerFile = normalizeFilePath(moduleId)
477
+ // Pass the raw moduleId so the provider can look up the exact virtual
478
+ // module variant (e.g. ?tsr-split=component) before falling back to the
479
+ // base file path.
480
+ const res = provider.getTransformResult(moduleId)
481
+ if (!res) return undefined
482
+
483
+ const { code: transformedCode, originalCode } = res
484
+ if (typeof transformedCode !== 'string') return undefined
485
+
486
+ // Prefer the original source that was captured at transform time from the
487
+ // sourcemap's sourcesContent. This avoids the source-name-mismatch
488
+ // problem that plagued the old sourceContentFor()-based lookup.
489
+ const sourceCode = originalCode ?? transformedCode
490
+
491
+ const allLines = sourceCode.split(/\r?\n/)
492
+ const targetLine = loc.line // 1-indexed
493
+ const targetCol = loc.column // 1-indexed
494
+
495
+ if (targetLine < 1 || targetLine > allLines.length) return undefined
496
+
497
+ const startLine = Math.max(1, targetLine - contextLines)
498
+ const endLine = Math.min(allLines.length, targetLine + contextLines)
499
+ const gutterWidth = String(endLine).length
500
+
501
+ const sourceFile = loc.file ?? importerFile
502
+ const snippetLines: Array<string> = []
503
+ for (let i = startLine; i <= endLine; i++) {
504
+ const lineContent = allLines[i - 1] ?? ''
505
+ const lineNum = String(i).padStart(gutterWidth, ' ')
506
+ const marker = i === targetLine ? '>' : ' '
507
+ snippetLines.push(` ${marker} ${lineNum} | ${lineContent}`)
508
+
509
+ // Add column pointer on the target line
510
+ if (i === targetLine && targetCol > 0) {
511
+ const padding = ' '.repeat(targetCol - 1)
512
+ snippetLines.push(` ${' '.repeat(gutterWidth)} | ${padding}^`)
513
+ }
514
+ }
515
+
516
+ return {
517
+ lines: snippetLines,
518
+ highlightLine: targetLine,
519
+ location: `${sourceFile}:${targetLine}:${targetCol}`,
520
+ }
521
+ } catch {
522
+ return undefined
523
+ }
524
+ }