From b556abcdc39f29f9e9093826086af95834e254d5 Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Tue, 25 Aug 2026 20:26:16 +0800 Subject: [PATCH 01/19] perf(start): avoid parsing original source in Rsbuild import protection --- .../rsbuild/INTERNALS-import-protection.md | 30 +-- .../src/rsbuild/import-protection.ts | 201 ------------------ .../tests/rsbuild/import-protection.test.ts | 86 +++++++- 3 files changed, 90 insertions(+), 227 deletions(-) diff --git a/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md b/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md index d3cc8c40177..0c30be97de9 100644 --- a/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md +++ b/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md @@ -11,8 +11,6 @@ Rsbuild owns: - virtual-module transport through `VirtualModulesPlugin` - compilation-truth reporting in `processAssets` - final graph reconstruction from Rspack compilation data -- the small build-only deferred queue for file violations that can disappear - from the compiled graph Shared AST analysis, rewrite logic, source extraction, usage lookup, source locations, trace formatting, and mock code generation are described in the @@ -45,16 +43,12 @@ Per environment, Rsbuild keeps a smaller runtime state than Vite: - `resolveCache` - `seenViolations` -- `buildTransformResults` -- `deferredFileViolations` -- `deferredFileViolationKeys` -Shared state is for virtual module transport and compiler fs access: +Shared state is for virtual module transport: - `virtualModules` - `vmPlugins` - `readyVmPlugins` -- `inputFileSystems` - `pendingWrites` Notably absent compared to Vite: @@ -77,9 +71,11 @@ The transform phase is responsible for: - self-denial for forbidden files - self-denial for marker-protected files in the wrong environment - direct specifier rewrites to mock-edge modules -- build-time transformed/original source preloading for later diagnostics -- recording build-only deferred file violations when original unsafe usage may - outlive a direct compiled graph edge + +The transform treats the code it receives as authoritative. It does not read, +parse, or analyze original source. Imports removed by the Start compiler are no +longer part of this phase; imports with unsafe client/server usage remain in the +transformed code and are checked normally. ## Virtual Module Transport @@ -111,23 +107,10 @@ It reconstructs the final view of the compilation from Rspack data by: 3. reconstructing surviving specifier violations from compiled mock-edge files 4. reporting live file violations from active edges 5. reporting live marker violations from active edges plus original source -6. reporting deferred file violations only when both importer and target truly - survived compilation This is the core Rsbuild-native replacement for Vite's `generateBundle` verification plus dev pending-violation flow. -## Why The Deferred Queue Is Narrow - -Rsbuild only needs explicit build deferral for file violations whose direct edge -may disappear after compilation. - -Specifier violations are rediscovered from surviving mock-edge virtual files. -Marker violations are rediscovered from live compiled edges. - -Only file violations need extra bookkeeping when the final compiled graph can no -longer show the original denied edge directly. - ## Source And Compilation APIs The Rsbuild adapter intentionally prefers native Rspack APIs where possible. @@ -137,7 +120,6 @@ Transform-time: - `ctx.resource` - `ctx.context` - `ctx.resolve(...)` -- captured `compiler.inputFileSystem.readFile(...)` Compilation-time: diff --git a/packages/start-plugin-core/src/rsbuild/import-protection.ts b/packages/start-plugin-core/src/rsbuild/import-protection.ts index d76dbe9aa96..01265646ba1 100644 --- a/packages/start-plugin-core/src/rsbuild/import-protection.ts +++ b/packages/start-plugin-core/src/rsbuild/import-protection.ts @@ -211,18 +211,6 @@ interface PluginConfig { interface EnvRuntimeState { resolveCache: Map seenViolations: Set - buildTransformResults: Map - deferredFileViolations: Array - deferredFileViolationKeys: Set -} - -interface DeferredFileViolation { - importer: string - specifier: string - resolved: string - relativeResolved: string - pattern: string | RegExp - useOriginalLocation: boolean } interface SharedState { @@ -230,7 +218,6 @@ interface SharedState { virtualModules: Map vmPlugins: Record readyVmPlugins: Record - inputFileSystems: Record pendingWrites: Map> } @@ -313,9 +300,6 @@ function getOrCreateEnvState( env = { resolveCache: new Map(), seenViolations: new Set(), - buildTransformResults: new Map(), - deferredFileViolations: [], - deferredFileViolationKeys: new Set(), } envStates.set(envName, env) } @@ -606,19 +590,6 @@ function hasTransformResult( return cache.has(normalizePath(key)) || cache.has(normalizeFilePath(key)) } -function deferFileViolation( - envState: EnvRuntimeState, - violation: DeferredFileViolation, -): void { - const key = `${violation.importer}:${violation.specifier}:${violation.resolved}:${String(violation.pattern)}` - if (envState.deferredFileViolationKeys.has(key)) { - return - } - - envState.deferredFileViolationKeys.add(key) - envState.deferredFileViolations.push(violation) -} - function hasOriginalUnsafeUsage( result: TransformResult | undefined, source: string, @@ -640,17 +611,10 @@ async function buildTransformResultProvider(opts: { modules: Array root: string loadOriginalCode: OriginalCodeLoader - preloaded?: Map perf?: PerfCollector }): Promise { const cache = new Map() - if (opts.preloaded) { - for (const [key, result] of opts.preloaded) { - cache.set(key, result) - } - } - opts.perf?.count('processAssets.provider.modules', opts.modules.length) for (const module of opts.modules) { @@ -1085,7 +1049,6 @@ export function registerImportProtection( const perf = isPerfEnabled() ? createPerfCollector() : undefined const extensionlessResolver = new ExtensionlessAbsoluteIdResolver() const envStates = new Map() - const fileReadCache = new Map>() const shouldCheckImporterCache = new Map() const config: PluginConfig = { @@ -1126,7 +1089,6 @@ export function registerImportProtection( virtualModules: new Map(), vmPlugins: {}, readyVmPlugins: {}, - inputFileSystems: {}, pendingWrites: new Map(), } @@ -1228,7 +1190,6 @@ export function registerImportProtection( applyUserConfig() clearNormalizeFilePathCache() extensionlessResolver.clear() - fileReadCache.clear() shouldCheckImporterCache.clear() envStates.clear() if (perf) { @@ -1241,14 +1202,10 @@ export function registerImportProtection( applyUserConfig() clearNormalizeFilePathCache() extensionlessResolver.clear() - fileReadCache.clear() shouldCheckImporterCache.clear() for (const envState of envStates.values()) { envState.resolveCache.clear() - envState.buildTransformResults.clear() - envState.deferredFileViolations.length = 0 - envState.deferredFileViolationKeys.clear() } if (perf) { perf.time('onBeforeDevCompile', startedAt) @@ -1269,7 +1226,6 @@ export function registerImportProtection( rspackConfig.plugins.push(vmPlugin) rspackConfig.plugins.push({ apply(compiler: Rspack.Compiler) { - shared.inputFileSystems[envName] = compiler.inputFileSystem compiler.hooks.thisCompilation.tap( 'TanStackStartImportProtectionVirtualModulesReady', () => { @@ -1326,72 +1282,6 @@ export function registerImportProtection( } const importSources = getImportSourcesFromResult(transformResult) perf?.count('transform.importSources', importSources.length) - const transformedImportSources = new Set(importSources) - const transformInputFileSystem = shared.inputFileSystems[envName] - const loadOriginalCodeForTransform: OriginalCodeLoader = - transformInputFileSystem - ? (target) => - loadOriginalCodeFromInputFileSystem( - transformInputFileSystem, - target, - ) - : () => Promise.resolve(undefined) - const originalCodeStartedAt = perf ? performance.now() : 0 - const originalCode = - config.command === 'build' - ? await loadOriginalCode( - fileReadCache, - file, - loadOriginalCodeForTransform, - ) - : undefined - if (perf && config.command === 'build') { - perf.time('transform.originalCode.load', originalCodeStartedAt) - } - transformResult.originalCode = originalCode - const originalTransformResult = originalCode - ? getOrCreateOriginalTransformResult(transformResult) - : undefined - const buildImportSourcesStartedAt = perf ? performance.now() : 0 - const buildImportSources = originalTransformResult - ? getImportSourcesFromResult(originalTransformResult) - : [] - if (perf && originalCode) { - perf.time( - 'transform.originalImportAnalysis', - buildImportSourcesStartedAt, - ) - perf.count( - 'transform.originalImportSources', - buildImportSources.length, - ) - } - const buildTransformResult: TransformResult | undefined = - config.command === 'build' ? transformResult : undefined - - if (config.command === 'build') { - const relativeBuildFile = getImportProtectionRelativePath( - config.root, - file, - ) - addTransformResult( - envState.buildTransformResults, - file, - buildTransformResult!, - ) - addTransformResult( - envState.buildTransformResults, - relativeBuildFile, - buildTransformResult!, - ) - if (id !== file) { - addTransformResult( - envState.buildTransformResults, - id, - buildTransformResult!, - ) - } - } const hasServerOnlyMarker = importSources.some((source) => config.markerSpecifiers.serverOnly.has(source), @@ -1506,56 +1396,6 @@ export function registerImportProtection( deniedSpecifierReplacements.set(source, replacement) } - if (config.command === 'build') { - for (const source of buildImportSources) { - if (transformedImportSources.has(source)) { - continue - } - - if (matchesAny(source, matchers.specifiers)) { - continue - } - - if ( - !hasOriginalUnsafeUsage(buildTransformResult, source, envType) - ) { - continue - } - - const resolved = await resolveAgainstImporter({ - envState, - config, - ctx, - importerId: id, - source, - extensionlessResolver, - perf, - }) - - if (!resolved) { - continue - } - - const relativeResolved = getImportProtectionRelativePath( - config.root, - resolved, - ) - const buildFileMatch = checkFileDenial(relativeResolved, matchers) - if (!buildFileMatch) { - continue - } - - deferFileViolation(envState, { - importer: file, - specifier: source, - resolved, - relativeResolved, - pattern: buildFileMatch.pattern, - useOriginalLocation: true, - }) - } - } - if (deniedSpecifierReplacements.size === 0) { return ctx.code } @@ -1631,7 +1471,6 @@ export function registerImportProtection( modules: relevantModules, root: config.root, loadOriginalCode: loadOriginalCodeFromCompilation, - preloaded: envState.buildTransformResults, perf, }) if (perf) { @@ -1868,46 +1707,6 @@ export function registerImportProtection( }) } } - - for (const violation of envState.deferredFileViolations) { - const liveEdgeKey = `${normalizeFilePath(violation.importer)}::${violation.specifier}::${normalizeFilePath(violation.resolved)}` - if (liveFileEdgeKeys.has(liveEdgeKey)) { - continue - } - - if (!didModuleSurvive(violation.resolved)) { - continue - } - - if (!didModuleSurvive(violation.importer)) { - continue - } - - const info = await buildViolationInfo({ - config, - provider, - graph, - importLocCache, - perf, - envName, - envType, - importer: violation.importer, - source: violation.specifier, - resolved: violation.resolved, - type: 'file', - pattern: violation.pattern, - preferOriginalCode: violation.useOriginalLocation, - }) - - await reportViolation({ - config, - envState, - compilation: context.compilation, - rspack: context.compiler.rspack, - perf, - info, - }) - } } finally { if (perf) { perf.time('processAssets', startedAt) diff --git a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts index 5048d395491..884d9ca1412 100644 --- a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts @@ -1,6 +1,9 @@ -import { describe, expect, test } from 'vitest' +import { describe, expect, test, vi } from 'vitest' import { compileMatchers } from '../../src/import-protection/matchers' -import { getRsbuildResolvedImportProtectionCheck } from '../../src/rsbuild/import-protection' +import { + getRsbuildResolvedImportProtectionCheck, + registerImportProtection, +} from '../../src/rsbuild/import-protection' describe('getRsbuildResolvedImportProtectionCheck', () => { test('skips file and marker checks for excluded resolved files', () => { @@ -49,3 +52,82 @@ describe('getRsbuildResolvedImportProtectionCheck', () => { ).toEqual({ type: 'marker' }) }) }) + +describe('registerImportProtection transform', () => { + test('does not read original source during the post transform', async () => { + let beforeBuild: (() => void) | undefined + let modifyRspackConfig: ((config: any, utils: any) => void) | undefined + let transformHandler: ((context: any) => Promise) | undefined + + const api = { + context: { action: 'build' }, + onBeforeBuild(handler: () => void) { + beforeBuild = handler + }, + onBeforeDevCompile() {}, + modifyRspackConfig(handler: (config: any, utils: any) => void) { + modifyRspackConfig = handler + }, + transform( + _options: unknown, + handler: (context: any) => Promise, + ) { + transformHandler = handler + }, + processAssets() {}, + } + + registerImportProtection(api as any, { + framework: 'react', + environments: [{ name: 'client', type: 'client' }], + getConfig: () => + ({ + startConfig: {}, + resolvedStartConfig: { + root: '/app', + srcDirectory: '/app/src', + }, + }) as any, + }) + + if (!beforeBuild || !modifyRspackConfig || !transformHandler) { + throw new Error('Expected import-protection hooks to be registered') + } + + beforeBuild() + + class VirtualModulesPlugin {} + const rspackConfig = { plugins: [] as Array } + modifyRspackConfig(rspackConfig, { + environment: { name: 'client' }, + rspack: { + experiments: { VirtualModulesPlugin }, + }, + }) + + const readFile = vi.fn( + (_file: string, callback: (error: null, data: Buffer) => void) => { + callback(null, Buffer.from(`import { secret } from './secret.server'`)) + }, + ) + const rspackPlugin = rspackConfig.plugins[1] + rspackPlugin.apply({ + inputFileSystem: { readFile }, + hooks: { + thisCompilation: { tap: vi.fn() }, + }, + }) + + const code = `import { value } from './safe'\nexport { value }` + const result = await transformHandler({ + code, + resource: '/app/src/entry.ts', + resourcePath: '/app/src/entry.ts', + context: '/app/src', + resolve: vi.fn(), + }) + + expect(result).toBe(code) + expect(readFile).not.toHaveBeenCalled() + }) +}) From 0afe53da7e43610d2dd59ea2a2ca4fd17a749368 Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Tue, 25 Aug 2026 20:50:38 +0800 Subject: [PATCH 02/19] perf(start): avoid filesystem reads in Rsbuild import protection reporting --- .../rsbuild/INTERNALS-import-protection.md | 9 +- .../src/rsbuild/import-protection.ts | 132 +++++------------- .../tests/rsbuild/import-protection.test.ts | 31 +++- 3 files changed, 66 insertions(+), 106 deletions(-) diff --git a/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md b/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md index 0c30be97de9..75a4141f0a3 100644 --- a/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md +++ b/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md @@ -106,7 +106,7 @@ It reconstructs the final view of the compilation from Rspack data by: 2. rebuilding the active compilation graph from outgoing connections 3. reconstructing surviving specifier violations from compiled mock-edge files 4. reporting live file violations from active edges -5. reporting live marker violations from active edges plus original source +5. reporting live marker violations from active edges plus compilation source This is the core Rsbuild-native replacement for Vite's `generateBundle` verification plus dev pending-violation flow. @@ -127,10 +127,6 @@ Compilation-time: - `module.resourceResolveData?.resource` - `module.originalSource().sourceAndMap()` - sourcemap `sourcesContent` -- `compilation.inputFileSystem.readFile(...)` - -This keeps the adapter closer to Rsbuild/Rspack truth and avoids falling back to -Node fs when the compilation already has the needed data. ## Marker Handling @@ -138,7 +134,8 @@ Unlike Vite, Rsbuild does not introduce plugin-owned virtual marker modules for normal operation. The real package marker files are used as source-level markers, and the adapter -later infers marker kind from original source while reporting compiled edges. +later infers marker kind from the compiled module source (preferring embedded +`sourcesContent` when available) while reporting compiled edges. ## Practical Maintainer Rule diff --git a/packages/start-plugin-core/src/rsbuild/import-protection.ts b/packages/start-plugin-core/src/rsbuild/import-protection.ts index 01265646ba1..fa27a2f9822 100644 --- a/packages/start-plugin-core/src/rsbuild/import-protection.ts +++ b/packages/start-plugin-core/src/rsbuild/import-protection.ts @@ -96,7 +96,6 @@ type RspackModuleGraphConnection = { dependency?: unknown getActiveState?: (runtime: string | Array | undefined) => unknown } -type OriginalCodeLoader = (file: string) => Promise const importSpecifierLocationIndex = createImportSpecifierLocationIndex() type PerfTiming = { @@ -442,36 +441,6 @@ function getMockEdgePayloadFromFile( } } -async function loadOriginalCode( - cache: Map>, - file: string, - loader: OriginalCodeLoader, -): Promise { - let result = cache.get(file) - if (!result) { - result = loader(file) - cache.set(file, result) - } - - return result -} - -async function loadOriginalCodeFromInputFileSystem( - inputFileSystem: NonNullable, - file: string, -): Promise { - return new Promise((resolve) => { - inputFileSystem.readFile(file, (error, data) => { - if (error || data == null) { - resolve(undefined) - return - } - - resolve(typeof data === 'string' ? data : data.toString('utf8')) - }) - }) -} - async function resolveAgainstImporter(opts: { envState: EnvRuntimeState config: PluginConfig @@ -607,12 +576,11 @@ function hasOriginalUnsafeUsage( return !!findOriginalUnsafeUsagePosFromResult(originalResult, source, envType) } -async function buildTransformResultProvider(opts: { +function buildTransformResultProvider(opts: { modules: Array root: string - loadOriginalCode: OriginalCodeLoader perf?: PerfCollector -}): Promise { +}): TransformResultProvider { const cache = new Map() opts.perf?.count('processAssets.provider.modules', opts.modules.length) @@ -636,11 +604,8 @@ async function buildTransformResultProvider(opts: { const originalCodeStartedAt = opts.perf ? performance.now() : 0 const originalCode = map?.sourcesContent - ? (pickOriginalCodeFromSourcesContent(map, resource ?? file, opts.root) ?? - (resource ? await opts.loadOriginalCode(resource) : undefined)) - : resource - ? await opts.loadOriginalCode(resource) - : undefined + ? pickOriginalCodeFromSourcesContent(map, resource ?? file, opts.root) + : undefined if (opts.perf) { opts.perf.time( 'processAssets.provider.originalCode', @@ -950,50 +915,44 @@ async function buildViolationInfo(opts: { return info } -async function getMarkerKindForFile(opts: { +function getMarkerKindForFile(opts: { config: PluginConfig provider: TransformResultProvider - loadOriginalCode: OriginalCodeLoader - markerKindCache: Map> + markerKindCache: Map file: string -}): Promise<'server' | 'client' | undefined> { +}): 'server' | 'client' | undefined { if (!isImportProtectionSourceFile(opts.file)) { return undefined } - let cached = opts.markerKindCache.get(opts.file) - if (!cached) { - cached = (async () => { - const code = - opts.provider.getTransformResult(opts.file)?.originalCode ?? - (await opts.loadOriginalCode(opts.file)) - - if (!code) { - return undefined - } - - const imports = getImportSources(code, opts.file) - const hasServerOnly = imports.some((source) => - opts.config.markerSpecifiers.serverOnly.has(source), - ) - const hasClientOnly = imports.some((source) => - opts.config.markerSpecifiers.clientOnly.has(source), - ) - - if (hasServerOnly && !hasClientOnly) { - return 'server' - } - - if (hasClientOnly && !hasServerOnly) { - return 'client' - } + const cached = opts.markerKindCache.get(opts.file) + if (cached !== undefined) { + return cached ?? undefined + } - return undefined - })() - opts.markerKindCache.set(opts.file, cached) + const result = opts.provider.getTransformResult(opts.file) + const code = result?.originalCode ?? result?.code + if (!code) { + opts.markerKindCache.set(opts.file, null) + return undefined } - return cached + const imports = getImportSources(code, opts.file) + const hasServerOnly = imports.some((source) => + opts.config.markerSpecifiers.serverOnly.has(source), + ) + const hasClientOnly = imports.some((source) => + opts.config.markerSpecifiers.clientOnly.has(source), + ) + const markerKind = + hasServerOnly && !hasClientOnly + ? 'server' + : hasClientOnly && !hasServerOnly + ? 'client' + : null + + opts.markerKindCache.set(opts.file, markerKind) + return markerKind ?? undefined } async function reportViolation(opts: { @@ -1443,22 +1402,6 @@ export function registerImportProtection( const envType = getImportProtectionEnvType(config, envName) const envState = getOrCreateEnvState(envStates, envName) const matchers = getRulesForEnvironment(config, envName) - const processFileReadCache = new Map< - string, - Promise - >() - const loadOriginalCodeFromCompilation: OriginalCodeLoader = (file) => - loadOriginalCode( - processFileReadCache, - file, - context.compilation.inputFileSystem - ? (target) => - loadOriginalCodeFromInputFileSystem( - context.compilation.inputFileSystem!, - target, - ) - : () => Promise.resolve(undefined), - ) const allModules = Array.from(context.compilation.modules) const relevantModules = allModules.filter( isImportProtectionSourceModule, @@ -1467,20 +1410,16 @@ export function registerImportProtection( perf?.count('processAssets.modules.relevant', relevantModules.length) const providerStartedAt = perf ? performance.now() : 0 - const provider = await buildTransformResultProvider({ + const provider = buildTransformResultProvider({ modules: relevantModules, root: config.root, - loadOriginalCode: loadOriginalCodeFromCompilation, perf, }) if (perf) { perf.time('processAssets.provider.build', providerStartedAt) } const importLocCache = new ImportLocCache() - const markerKindCache = new Map< - string, - Promise<'server' | 'client' | undefined> - >() + const markerKindCache = new Map() const graphStartedAt = perf ? performance.now() : 0 const { graph, edges, inactiveEdges } = buildCompilationGraph({ compilation: context.compilation, @@ -1602,10 +1541,9 @@ export function registerImportProtection( continue } - const markerKind = await getMarkerKindForFile({ + const markerKind = getMarkerKindForFile({ config, provider, - loadOriginalCode: loadOriginalCodeFromCompilation, markerKindCache, file: edge.resolved, }) diff --git a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts index 884d9ca1412..7efbccade1e 100644 --- a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts @@ -54,10 +54,11 @@ describe('getRsbuildResolvedImportProtectionCheck', () => { }) describe('registerImportProtection transform', () => { - test('does not read original source during the post transform', async () => { + test('does not read original source through the filesystem', async () => { let beforeBuild: (() => void) | undefined let modifyRspackConfig: ((config: any, utils: any) => void) | undefined let transformHandler: ((context: any) => Promise) | undefined + let processAssetsHandler: ((context: any) => Promise) | undefined const api = { context: { action: 'build' }, @@ -74,7 +75,12 @@ describe('registerImportProtection transform', () => { ) { transformHandler = handler }, - processAssets() {}, + processAssets( + _options: unknown, + handler: (context: any) => Promise, + ) { + processAssetsHandler = handler + }, } registerImportProtection(api as any, { @@ -90,7 +96,12 @@ describe('registerImportProtection transform', () => { }) as any, }) - if (!beforeBuild || !modifyRspackConfig || !transformHandler) { + if ( + !beforeBuild || + !modifyRspackConfig || + !transformHandler || + !processAssetsHandler + ) { throw new Error('Expected import-protection hooks to be registered') } @@ -128,6 +139,20 @@ describe('registerImportProtection transform', () => { }) expect(result).toBe(code) + + await processAssetsHandler({ + environment: { name: 'client' }, + compilation: { + modules: new Set(), + entries: new Map(), + moduleGraph: {}, + inputFileSystem: { readFile }, + errors: [], + warnings: [], + }, + compiler: { rspack: {} }, + }) + expect(readFile).not.toHaveBeenCalled() }) }) From 44d4b501ebac9bf662f3039329742112f91561d7 Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Tue, 25 Aug 2026 20:58:03 +0800 Subject: [PATCH 03/19] perf(start): remove redundant module survival checks --- .../src/rsbuild/import-protection.ts | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/packages/start-plugin-core/src/rsbuild/import-protection.ts b/packages/start-plugin-core/src/rsbuild/import-protection.ts index fa27a2f9822..aca3aca0c12 100644 --- a/packages/start-plugin-core/src/rsbuild/import-protection.ts +++ b/packages/start-plugin-core/src/rsbuild/import-protection.ts @@ -49,7 +49,6 @@ import { loadSilentMockModule, } from '../import-protection/virtualModules' import { - buildResolutionCandidates, buildSourceCandidates, canonicalizeResolvedId, checkFileDenial, @@ -1438,25 +1437,6 @@ export function registerImportProtection( `${normalizeFilePath(edge.importer)}::${edge.specifier!}::${normalizeFilePath(edge.resolved)}`, ), ) - const candidateCache = new Map>() - const getCandidates = (id: string) => { - const normalized = normalizeFilePath(id) - let candidates = candidateCache.get(normalized) - if (!candidates) { - candidates = buildResolutionCandidates(normalized) - candidateCache.set(normalized, candidates) - } - return candidates - } - const survivingModules = new Set() - for (const module of relevantModules) { - for (const candidate of getCandidates(getModuleFile(module))) { - survivingModules.add(candidate) - } - } - - const didModuleSurvive = (id: string): boolean => - getCandidates(id).some((candidate) => survivingModules.has(candidate)) for (const module of relevantModules) { const payload = getMockEdgePayloadFromFile(getModuleFile(module)) @@ -1596,12 +1576,6 @@ export function registerImportProtection( continue } seenInactiveFileEdgeKeys.add(liveEdgeKey) - if (!didModuleSurvive(edge.resolved)) { - continue - } - if (!didModuleSurvive(edge.importer)) { - continue - } const transformResult = provider.getTransformResult(edge.importer) if ( From 5f04bc2e392c913d5026adf3adcf88e9346ea8c2 Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Tue, 25 Aug 2026 21:59:26 +0800 Subject: [PATCH 04/19] perf(start): ignore inactive Rspack import connections --- .../src/rsbuild/import-protection.ts | 96 +------------------ .../tests/rsbuild/import-protection.test.ts | 95 ++++++++++++++++++ 2 files changed, 97 insertions(+), 94 deletions(-) diff --git a/packages/start-plugin-core/src/rsbuild/import-protection.ts b/packages/start-plugin-core/src/rsbuild/import-protection.ts index aca3aca0c12..16e134d3eb6 100644 --- a/packages/start-plugin-core/src/rsbuild/import-protection.ts +++ b/packages/start-plugin-core/src/rsbuild/import-protection.ts @@ -15,7 +15,6 @@ import { shouldCheckImportProtectionImporter, } from '../import-protection/adapterUtils' import { - findOriginalUnsafeUsagePosFromResult, getImportSources, getImportSourcesFromResult, getMockExportNamesBySourceFromResult, @@ -558,23 +557,6 @@ function hasTransformResult( return cache.has(normalizePath(key)) || cache.has(normalizeFilePath(key)) } -function hasOriginalUnsafeUsage( - result: TransformResult | undefined, - source: string, - envType: 'client' | 'server', -): boolean { - if (!result) { - return false - } - - const originalResult = getOrCreateOriginalTransformResult(result) - if (!originalResult) { - return false - } - - return !!findOriginalUnsafeUsagePosFromResult(originalResult, source, envType) -} - function buildTransformResultProvider(opts: { modules: Array root: string @@ -661,11 +643,9 @@ function buildCompilationGraph(opts: { }): { graph: ImportGraph edges: Array - inactiveEdges: Array } { const graph = new ImportGraph() const edges: Array = [] - const inactiveEdges: Array = [] addEntryModulesToGraph({ compilation: opts.compilation, @@ -691,13 +671,11 @@ function buildCompilationGraph(opts: { if (isActiveConnection(connection)) { graph.addEdge(resolved, importer, specifier) edges.push({ importer, specifier, resolved }) - } else { - inactiveEdges.push({ importer, specifier, resolved }) } } } - return { graph, edges, inactiveEdges } + return { graph, edges } } function isActiveConnection(connection: RspackModuleGraphConnection): boolean { @@ -1420,23 +1398,14 @@ export function registerImportProtection( const importLocCache = new ImportLocCache() const markerKindCache = new Map() const graphStartedAt = perf ? performance.now() : 0 - const { graph, edges, inactiveEdges } = buildCompilationGraph({ + const { graph, edges } = buildCompilationGraph({ compilation: context.compilation, modules: relevantModules, }) if (perf) { perf.time('processAssets.graph.build', graphStartedAt) perf.count('processAssets.graph.edges', edges.length) - perf.count('processAssets.graph.inactiveEdges', inactiveEdges.length) } - const liveFileEdgeKeys = new Set( - edges - .filter((edge) => !!edge.specifier) - .map( - (edge) => - `${normalizeFilePath(edge.importer)}::${edge.specifier!}::${normalizeFilePath(edge.resolved)}`, - ), - ) for (const module of relevantModules) { const payload = getMockEdgePayloadFromFile(getModuleFile(module)) @@ -1558,67 +1527,6 @@ export function registerImportProtection( info, }) } - - if (config.command === 'build') { - const seenInactiveFileEdgeKeys = new Set() - for (const edge of inactiveEdges) { - if (!edge.specifier) { - continue - } - if (!shouldCheckImporter(edge.importer)) { - continue - } - const liveEdgeKey = `${normalizeFilePath(edge.importer)}::${edge.specifier}::${normalizeFilePath(edge.resolved)}` - if (liveFileEdgeKeys.has(liveEdgeKey)) { - continue - } - if (seenInactiveFileEdgeKeys.has(liveEdgeKey)) { - continue - } - seenInactiveFileEdgeKeys.add(liveEdgeKey) - - const transformResult = provider.getTransformResult(edge.importer) - if ( - !hasOriginalUnsafeUsage(transformResult, edge.specifier, envType) - ) { - continue - } - - const relativeResolved = getImportProtectionRelativePath( - config.root, - edge.resolved, - ) - const fileMatch = checkFileDenial(relativeResolved, matchers) - if (!fileMatch) { - continue - } - - const info = await buildViolationInfo({ - config, - provider, - graph, - importLocCache, - perf, - envName, - envType, - importer: edge.importer, - source: edge.specifier, - resolved: edge.resolved, - type: 'file', - pattern: fileMatch.pattern, - preferOriginalCode: true, - }) - - await reportViolation({ - config, - envState, - compilation: context.compilation, - rspack: context.compiler.rspack, - perf, - info, - }) - } - } } finally { if (perf) { perf.time('processAssets', startedAt) diff --git a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts index 7efbccade1e..d788816970a 100644 --- a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts @@ -155,4 +155,99 @@ describe('registerImportProtection transform', () => { expect(readFile).not.toHaveBeenCalled() }) + + test('does not report inactive compilation connections', async () => { + let beforeBuild: (() => void) | undefined + let processAssetsHandler: ((context: any) => Promise) | undefined + const onViolation = vi.fn(() => false) + + const api = { + context: { action: 'build' }, + onBeforeBuild(handler: () => void) { + beforeBuild = handler + }, + onBeforeDevCompile() {}, + modifyRspackConfig() {}, + transform() {}, + processAssets( + _options: unknown, + handler: (context: any) => Promise, + ) { + processAssetsHandler = handler + }, + } + + registerImportProtection(api as any, { + framework: 'react', + environments: [{ name: 'client', type: 'client' }], + getConfig: () => + ({ + startConfig: { + importProtection: { onViolation }, + }, + resolvedStartConfig: { + root: '/app', + srcDirectory: '/app/src', + }, + }) as any, + }) + + if (!beforeBuild || !processAssetsHandler) { + throw new Error('Expected import-protection hooks to be registered') + } + beforeBuild() + + const createModule = ( + resource: string, + code: string, + originalCode: string = code, + ) => ({ + nameForCondition: () => resource, + identifier: () => resource, + originalSource: () => ({ + sourceAndMap: () => ({ + source: code, + map: { + version: 3, + names: [], + sources: [resource], + sourcesContent: [originalCode], + mappings: '', + }, + }), + }), + }) + const importer = createModule( + '/app/src/entry.ts', + `export function leak() { return undefined }`, + `import { secret } from './secret.server'\nexport function leak() { return secret }`, + ) + const target = createModule( + '/app/src/secret.server.ts', + `export const secret = 'secret'`, + ) + const inactiveConnection = { + module: target, + dependency: { request: './secret.server' }, + getActiveState: () => false, + } + + await processAssetsHandler({ + environment: { name: 'client' }, + compilation: { + modules: new Set([importer, target]), + entries: new Map(), + moduleGraph: { + getOutgoingConnectionsInOrder(module: unknown) { + return module === importer ? [inactiveConnection] : [] + }, + }, + errors: [], + warnings: [], + }, + compiler: { rspack: {} }, + }) + + expect(onViolation).not.toHaveBeenCalled() + }) }) From e2dc01eb41f72478bf5fd493825209c9c691876c Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Wed, 26 Aug 2026 16:50:24 +0800 Subject: [PATCH 05/19] perf(start): lazily build Rsbuild import protection diagnostics --- .../rsbuild/INTERNALS-import-protection.md | 68 +- .../src/rsbuild/import-protection.ts | 976 +++++++++++------- 2 files changed, 652 insertions(+), 392 deletions(-) diff --git a/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md b/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md index 75a4141f0a3..c25cdca3465 100644 --- a/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md +++ b/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md @@ -12,9 +12,9 @@ Rsbuild owns: - compilation-truth reporting in `processAssets` - final graph reconstruction from Rspack compilation data -Shared AST analysis, rewrite logic, source extraction, usage lookup, source -locations, trace formatting, and mock code generation are described in the -shared internals doc. +Shared transform-time AST analysis, rewrite logic, source extraction, usage +lookup, source locations, trace formatting, and mock code generation are +described in the shared internals doc. ## Mental Model @@ -102,11 +102,47 @@ adapter queues them and flushes during compilation setup. It reconstructs the final view of the compilation from Rspack data by: -1. building a `TransformResultProvider` from `compilation.modules` -2. rebuilding the active compilation graph from outgoing connections -3. reconstructing surviving specifier violations from compiled mock-edge files -4. reporting live file violations from active edges -5. reporting live marker violations from active edges plus compilation source +1. collecting every module's active outgoing connections into + `RspackModuleGraphNode[]`, while a separate visitor classifies each node as + soon as it is created +2. finishing marker fallback checks after all module specifiers are known +3. returning immediately when collection produces no candidates +4. building the `ImportGraph` and diagnostic indexes only for confirmed + candidates + +Each `RspackModuleGraphNode` contains only a module and its active +`{ dependency, module }` imports. For multiple active connections to the same +target `Module`, collection keeps only the first connection in Rspack's outgoing +order. Collection does not filter by source-file eligibility, because every +intermediate module is required to preserve complete entry-to-violation traces. +The classification visitor applies source-file and rule eligibility separately; +it does not traverse the node array afterward. Marker fallback retains only +pending imports until every eligible node's specifier set is available. Module +identity keeps query, layer, and other same-resource variants distinct. +Normalized file paths remain the user-facing identity for rules, traces, source +mapping, and diagnostics. + +When at least one candidate exists, the adapter replays the in-memory node array +to build `ImportGraph`; it never calls +`getOutgoingConnectionsInOrder(module)` a second time. A successful compilation +therefore avoids allocating `ImportGraph`, entry data, and path-based trace +indexes entirely. + +`processAssets` does not parse module source. Import requests come from +the retained `connection.dependency.request`. Diagnostic locations come from +that dependency's `loc`, then map through the compiled module sourcemap. The +adapter does not distinguish import and usage locations. When Rspack does not +expose a dependency location, the diagnostic remains valid but may omit its +source location and snippet. + +When `sourceAndMap()` does not provide a sourcemap, generated dependency +locations are not reported as original source locations. Importer and trace +locations, along with the source snippet, are omitted in that case. + +`module.originalSource()` plus `sourceAndMap()` are called only for modules +required to build a confirmed violation. A compilation with no violations +therefore does not read dependency locations, module sources, or compilation +entries. This is the core Rsbuild-native replacement for Vite's `generateBundle` verification plus dev pending-violation flow. @@ -123,19 +159,25 @@ Transform-time: Compilation-time: -- `module.nameForCondition?.()` - `module.resourceResolveData?.resource` -- `module.originalSource().sourceAndMap()` +- `module.identifier()` (normalized fallback) +- `module.originalSource().sourceAndMap()` (confirmed diagnostics only) - sourcemap `sourcesContent` +- `moduleGraph.getOutgoingConnectionsInOrder(module)` +- `connection.dependency.request` +- `connection.dependency.loc` (confirmed diagnostics only) + +Diagnostics use the retained first connection's dependency location and map it +back through the composed compilation sourcemap. ## Marker Handling Unlike Vite, Rsbuild does not introduce plugin-owned virtual marker modules for normal operation. -The real package marker files are used as source-level markers, and the adapter -later infers marker kind from the compiled module source (preferring embedded -`sourcesContent` when available) while reporting compiled edges. +The real package marker files are used as source-level markers. The adapter +derives marker kind exclusively from dependency requests in the final module +graph. ## Practical Maintainer Rule diff --git a/packages/start-plugin-core/src/rsbuild/import-protection.ts b/packages/start-plugin-core/src/rsbuild/import-protection.ts index 16e134d3eb6..aaf17bf5ccc 100644 --- a/packages/start-plugin-core/src/rsbuild/import-protection.ts +++ b/packages/start-plugin-core/src/rsbuild/import-protection.ts @@ -1,6 +1,8 @@ import { writeFileSync } from 'node:fs' import { extname, resolve as resolvePath } from 'node:path' +import { SourceMapConsumer } from 'source-map' + import { getDefaultImportProtectionRules, getMarkerSpecifiers, @@ -15,23 +17,13 @@ import { shouldCheckImportProtectionImporter, } from '../import-protection/adapterUtils' import { - getImportSources, getImportSourcesFromResult, getMockExportNamesBySourceFromResult, getNamedExportsFromResult, } from '../import-protection/analysis' import { rewriteDeniedImports } from '../import-protection/rewrite' import { - ImportLocCache, - addTraceImportLocations, buildCodeSnippet, - buildLineIndex, - createImportSpecifierLocationIndex, - findImportStatementLocationFromTransformed, - findOriginalUsageLocation, - findPostCompileUsageLocation, - getOrCreateOriginalTransformResult, - indexToLineColumn, normalizeSourceMap, pickOriginalCodeFromSourcesContent, } from '../import-protection/sourceLocation' @@ -48,7 +40,6 @@ import { loadSilentMockModule, } from '../import-protection/virtualModules' import { - buildSourceCandidates, canonicalizeResolvedId, checkFileDenial, clearNormalizeFilePathCache, @@ -67,7 +58,6 @@ import type { FileMatchers } from '../import-protection/utils' import type { SourceMapLike, TransformResult, - TransformResultProvider, } from '../import-protection/sourceLocation' import type { Loc, TraceStep, ViolationInfo } from '../import-protection/trace' import type { CompileStartFrameworkOptions, GetConfigFn } from '../types' @@ -76,6 +66,7 @@ import type { Rspack, rspack as rspackNamespaceType, } from '@rsbuild/core' +import type { RawSourceMap } from 'source-map' type RspackNamespace = typeof rspackNamespaceType type RspackVirtualModulesPlugin = InstanceType< @@ -89,12 +80,7 @@ type TransformContext = Parameters< >[0] type RspackCompilation = Rspack.Compilation type RspackModule = Rspack.Module -type RspackModuleGraphConnection = { - module?: RspackModule | null - dependency?: unknown - getActiveState?: (runtime: string | Array | undefined) => unknown -} -const importSpecifierLocationIndex = createImportSpecifierLocationIndex() +type RspackDependency = Rspack.Dependency type PerfTiming = { count: number @@ -219,9 +205,40 @@ interface SharedState { } interface CompilationEdge { - importer: string + dependency: RspackDependency + importerModule: RspackModule specifier?: string resolved: string + resolvedModule: RspackModule +} + +interface CompilationEdgeIndex { + edges: Array + edgeByKey: Map + edgesByModules: Map> +} + +interface CompilationImportGraph { + importGraph: ImportGraph + edgeIndex: CompilationEdgeIndex +} + +interface CompilationTransformResultProvider { + getTransformResult: ( + module: RspackModule, + ) => TransformResult | undefined +} + +// An identity-only snapshot of one module's active compilation connections. +// Derived paths, requests, locations, and diagnostic indexes live elsewhere. +interface CompilationImport { + dependency: RspackDependency + module: RspackModule +} + +interface RspackModuleGraphNode { + module: RspackModule + imports: Array } interface MockEdgePayload { @@ -237,6 +254,30 @@ interface MockEdgePayload { } } +interface ModuleGraphEdge { + dependency: RspackDependency + importer: RspackModule + module: RspackModule +} + +type CompilationViolationCandidate = + | { + type: 'specifier' + payload: MockEdgePayload + edge: ModuleGraphEdge + } + | { + type: 'file' + edge: ModuleGraphEdge + source: string + pattern: string | RegExp + } + | { + type: 'marker' + edge: ModuleGraphEdge + source: string + } + type ResolvedImportProtectionCheck = | { type: 'file'; fileMatch: FileMatchers['files'][number] } | { type: 'marker' } @@ -492,26 +533,37 @@ async function resolveAgainstImporter(opts: { return canonical } -function getModuleResource(module: RspackModule): string | undefined { - const candidate = module as RspackModule & { - nameForCondition?: () => string | undefined - resourceResolveData?: { resource?: string } - resource?: string - userRequest?: string - request?: string - } +function getModuleResource(module: RspackModule): string { + const resourceResolveData = ( + module as RspackModule & { + resourceResolveData?: { resource?: string } + } + ).resourceResolveData return ( - candidate.nameForCondition() ?? - candidate.resourceResolveData?.resource ?? - candidate.resource ?? - candidate.userRequest ?? - candidate.request + resourceResolveData?.resource ?? normalizeFilePath(module.identifier()) ) } -function getModuleFile(module: RspackModule): string { - return normalizeFilePath(getModuleResource(module) ?? module.identifier()) +function getDependencyLocation( + dependency: RspackDependency, +): Loc | undefined { + const loc = dependency.loc + if (!loc || !('start' in loc)) { + return undefined + } + + const start = loc.start + const line = start.line + const column = start.column + if (typeof line !== 'number') { + return undefined + } + + return { + line, + column: typeof column === 'number' ? column : 1, + } } const IMPORT_PROTECTION_PARSEABLE_EXTENSIONS = new Set([ @@ -537,268 +589,470 @@ function isImportProtectionSourceFile(file: string | undefined): boolean { ) } -function isImportProtectionSourceModule(module: RspackModule): boolean { - return isImportProtectionSourceFile(getModuleResource(module)) -} - -function addTransformResult( - cache: Map, - key: string, - result: TransformResult, -): void { - cache.set(normalizePath(key), result) - cache.set(normalizeFilePath(key), result) -} - -function hasTransformResult( - cache: Map, - key: string, -): boolean { - return cache.has(normalizePath(key)) || cache.has(normalizeFilePath(key)) -} - function buildTransformResultProvider(opts: { - modules: Array root: string perf?: PerfCollector -}): TransformResultProvider { - const cache = new Map() - - opts.perf?.count('processAssets.provider.modules', opts.modules.length) - - for (const module of opts.modules) { - const source = module.originalSource() - if (!source) continue +}): CompilationTransformResultProvider { + const resultByModule = new WeakMap() + const missingSource = new WeakSet() - const sourceAndMapStartedAt = opts.perf ? performance.now() : 0 - const sourceAndMap = source.sourceAndMap() - if (opts.perf) { - opts.perf.time( - 'processAssets.provider.sourceAndMap', - sourceAndMapStartedAt, - ) - } - const code = String(sourceAndMap.source) - const map = normalizeSourceMap(sourceAndMap.map as SourceMapLike | null) - const file = getModuleFile(module) - const resource = getModuleResource(module) - - const originalCodeStartedAt = opts.perf ? performance.now() : 0 - const originalCode = map?.sourcesContent - ? pickOriginalCodeFromSourcesContent(map, resource ?? file, opts.root) - : undefined - if (opts.perf) { - opts.perf.time( - 'processAssets.provider.originalCode', - originalCodeStartedAt, - ) - } + return { + getTransformResult(module) { + if (missingSource.has(module)) { + return undefined + } - const result: TransformResult = { - code, - filename: resource ?? file, - map, - originalCode, - perf: opts.perf, - } + const cached = resultByModule.get(module) + if (cached) { + return cached + } - if (!hasTransformResult(cache, file)) { - addTransformResult(cache, file, result) - } + opts.perf?.count('processAssets.provider.modulesLoaded') + const source = module.originalSource() + if (!source) { + missingSource.add(module) + return undefined + } - if (resource && !hasTransformResult(cache, resource)) { - addTransformResult(cache, resource, result) - } - } + const sourceAndMapStartedAt = opts.perf ? performance.now() : 0 + const sourceAndMap = source.sourceAndMap() + if (opts.perf) { + opts.perf.time( + 'processAssets.provider.sourceAndMap', + sourceAndMapStartedAt, + ) + } + const resource = getModuleResource(module) + const code = String(sourceAndMap.source) + const map = normalizeSourceMap(sourceAndMap.map as SourceMapLike | null) + const originalCodeStartedAt = opts.perf ? performance.now() : 0 + const originalCode = map?.sourcesContent + ? pickOriginalCodeFromSourcesContent(map, resource, opts.root) + : undefined + if (opts.perf) { + opts.perf.time( + 'processAssets.provider.originalCode', + originalCodeStartedAt, + ) + } - return { - getTransformResult(id: string) { - return cache.get(normalizePath(id)) ?? cache.get(normalizeFilePath(id)) + const result: TransformResult = { + code, + filename: resource, + map, + originalCode, + perf: opts.perf, + } + resultByModule.set(module, result) + return result }, } } -function getConnectionRequest(dependency: unknown): string | undefined { - const candidate = dependency as { request?: unknown } - return typeof candidate.request === 'string' ? candidate.request : undefined +function getCompilationEdgeKey( + importer: string, + resolved: string, + specifier: string | undefined, +): string { + return `${importer}\0${resolved}\0${specifier ?? ''}` +} + +function getCompilationModulesKey(importer: string, resolved: string): string { + return `${importer}\0${resolved}` } function addEntryModulesToGraph(opts: { compilation: RspackCompilation - graph: ImportGraph + importGraph: ImportGraph }): void { for (const entry of opts.compilation.entries.values()) { for (const dependency of entry.dependencies) { const connection = opts.compilation.moduleGraph.getConnection(dependency) const module = connection?.module - if (!module) continue - opts.graph.addEntry(getModuleFile(module)) + if (!module) { + continue + } + opts.importGraph.addEntry(getModuleResource(module)) } } } -function buildCompilationGraph(opts: { +function forEachActiveModules(opts: { compilation: RspackCompilation modules: Array -}): { - graph: ImportGraph - edges: Array -} { - const graph = new ImportGraph() - const edges: Array = [] - - addEntryModulesToGraph({ - compilation: opts.compilation, - graph, - }) + visitNode: (node: RspackModuleGraphNode) => void +}): Array { + const nodes: Array = [] for (const module of opts.modules) { - const importer = getModuleFile(module) + const imports: Array = [] + const importedModules = new WeakSet() const connections = opts.compilation.moduleGraph.getOutgoingConnectionsInOrder(module) for (const connection of connections) { - if (!connection.module) continue + const connectedModule = connection.module + if (!connectedModule) { + continue + } - // Only consider modules that are not errored - if ('error' in connection.module && connection.module.error) { + if (connection.getActiveState(undefined) !== true) { continue } - const resolved = getModuleFile(connection.module) - const specifier = getConnectionRequest(connection.dependency) + // Only consider modules that are not errored + if ('error' in connectedModule && connectedModule.error) { + continue + } - if (isActiveConnection(connection)) { - graph.addEdge(resolved, importer, specifier) - edges.push({ importer, specifier, resolved }) + if (importedModules.has(connectedModule)) { + continue } + importedModules.add(connectedModule) + + imports.push({ + dependency: connection.dependency, + module: connectedModule, + }) } + + const node = { module, imports } + nodes.push(node) + opts.visitNode(node) } - return { graph, edges } + return nodes } -function isActiveConnection(connection: RspackModuleGraphConnection): boolean { - if (typeof connection.getActiveState !== 'function') { - return true - } +interface PendingMarkerImport { + importer: RspackModule + imported: CompilationImport + source: string +} - return connection.getActiveState(undefined) === true +type FileViolationCandidate = Extract< + CompilationViolationCandidate, + { type: 'file' } +> + +interface CompilationViolationScanner { + visitNode: (node: RspackModuleGraphNode) => void + finish: () => Array } -function findImportLocationInOriginalCode( - provider: TransformResultProvider, - importer: string, - source: string, -): Loc | undefined { - const result = provider.getTransformResult(importer) - if (!result) { - return undefined - } +function createCompilationViolationScanner(opts: { + config: PluginConfig + envType: 'client' | 'server' + matchers: FileMatchers + shouldCheckImporter: (importer: string) => boolean +}): CompilationViolationScanner { + const mockCandidates: Array = [] + const regularChecks: Array = [] + const importSpecifiersByModule = new WeakMap>() + const mockPayloadByModule = new WeakMap< + RspackModule, + MockEdgePayload | null + >() + + const getMockPayload = (module: RspackModule) => { + const cached = mockPayloadByModule.get(module) + if (cached !== undefined) { + return cached ?? undefined + } - const originalResult = getOrCreateOriginalTransformResult(result) - if (!originalResult) { - return undefined + const payload = getMockEdgePayloadFromFile(getModuleResource(module)) + mockPayloadByModule.set(module, payload ?? null) + return payload } - const index = importSpecifierLocationIndex.find(originalResult, source) - if (index === -1) { - return undefined + return { + visitNode(node) { + const importer = getModuleResource(node.module) + if (!isImportProtectionSourceFile(importer)) { + return + } + + const shouldCheckImporter = opts.shouldCheckImporter(importer) + const importSpecifiers = new Set() + + for (const imported of node.imports) { + const source = imported.dependency.request + if (source) { + importSpecifiers.add(source) + } + + if (!shouldCheckImporter) { + continue + } + + const payload = getMockPayload(imported.module) + if (payload?.violation.importer === importer) { + mockCandidates.push({ + type: 'specifier', + payload, + edge: { + importer: node.module, + module: imported.module, + dependency: imported.dependency, + }, + }) + } + + if (!source) { + continue + } + + const resolved = getModuleResource(imported.module) + const relativeResolved = getImportProtectionRelativePath( + opts.config.root, + resolved, + ) + const importProtectionCheck = getRsbuildResolvedImportProtectionCheck( + relativeResolved, + opts.matchers, + ) + if (!importProtectionCheck) { + continue + } + + if (importProtectionCheck.type === 'file') { + regularChecks.push({ + type: 'file', + edge: { + importer: node.module, + module: imported.module, + dependency: imported.dependency, + }, + source, + pattern: importProtectionCheck.fileMatch.pattern, + }) + } else { + regularChecks.push({ importer: node.module, imported, source }) + } + } + + importSpecifiersByModule.set(node.module, importSpecifiers) + }, + finish() { + const candidates = [...mockCandidates] + + for (const check of regularChecks) { + if ('type' in check) { + candidates.push(check) + continue + } + + const markerKind = getMarkerKindForModule({ + config: opts.config, + importSpecifiersByModule, + module: check.imported.module, + }) + const violatesMarker = + (opts.envType === 'client' && markerKind === 'server') || + (opts.envType === 'server' && markerKind === 'client') + if (!violatesMarker) { + continue + } + + candidates.push({ + type: 'marker', + edge: { + importer: check.importer, + module: check.imported.module, + dependency: check.imported.dependency, + }, + source: check.source, + }) + } + + return candidates + }, } +} + +function buildCompilationImportGraph(opts: { + compilation: RspackCompilation + nodes: Array +}): CompilationImportGraph { + const importGraph = new ImportGraph() + const edges: Array = [] + const edgeByKey = new Map() + const edgesByModules = new Map>() - const lineIndex = - originalResult.lineIndex ?? - (originalResult.lineIndex = buildLineIndex(originalResult.code)) - const loc = indexToLineColumn(lineIndex, index) + addEntryModulesToGraph({ + compilation: opts.compilation, + importGraph, + }) + + for (const node of opts.nodes) { + const importer = getModuleResource(node.module) + for (const imported of node.imports) { + const resolved = getModuleResource(imported.module) + const specifier = imported.dependency.request + const edge = { + importerModule: node.module, + specifier, + resolved, + resolvedModule: imported.module, + dependency: imported.dependency, + } + edges.push(edge) + importGraph.addEdge(resolved, importer, specifier) + + const edgeKey = getCompilationEdgeKey(importer, resolved, specifier) + if (!edgeByKey.has(edgeKey)) { + edgeByKey.set(edgeKey, edge) + } + + const modulesKey = getCompilationModulesKey(importer, resolved) + const moduleEdges = edgesByModules.get(modulesKey) + if (moduleEdges) { + moduleEdges.push(edge) + } else { + edgesByModules.set(modulesKey, [edge]) + } + } + } return { - file: normalizeFilePath(importer), - line: loc.line, - column: loc.column, + importGraph, + edgeIndex: { + edges, + edgeByKey, + edgesByModules, + }, + } +} + +function findCompilationEdge( + edgeIndex: CompilationEdgeIndex, + importer: string, + resolved: string, + specifier?: string, +): CompilationEdge | undefined { + if (specifier) { + const exact = edgeIndex.edgeByKey.get( + getCompilationEdgeKey(importer, resolved, specifier), + ) + if (exact) { + return exact + } } + + return edgeIndex.edgesByModules.get( + getCompilationModulesKey(importer, resolved), + )?.[0] } -async function resolveImporterLocation(opts: { - provider: TransformResultProvider - importLocCache: ImportLocCache +async function mapCompilationLocation(opts: { + provider: CompilationTransformResultProvider importer: string - sourceCandidates: Iterable - preferOriginalCode?: boolean - envType?: 'client' | 'server' + importerModule: RspackModule + generatedLoc?: Loc }): Promise { - if (opts.preferOriginalCode) { - for (const candidate of opts.sourceCandidates) { - const loc = - findOriginalUsageLocation( - opts.provider, - opts.importer, - candidate, - opts.envType, - ) ?? - findImportLocationInOriginalCode( - opts.provider, - opts.importer, - candidate, - ) - if (loc) { - return loc - } - } + if (!opts.generatedLoc) { + return undefined } - for (const candidate of opts.sourceCandidates) { - const loc = - (await findPostCompileUsageLocation( - opts.provider, - opts.importer, - candidate, - )) || - (await findImportStatementLocationFromTransformed( - opts.provider, - opts.importer, - candidate, - opts.importLocCache, - importSpecifierLocationIndex.find, - )) + const map = opts.provider.getTransformResult(opts.importerModule)?.map + if (!map) { + return undefined + } - if (loc) { - return loc - } + const fallback: Loc = { + file: normalizeFilePath(opts.importer), + line: opts.generatedLoc.line, + column: opts.generatedLoc.column, + } + const consumer = await getCompilationSourceMapConsumer(map) + if (!consumer) { + return fallback } - if (!opts.preferOriginalCode) { - for (const candidate of opts.sourceCandidates) { - const loc = findImportLocationInOriginalCode( - opts.provider, - opts.importer, - candidate, - ) - if (loc) { - return loc + try { + const original = consumer.originalPositionFor({ + line: opts.generatedLoc.line, + column: Math.max(0, opts.generatedLoc.column - 1), + }) + if (original.line != null && original.column != null) { + return { + file: original.source + ? normalizeFilePath(original.source) + : fallback.file, + line: original.line, + column: original.column + 1, } } + } catch { + // Malformed sourcemap } - return undefined + return fallback +} + +const compilationSourceMapConsumerCache = new WeakMap< + object, + Promise +>() + +function getCompilationSourceMapConsumer( + map: SourceMapLike, +): Promise { + const cached = compilationSourceMapConsumerCache.get(map) + if (cached) { + return cached + } + + const consumer = (async () => { + try { + const rawMap: RawSourceMap = { + ...map, + file: map.file ?? '', + version: Number(map.version), + sourcesContent: map.sourcesContent?.map((source) => source ?? '') ?? [], + } + return await new SourceMapConsumer(rawMap) + } catch { + return null + } + })() + compilationSourceMapConsumerCache.set(map, consumer) + return consumer } async function rebuildAndAnnotateTrace(opts: { - provider: TransformResultProvider - graph: ImportGraph - importLocCache: ImportLocCache + provider: CompilationTransformResultProvider + importGraph: ImportGraph + edgeIndex: CompilationEdgeIndex importer: string specifier: string importerLoc?: Loc maxTraceDepth: number }): Promise> { - const trace = buildTrace(opts.graph, opts.importer, opts.maxTraceDepth) - - await addTraceImportLocations( - opts.provider, - trace, - opts.importLocCache, - importSpecifierLocationIndex.find, - ) + const trace = buildTrace(opts.importGraph, opts.importer, opts.maxTraceDepth) + + for (let i = 0; i < trace.length - 1; i++) { + const step = trace[i]! + const next = trace[i + 1]! + const edge = findCompilationEdge( + opts.edgeIndex, + step.file, + next.file, + step.specifier, + ) + const loc = edge + ? await mapCompilationLocation({ + provider: opts.provider, + importer: step.file, + importerModule: edge.importerModule, + generatedLoc: getDependencyLocation(edge.dependency), + }) + : undefined + if (loc) { + step.line = loc.line + step.column = loc.column + } + } if (trace.length > 0) { const last = trace[trace.length - 1]! @@ -816,34 +1070,29 @@ async function rebuildAndAnnotateTrace(opts: { async function buildViolationInfo(opts: { config: PluginConfig - provider: TransformResultProvider - graph: ImportGraph - importLocCache: ImportLocCache + provider: CompilationTransformResultProvider + importGraph: ImportGraph + edgeIndex: CompilationEdgeIndex perf?: PerfCollector envName: string envType: 'client' | 'server' importer: string + importerModule: RspackModule source: string resolved?: string + importLoc?: Loc type: 'specifier' | 'file' | 'marker' pattern?: string | RegExp - preferOriginalCode?: boolean }): Promise { const startedAt = opts.perf ? performance.now() : 0 opts.perf?.count('violations.enriched') const importerLocStartedAt = opts.perf ? performance.now() : 0 - const importerLoc = await resolveImporterLocation({ + const importerLoc = await mapCompilationLocation({ provider: opts.provider, - importLocCache: opts.importLocCache, importer: opts.importer, - sourceCandidates: buildSourceCandidates( - opts.source, - opts.resolved, - opts.config.root, - ), - preferOriginalCode: opts.preferOriginalCode, - envType: opts.envType, + importerModule: opts.importerModule, + generatedLoc: opts.importLoc, }) if (opts.perf) { opts.perf.time('violations.resolveImporterLocation', importerLocStartedAt) @@ -852,8 +1101,8 @@ async function buildViolationInfo(opts: { const traceStartedAt = opts.perf ? performance.now() : 0 const trace = await rebuildAndAnnotateTrace({ provider: opts.provider, - graph: opts.graph, - importLocCache: opts.importLocCache, + importGraph: opts.importGraph, + edgeIndex: opts.edgeIndex, importer: opts.importer, specifier: opts.source, importerLoc, @@ -865,7 +1114,14 @@ async function buildViolationInfo(opts: { const snippetStartedAt = opts.perf ? performance.now() : 0 const snippet = importerLoc - ? buildCodeSnippet(opts.provider, opts.importer, importerLoc) + ? buildCodeSnippet( + { + getTransformResult: () => + opts.provider.getTransformResult(opts.importerModule), + }, + opts.importer, + importerLoc, + ) : undefined if (opts.perf && importerLoc) { opts.perf.time('violations.snippet', snippetStartedAt) @@ -892,44 +1148,31 @@ async function buildViolationInfo(opts: { return info } -function getMarkerKindForFile(opts: { +function getMarkerKindForModule(opts: { config: PluginConfig - provider: TransformResultProvider - markerKindCache: Map - file: string + importSpecifiersByModule: WeakMap> + module: RspackModule }): 'server' | 'client' | undefined { - if (!isImportProtectionSourceFile(opts.file)) { + const file = getModuleResource(opts.module) + if (!isImportProtectionSourceFile(file)) { return undefined } - const cached = opts.markerKindCache.get(opts.file) - if (cached !== undefined) { - return cached ?? undefined + const imports = opts.importSpecifiersByModule.get(opts.module) + let hasServerOnly = false + let hasClientOnly = false + for (const source of imports ?? []) { + hasServerOnly ||= opts.config.markerSpecifiers.serverOnly.has(source) + hasClientOnly ||= opts.config.markerSpecifiers.clientOnly.has(source) } - const result = opts.provider.getTransformResult(opts.file) - const code = result?.originalCode ?? result?.code - if (!code) { - opts.markerKindCache.set(opts.file, null) - return undefined + if (hasServerOnly && !hasClientOnly) { + return 'server' } - - const imports = getImportSources(code, opts.file) - const hasServerOnly = imports.some((source) => - opts.config.markerSpecifiers.serverOnly.has(source), - ) - const hasClientOnly = imports.some((source) => - opts.config.markerSpecifiers.clientOnly.has(source), - ) - const markerKind = - hasServerOnly && !hasClientOnly - ? 'server' - : hasClientOnly && !hasServerOnly - ? 'client' - : null - - opts.markerKindCache.set(opts.file, markerKind) - return markerKind ?? undefined + if (hasClientOnly && !hasServerOnly) { + return 'client' + } + return undefined } async function reportViolation(opts: { @@ -1380,144 +1623,119 @@ export function registerImportProtection( const envState = getOrCreateEnvState(envStates, envName) const matchers = getRulesForEnvironment(config, envName) const allModules = Array.from(context.compilation.modules) - const relevantModules = allModules.filter( - isImportProtectionSourceModule, - ) perf?.count('processAssets.modules.total', allModules.length) - perf?.count('processAssets.modules.relevant', relevantModules.length) - const providerStartedAt = perf ? performance.now() : 0 - const provider = buildTransformResultProvider({ - modules: relevantModules, - root: config.root, - perf, + const violationScanner = createCompilationViolationScanner({ + config, + envType, + matchers, + shouldCheckImporter, }) - if (perf) { - perf.time('processAssets.provider.build', providerStartedAt) - } - const importLocCache = new ImportLocCache() - const markerKindCache = new Map() - const graphStartedAt = perf ? performance.now() : 0 - const { graph, edges } = buildCompilationGraph({ + const forEachStartedAt = perf ? performance.now() : 0 + const moduleGraphNodes: Array = [] + forEachActiveModules({ compilation: context.compilation, - modules: relevantModules, + modules: allModules, + visitNode(node) { + moduleGraphNodes.push(node) + violationScanner.visitNode(node) + }, }) if (perf) { - perf.time('processAssets.graph.build', graphStartedAt) - perf.count('processAssets.graph.edges', edges.length) + perf.time('processAssets.forEachActiveModules', forEachStartedAt) + perf.count('processAssets.modules.collected', moduleGraphNodes.length) + perf.count( + 'processAssets.imports.active', + moduleGraphNodes.reduce( + (total, node) => total + node.imports.length, + 0, + ), + ) } - for (const module of relevantModules) { - const payload = getMockEdgePayloadFromFile(getModuleFile(module)) - if (!payload) { - continue - } - if (!shouldCheckImporter(payload.violation.importer)) { - continue - } + const candidateStartedAt = perf ? performance.now() : 0 + const candidates = violationScanner.finish() + if (perf) { + perf.time('processAssets.candidates.finish', candidateStartedAt) + perf.count('processAssets.candidates', candidates.length) + } - const info = await buildViolationInfo({ - config, - provider, - graph, - importLocCache, - perf, - envName, - envType, - importer: payload.violation.importer, - source: payload.violation.specifier, - resolved: payload.violation.resolved, - type: 'specifier', - pattern: payload.violation.patternText, - preferOriginalCode: true, - }) + if (candidates.length === 0) { + return + } - await reportViolation({ - config, - envState, - compilation: context.compilation, - rspack: context.compiler.rspack, - perf, - info, - }) + const graphStartedAt = perf ? performance.now() : 0 + const { importGraph, edgeIndex } = buildCompilationImportGraph({ + compilation: context.compilation, + nodes: moduleGraphNodes, + }) + if (perf) { + perf.time('processAssets.importGraph.build', graphStartedAt) + perf.count('processAssets.importGraph.edges', edgeIndex.edges.length) } - for (const edge of edges) { - if (!edge.specifier) { - continue - } - if (!shouldCheckImporter(edge.importer)) { - continue + let provider: CompilationTransformResultProvider | undefined + const getProvider = () => { + if (!provider) { + const providerStartedAt = perf ? performance.now() : 0 + provider = buildTransformResultProvider({ + root: config.root, + perf, + }) + if (perf) { + perf.time('processAssets.provider.build', providerStartedAt) + } } + return provider + } - const relativeResolved = getImportProtectionRelativePath( - config.root, - edge.resolved, - ) - - const importProtectionCheck = getRsbuildResolvedImportProtectionCheck( - relativeResolved, - matchers, - ) - if (!importProtectionCheck) { - continue - } + for (const candidate of candidates) { + let info: ViolationInfo - if (importProtectionCheck.type === 'file') { - const info = await buildViolationInfo({ + if (candidate.type === 'specifier') { + const { payload } = candidate + info = await buildViolationInfo({ config, - provider, - graph, - importLocCache, + provider: getProvider(), + importGraph, + edgeIndex, perf, envName, envType, - importer: edge.importer, - source: edge.specifier, - resolved: edge.resolved, - type: 'file', - pattern: importProtectionCheck.fileMatch.pattern, + importer: payload.violation.importer, + importerModule: candidate.edge.importer, + source: payload.violation.specifier, + resolved: payload.violation.resolved, + importLoc: getDependencyLocation( + candidate.edge.dependency, + ), + type: 'specifier', + pattern: payload.violation.patternText, }) - - await reportViolation({ + } else { + const { edge, source } = candidate + const importer = getModuleResource(edge.importer) + const resolved = getModuleResource(edge.module) + info = await buildViolationInfo({ config, - envState, - compilation: context.compilation, - rspack: context.compiler.rspack, + provider: getProvider(), + importGraph, + edgeIndex, perf, - info, + envName, + envType, + importer, + importerModule: edge.importer, + source, + resolved, + importLoc: getDependencyLocation(edge.dependency), + type: candidate.type, + ...(candidate.type === 'file' + ? { pattern: candidate.pattern } + : {}), }) - continue - } - - const markerKind = getMarkerKindForFile({ - config, - provider, - markerKindCache, - file: edge.resolved, - }) - const violatesMarker = - (envType === 'client' && markerKind === 'server') || - (envType === 'server' && markerKind === 'client') - - if (!violatesMarker) { - continue } - const info = await buildViolationInfo({ - config, - provider, - graph, - importLocCache, - perf, - envName, - envType, - importer: edge.importer, - source: edge.specifier, - resolved: edge.resolved, - type: 'marker', - }) - await reportViolation({ config, envState, From fa92944f626251b68ced9bed990e5f8a4fcb3475 Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Wed, 26 Aug 2026 16:58:42 +0800 Subject: [PATCH 06/19] chore: remove test case --- .../tests/rsbuild/import-protection.test.ts | 199 ------------------ 1 file changed, 199 deletions(-) diff --git a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts index d788816970a..b3a33def093 100644 --- a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts @@ -52,202 +52,3 @@ describe('getRsbuildResolvedImportProtectionCheck', () => { ).toEqual({ type: 'marker' }) }) }) - -describe('registerImportProtection transform', () => { - test('does not read original source through the filesystem', async () => { - let beforeBuild: (() => void) | undefined - let modifyRspackConfig: ((config: any, utils: any) => void) | undefined - let transformHandler: ((context: any) => Promise) | undefined - let processAssetsHandler: ((context: any) => Promise) | undefined - - const api = { - context: { action: 'build' }, - onBeforeBuild(handler: () => void) { - beforeBuild = handler - }, - onBeforeDevCompile() {}, - modifyRspackConfig(handler: (config: any, utils: any) => void) { - modifyRspackConfig = handler - }, - transform( - _options: unknown, - handler: (context: any) => Promise, - ) { - transformHandler = handler - }, - processAssets( - _options: unknown, - handler: (context: any) => Promise, - ) { - processAssetsHandler = handler - }, - } - - registerImportProtection(api as any, { - framework: 'react', - environments: [{ name: 'client', type: 'client' }], - getConfig: () => - ({ - startConfig: {}, - resolvedStartConfig: { - root: '/app', - srcDirectory: '/app/src', - }, - }) as any, - }) - - if ( - !beforeBuild || - !modifyRspackConfig || - !transformHandler || - !processAssetsHandler - ) { - throw new Error('Expected import-protection hooks to be registered') - } - - beforeBuild() - - class VirtualModulesPlugin {} - const rspackConfig = { plugins: [] as Array } - modifyRspackConfig(rspackConfig, { - environment: { name: 'client' }, - rspack: { - experiments: { VirtualModulesPlugin }, - }, - }) - - const readFile = vi.fn( - (_file: string, callback: (error: null, data: Buffer) => void) => { - callback(null, Buffer.from(`import { secret } from './secret.server'`)) - }, - ) - const rspackPlugin = rspackConfig.plugins[1] - rspackPlugin.apply({ - inputFileSystem: { readFile }, - hooks: { - thisCompilation: { tap: vi.fn() }, - }, - }) - - const code = `import { value } from './safe'\nexport { value }` - const result = await transformHandler({ - code, - resource: '/app/src/entry.ts', - resourcePath: '/app/src/entry.ts', - context: '/app/src', - resolve: vi.fn(), - }) - - expect(result).toBe(code) - - await processAssetsHandler({ - environment: { name: 'client' }, - compilation: { - modules: new Set(), - entries: new Map(), - moduleGraph: {}, - inputFileSystem: { readFile }, - errors: [], - warnings: [], - }, - compiler: { rspack: {} }, - }) - - expect(readFile).not.toHaveBeenCalled() - }) - - test('does not report inactive compilation connections', async () => { - let beforeBuild: (() => void) | undefined - let processAssetsHandler: ((context: any) => Promise) | undefined - const onViolation = vi.fn(() => false) - - const api = { - context: { action: 'build' }, - onBeforeBuild(handler: () => void) { - beforeBuild = handler - }, - onBeforeDevCompile() {}, - modifyRspackConfig() {}, - transform() {}, - processAssets( - _options: unknown, - handler: (context: any) => Promise, - ) { - processAssetsHandler = handler - }, - } - - registerImportProtection(api as any, { - framework: 'react', - environments: [{ name: 'client', type: 'client' }], - getConfig: () => - ({ - startConfig: { - importProtection: { onViolation }, - }, - resolvedStartConfig: { - root: '/app', - srcDirectory: '/app/src', - }, - }) as any, - }) - - if (!beforeBuild || !processAssetsHandler) { - throw new Error('Expected import-protection hooks to be registered') - } - beforeBuild() - - const createModule = ( - resource: string, - code: string, - originalCode: string = code, - ) => ({ - nameForCondition: () => resource, - identifier: () => resource, - originalSource: () => ({ - sourceAndMap: () => ({ - source: code, - map: { - version: 3, - names: [], - sources: [resource], - sourcesContent: [originalCode], - mappings: '', - }, - }), - }), - }) - const importer = createModule( - '/app/src/entry.ts', - `export function leak() { return undefined }`, - `import { secret } from './secret.server'\nexport function leak() { return secret }`, - ) - const target = createModule( - '/app/src/secret.server.ts', - `export const secret = 'secret'`, - ) - const inactiveConnection = { - module: target, - dependency: { request: './secret.server' }, - getActiveState: () => false, - } - - await processAssetsHandler({ - environment: { name: 'client' }, - compilation: { - modules: new Set([importer, target]), - entries: new Map(), - moduleGraph: { - getOutgoingConnectionsInOrder(module: unknown) { - return module === importer ? [inactiveConnection] : [] - }, - }, - errors: [], - warnings: [], - }, - compiler: { rspack: {} }, - }) - - expect(onViolation).not.toHaveBeenCalled() - }) -}) From 85b822835381e8513665d1bcea74ea6dea74b897 Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Wed, 26 Aug 2026 17:05:20 +0800 Subject: [PATCH 07/19] add changeset --- .changeset/lazy-rspack-guards.md | 5 +++++ .../tests/rsbuild/import-protection.test.ts | 3 +-- 2 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 .changeset/lazy-rspack-guards.md diff --git a/.changeset/lazy-rspack-guards.md b/.changeset/lazy-rspack-guards.md new file mode 100644 index 00000000000..a1269b9a91e --- /dev/null +++ b/.changeset/lazy-rspack-guards.md @@ -0,0 +1,5 @@ +--- +'@tanstack/start-plugin-core': patch +--- + +Improve Rsbuild import protection performance by scanning the compilation graph once and deferring diagnostic work until a violation is found. diff --git a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts index b3a33def093..c751e231601 100644 --- a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts @@ -1,8 +1,7 @@ -import { describe, expect, test, vi } from 'vitest' +import { describe, expect, test } from 'vitest' import { compileMatchers } from '../../src/import-protection/matchers' import { getRsbuildResolvedImportProtectionCheck, - registerImportProtection, } from '../../src/rsbuild/import-protection' describe('getRsbuildResolvedImportProtectionCheck', () => { From 8349037805f6e2c0d320a7fd57f2f5b5f790053a Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Wed, 26 Aug 2026 17:49:30 +0800 Subject: [PATCH 08/19] fix(start): persist Rsbuild import protection markers in buildInfo --- .../rsbuild/INTERNALS-import-protection.md | 19 +++- .../src/rsbuild/import-protection.ts | 96 +++++++++++++++---- 2 files changed, 95 insertions(+), 20 deletions(-) diff --git a/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md b/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md index c25cdca3465..f43c9cf8049 100644 --- a/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md +++ b/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md @@ -44,6 +44,10 @@ Per environment, Rsbuild keeps a smaller runtime state than Vite: - `resolveCache` - `seenViolations` +A per-environment resource map associates each loader resource with its Rspack +module. It is populated by Rspack's loader hook and consumed by the matching +post-transform callback; durable marker state lives on `module.buildInfo`. + Shared state is for virtual module transport: - `virtualModules` @@ -70,6 +74,7 @@ The transform phase is responsible for: - self-denial for forbidden files - self-denial for marker-protected files in the wrong environment +- persisting detected marker kinds in Rspack `module.buildInfo` - direct specifier rewrites to mock-edge modules The transform treats the code it receives as authoritative. It does not read, @@ -105,7 +110,7 @@ It reconstructs the final view of the compilation from Rspack data by: 1. collecting every module's active outgoing connections into `RspackModuleGraphNode[]`, while a separate visitor classifies each node as soon as it is created -2. finishing marker fallback checks after all module specifiers are known +2. finishing marker checks after all modules are known 3. returning immediately when collection produces no candidates 4. building the `ImportGraph` and diagnostic indexes only for confirmed candidates @@ -175,9 +180,15 @@ back through the composed compilation sourcemap. Unlike Vite, Rsbuild does not introduce plugin-owned virtual marker modules for normal operation. -The real package marker files are used as source-level markers. The adapter -derives marker kind exclusively from dependency requests in the final module -graph. +The real package marker files are used as source-level markers. Rspack's loader +hook records the module under the exact loader resource. The matching post +transform consumes that association and writes the detected marker kind to the +module's `buildInfo` before replacing a wrong-environment module. This preserves +the marker after self-denial mocking and when Rspack restores modules from its +persistent cache. + +`processAssets` reads the persisted marker kind first. Dependency requests in +the final module graph remain a fallback for modules without metadata. ## Practical Maintainer Rule diff --git a/packages/start-plugin-core/src/rsbuild/import-protection.ts b/packages/start-plugin-core/src/rsbuild/import-protection.ts index aaf17bf5ccc..fc1f34ac287 100644 --- a/packages/start-plugin-core/src/rsbuild/import-protection.ts +++ b/packages/start-plugin-core/src/rsbuild/import-protection.ts @@ -48,7 +48,6 @@ import { isFileExcluded, normalizeFilePath, } from '../import-protection/utils' - import type { ImportProtectionBehavior, ImportProtectionOptions, @@ -82,6 +81,16 @@ type RspackCompilation = Rspack.Compilation type RspackModule = Rspack.Module type RspackDependency = Rspack.Dependency +type ImportProtectionMarkerKind = 'server' | 'client' +type ImportProtectionBuildInfo = { + markerKind: ImportProtectionMarkerKind | null +} + +const IMPORT_PROTECTION_BUILD_INFO_FIELD = 'tanstack.start.importProtection' +const EMPTY_IMPORT_PROTECTION_BUILD_INFO: ImportProtectionBuildInfo = { + markerKind: null, +} + type PerfTiming = { count: number totalMs: number @@ -202,6 +211,7 @@ interface SharedState { vmPlugins: Record readyVmPlugins: Record pendingWrites: Map> + moduleByResource: Record> } interface CompilationEdge { @@ -224,9 +234,7 @@ interface CompilationImportGraph { } interface CompilationTransformResultProvider { - getTransformResult: ( - module: RspackModule, - ) => TransformResult | undefined + getTransformResult: (module: RspackModule) => TransformResult | undefined } // An identity-only snapshot of one module's active compilation connections. @@ -540,14 +548,27 @@ function getModuleResource(module: RspackModule): string { } ).resourceResolveData - return ( - resourceResolveData?.resource ?? normalizeFilePath(module.identifier()) - ) + return resourceResolveData?.resource ?? normalizeFilePath(module.identifier()) +} + +function getMarkerKindFromBuildInfo( + module: RspackModule, +): ImportProtectionMarkerKind | undefined { + const metadata = module.buildInfo[IMPORT_PROTECTION_BUILD_INFO_FIELD] + if (!metadata || typeof metadata !== 'object') { + return undefined + } + + if (!('markerKind' in metadata)) { + return undefined + } + + return metadata.markerKind === 'server' || metadata.markerKind === 'client' + ? metadata.markerKind + : undefined } -function getDependencyLocation( - dependency: RspackDependency, -): Loc | undefined { +function getDependencyLocation(dependency: RspackDependency): Loc | undefined { const loc = dependency.loc if (!loc || !('start' in loc)) { return undefined @@ -1158,6 +1179,11 @@ function getMarkerKindForModule(opts: { return undefined } + const markerKind = getMarkerKindFromBuildInfo(opts.module) + if (markerKind) { + return markerKind + } + const imports = opts.importSpecifiersByModule.get(opts.module) let hasServerOnly = false let hasClientOnly = false @@ -1229,7 +1255,6 @@ export function registerImportProtection( const extensionlessResolver = new ExtensionlessAbsoluteIdResolver() const envStates = new Map() const shouldCheckImporterCache = new Map() - const config: PluginConfig = { enabled: true, root: '', @@ -1269,6 +1294,7 @@ export function registerImportProtection( vmPlugins: {}, readyVmPlugins: {}, pendingWrites: new Map(), + moduleByResource: {}, } function applyUserConfig(): void { @@ -1401,10 +1427,35 @@ export function registerImportProtection( shared.vmPlugins[envName] = vmPlugin shared.readyVmPlugins[envName] = false + const moduleByResource = new Map() + shared.moduleByResource[envName] = moduleByResource rspackConfig.plugins.push(vmPlugin) rspackConfig.plugins.push({ apply(compiler: Rspack.Compiler) { + compiler.hooks.compilation.tap( + 'TanStackStartImportProtectionBuildInfo', + (compilation) => { + utils.rspack.NormalModule.getCompilationHooks( + compilation, + ).loader.tap( + 'TanStackStartImportProtectionBuildInfo', + (loaderContext, module) => { + if (!isImportProtectionSourceFile(loaderContext.resourcePath)) { + return + } + + moduleByResource.set(loaderContext.resource, module) + }, + ) + }, + ) + + compiler.hooks.compile.tap( + 'TanStackStartImportProtectionModuleCleanup', + () => moduleByResource.clear(), + ) + compiler.hooks.thisCompilation.tap( 'TanStackStartImportProtectionVirtualModulesReady', () => { @@ -1432,14 +1483,23 @@ export function registerImportProtection( perf?.count(`transform.env.${environment.name}`) try { + const envName = environment.name + const id = ctx.resource + const moduleByResource = shared.moduleByResource[envName] + const module = moduleByResource?.get(id) + moduleByResource?.delete(id) + + if (module) { + module.buildInfo[IMPORT_PROTECTION_BUILD_INFO_FIELD] = + EMPTY_IMPORT_PROTECTION_BUILD_INFO + } + if (!config.enabled) { return ctx.code } - const envName = environment.name const envType = getImportProtectionEnvType(config, envName) const envState = getOrCreateEnvState(envStates, envName) - const id = ctx.resource const file = normalizeFilePath(ctx.resourcePath) if (!shouldCheckImporter(file)) { @@ -1481,6 +1541,12 @@ export function registerImportProtection( ? ('client' as const) : undefined + if (module && markerKind) { + module.buildInfo[IMPORT_PROTECTION_BUILD_INFO_FIELD] = { + markerKind, + } + } + const fileMatch = checkFileDenial(relativeFile, matchers) const markerViolation = (envType === 'client' && markerKind === 'server') || @@ -1706,9 +1772,7 @@ export function registerImportProtection( importerModule: candidate.edge.importer, source: payload.violation.specifier, resolved: payload.violation.resolved, - importLoc: getDependencyLocation( - candidate.edge.dependency, - ), + importLoc: getDependencyLocation(candidate.edge.dependency), type: 'specifier', pattern: payload.violation.patternText, }) From f44361c38699f1d21d878c2aaae9d3b410525b8f Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Wed, 26 Aug 2026 17:54:35 +0800 Subject: [PATCH 09/19] fix: normalize file path in getModuleResource --- packages/start-plugin-core/src/rsbuild/import-protection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/start-plugin-core/src/rsbuild/import-protection.ts b/packages/start-plugin-core/src/rsbuild/import-protection.ts index fc1f34ac287..fc39cdabed9 100644 --- a/packages/start-plugin-core/src/rsbuild/import-protection.ts +++ b/packages/start-plugin-core/src/rsbuild/import-protection.ts @@ -548,7 +548,7 @@ function getModuleResource(module: RspackModule): string { } ).resourceResolveData - return resourceResolveData?.resource ?? normalizeFilePath(module.identifier()) + return normalizeFilePath(resourceResolveData?.resource ?? module.identifier()) } function getMarkerKindFromBuildInfo( From eeff6b2c34fe6ad70bd4bb654f4dc8e1ac8d6bc4 Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Wed, 26 Aug 2026 18:20:18 +0800 Subject: [PATCH 10/19] fix(start): restore Rsbuild diagnostics for inactive imports --- .../src/rsbuild/import-protection.ts | 86 +++++++++++++++---- 1 file changed, 68 insertions(+), 18 deletions(-) diff --git a/packages/start-plugin-core/src/rsbuild/import-protection.ts b/packages/start-plugin-core/src/rsbuild/import-protection.ts index fc39cdabed9..a13cfaaa97e 100644 --- a/packages/start-plugin-core/src/rsbuild/import-protection.ts +++ b/packages/start-plugin-core/src/rsbuild/import-protection.ts @@ -24,6 +24,8 @@ import { import { rewriteDeniedImports } from '../import-protection/rewrite' import { buildCodeSnippet, + findOriginalUsageLocation, + findPostCompileUsageLocation, normalizeSourceMap, pickOriginalCodeFromSourcesContent, } from '../import-protection/sourceLocation' @@ -40,6 +42,7 @@ import { loadSilentMockModule, } from '../import-protection/virtualModules' import { + buildSourceCandidates, canonicalizeResolvedId, checkFileDenial, clearNormalizeFilePathCache, @@ -57,6 +60,7 @@ import type { FileMatchers } from '../import-protection/utils' import type { SourceMapLike, TransformResult, + TransformResultProvider, } from '../import-protection/sourceLocation' import type { Loc, TraceStep, ViolationInfo } from '../import-protection/trace' import type { CompileStartFrameworkOptions, GetConfigFn } from '../types' @@ -237,7 +241,7 @@ interface CompilationTransformResultProvider { getTransformResult: (module: RspackModule) => TransformResult | undefined } -// An identity-only snapshot of one module's active compilation connections. +// An identity-only snapshot of one module's compilation connections. // Derived paths, requests, locations, and diagnostic indexes live elsewhere. interface CompilationImport { dependency: RspackDependency @@ -698,7 +702,7 @@ function addEntryModulesToGraph(opts: { } } -function forEachActiveModules(opts: { +function forEachModules(opts: { compilation: RspackCompilation modules: Array visitNode: (node: RspackModuleGraphNode) => void @@ -717,10 +721,6 @@ function forEachActiveModules(opts: { continue } - if (connection.getActiveState(undefined) !== true) { - continue - } - // Only consider modules that are not errored if ('error' in connectedModule && connectedModule.error) { continue @@ -969,9 +969,9 @@ async function mapCompilationLocation(opts: { provider: CompilationTransformResultProvider importer: string importerModule: RspackModule - generatedLoc?: Loc + dependencyLoc?: Loc }): Promise { - if (!opts.generatedLoc) { + if (!opts.dependencyLoc) { return undefined } @@ -982,8 +982,8 @@ async function mapCompilationLocation(opts: { const fallback: Loc = { file: normalizeFilePath(opts.importer), - line: opts.generatedLoc.line, - column: opts.generatedLoc.column, + line: opts.dependencyLoc.line, + column: opts.dependencyLoc.column, } const consumer = await getCompilationSourceMapConsumer(map) if (!consumer) { @@ -992,8 +992,8 @@ async function mapCompilationLocation(opts: { try { const original = consumer.originalPositionFor({ - line: opts.generatedLoc.line, - column: Math.max(0, opts.generatedLoc.column - 1), + line: opts.dependencyLoc.line, + column: Math.max(0, opts.dependencyLoc.column - 1), }) if (original.line != null && original.column != null) { return { @@ -1041,6 +1041,52 @@ function getCompilationSourceMapConsumer( return consumer } +async function resolveImporterLocation(opts: { + config: PluginConfig + provider: CompilationTransformResultProvider + importer: string + importerModule: RspackModule + source: string + resolved?: string + dependencyLoc?: Loc + envType: 'client' | 'server' +}): Promise { + const dependencyLoc = await mapCompilationLocation({ + provider: opts.provider, + importer: opts.importer, + importerModule: opts.importerModule, + dependencyLoc: opts.dependencyLoc, + }) + if (dependencyLoc) { + return dependencyLoc + } + + const provider: TransformResultProvider = { + getTransformResult: () => + opts.provider.getTransformResult(opts.importerModule), + } + for (const source of buildSourceCandidates( + opts.source, + opts.resolved, + opts.config.root, + )) { + const loc = + (await findPostCompileUsageLocation(provider, opts.importer, source)) ?? + findOriginalUsageLocation( + provider, + opts.importer, + source, + opts.envType, + opts.config.root, + ) + if (loc) { + return loc + } + } + + return undefined +} + async function rebuildAndAnnotateTrace(opts: { provider: CompilationTransformResultProvider importGraph: ImportGraph @@ -1066,7 +1112,7 @@ async function rebuildAndAnnotateTrace(opts: { provider: opts.provider, importer: step.file, importerModule: edge.importerModule, - generatedLoc: getDependencyLocation(edge.dependency), + dependencyLoc: getDependencyLocation(edge.dependency), }) : undefined if (loc) { @@ -1109,11 +1155,15 @@ async function buildViolationInfo(opts: { opts.perf?.count('violations.enriched') const importerLocStartedAt = opts.perf ? performance.now() : 0 - const importerLoc = await mapCompilationLocation({ + const importerLoc = await resolveImporterLocation({ + config: opts.config, provider: opts.provider, importer: opts.importer, importerModule: opts.importerModule, - generatedLoc: opts.importLoc, + source: opts.source, + resolved: opts.resolved, + dependencyLoc: opts.importLoc, + envType: opts.envType, }) if (opts.perf) { opts.perf.time('violations.resolveImporterLocation', importerLocStartedAt) @@ -1699,7 +1749,7 @@ export function registerImportProtection( }) const forEachStartedAt = perf ? performance.now() : 0 const moduleGraphNodes: Array = [] - forEachActiveModules({ + forEachModules({ compilation: context.compilation, modules: allModules, visitNode(node) { @@ -1708,10 +1758,10 @@ export function registerImportProtection( }, }) if (perf) { - perf.time('processAssets.forEachActiveModules', forEachStartedAt) + perf.time('processAssets.forEachModules', forEachStartedAt) perf.count('processAssets.modules.collected', moduleGraphNodes.length) perf.count( - 'processAssets.imports.active', + 'processAssets.imports.collected', moduleGraphNodes.reduce( (total, node) => total + node.imports.length, 0, From 16964a084918955d2d9b4ea1f7e3b429267ba7b7 Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Wed, 26 Aug 2026 19:59:43 +0800 Subject: [PATCH 11/19] fix(start): improve Rsbuild import protection diagnostics --- .../src/rsbuild/import-protection.ts | 487 ++++++++++++------ 1 file changed, 332 insertions(+), 155 deletions(-) diff --git a/packages/start-plugin-core/src/rsbuild/import-protection.ts b/packages/start-plugin-core/src/rsbuild/import-protection.ts index a13cfaaa97e..ba1b7b2c16c 100644 --- a/packages/start-plugin-core/src/rsbuild/import-protection.ts +++ b/packages/start-plugin-core/src/rsbuild/import-protection.ts @@ -23,7 +23,10 @@ import { } from '../import-protection/analysis' import { rewriteDeniedImports } from '../import-protection/rewrite' import { + ImportLocCache, buildCodeSnippet, + createImportSpecifierLocationIndex, + findImportStatementLocationFromTransformed, findOriginalUsageLocation, findPostCompileUsageLocation, normalizeSourceMap, @@ -84,16 +87,15 @@ type TransformContext = Parameters< type RspackCompilation = Rspack.Compilation type RspackModule = Rspack.Module type RspackDependency = Rspack.Dependency +type RspackInputFileSystem = NonNullable type ImportProtectionMarkerKind = 'server' | 'client' -type ImportProtectionBuildInfo = { - markerKind: ImportProtectionMarkerKind | null +interface ImportProtectionMarker { + kind: ImportProtectionMarkerKind + source: string } const IMPORT_PROTECTION_BUILD_INFO_FIELD = 'tanstack.start.importProtection' -const EMPTY_IMPORT_PROTECTION_BUILD_INFO: ImportProtectionBuildInfo = { - markerKind: null, -} type PerfTiming = { count: number @@ -238,7 +240,9 @@ interface CompilationImportGraph { } interface CompilationTransformResultProvider { - getTransformResult: (module: RspackModule) => TransformResult | undefined + getTransformResult: ( + module: RspackModule, + ) => Promise } // An identity-only snapshot of one module's compilation connections. @@ -286,7 +290,7 @@ type CompilationViolationCandidate = } | { type: 'marker' - edge: ModuleGraphEdge + importer: RspackModule source: string } @@ -548,28 +552,25 @@ async function resolveAgainstImporter(opts: { function getModuleResource(module: RspackModule): string { const resourceResolveData = ( module as RspackModule & { - resourceResolveData?: { resource?: string } + resourceResolveData?: { path?: string; resource?: string } } ).resourceResolveData return normalizeFilePath(resourceResolveData?.resource ?? module.identifier()) } -function getMarkerKindFromBuildInfo( - module: RspackModule, -): ImportProtectionMarkerKind | undefined { - const metadata = module.buildInfo[IMPORT_PROTECTION_BUILD_INFO_FIELD] - if (!metadata || typeof metadata !== 'object') { - return undefined - } - - if (!('markerKind' in metadata)) { - return undefined - } +function getModuleResourcePath(module: RspackModule): string { + const resourceResolveData = ( + module as RspackModule & { + resourceResolveData?: { path?: string; resource?: string } + } + ).resourceResolveData - return metadata.markerKind === 'server' || metadata.markerKind === 'client' - ? metadata.markerKind - : undefined + return normalizeFilePath( + resourceResolveData?.path ?? + resourceResolveData?.resource ?? + module.identifier(), + ) } function getDependencyLocation(dependency: RspackDependency): Loc | undefined { @@ -614,31 +615,49 @@ function isImportProtectionSourceFile(file: string | undefined): boolean { ) } +function readModuleSourceFromInputFileSystem( + inputFileSystem: RspackInputFileSystem | null, + file: string, +): Promise { + if (!inputFileSystem) { + return Promise.resolve(undefined) + } + + return new Promise((resolve) => { + inputFileSystem.readFile(file, (error, data) => { + if (error || data == null) { + resolve(undefined) + return + } + + resolve(String(data)) + }) + }) +} + function buildTransformResultProvider(opts: { root: string perf?: PerfCollector + inputFileSystem: RspackInputFileSystem | null }): CompilationTransformResultProvider { const resultByModule = new WeakMap() + const loadingResultByModule = new WeakMap< + RspackModule, + Promise + >() const missingSource = new WeakSet() - return { - getTransformResult(module) { - if (missingSource.has(module)) { - return undefined - } - - const cached = resultByModule.get(module) - if (cached) { - return cached - } - - opts.perf?.count('processAssets.provider.modulesLoaded') - const source = module.originalSource() - if (!source) { - missingSource.add(module) - return undefined - } - + async function loadModuleTransformResult( + module: RspackModule, + ): Promise { + opts.perf?.count('processAssets.provider.modulesLoaded') + const resource = getModuleResource(module) + const resourcePath = getModuleResourcePath(module) + let code: string | undefined + let map: SourceMapLike | undefined + + const source = module.originalSource() + if (source) { const sourceAndMapStartedAt = opts.perf ? performance.now() : 0 const sourceAndMap = source.sourceAndMap() if (opts.perf) { @@ -647,28 +666,65 @@ function buildTransformResultProvider(opts: { sourceAndMapStartedAt, ) } - const resource = getModuleResource(module) - const code = String(sourceAndMap.source) - const map = normalizeSourceMap(sourceAndMap.map as SourceMapLike | null) - const originalCodeStartedAt = opts.perf ? performance.now() : 0 - const originalCode = map?.sourcesContent - ? pickOriginalCodeFromSourcesContent(map, resource, opts.root) - : undefined - if (opts.perf) { - opts.perf.time( - 'processAssets.provider.originalCode', - originalCodeStartedAt, - ) + code = String(sourceAndMap.source) + map = normalizeSourceMap(sourceAndMap.map as SourceMapLike | null) + } + + const originalCodeStartedAt = opts.perf ? performance.now() : 0 + let originalCode = map?.sourcesContent + ? pickOriginalCodeFromSourcesContent(map, resourcePath, opts.root) + : undefined + if (originalCode === undefined) { + originalCode = await readModuleSourceFromInputFileSystem( + opts.inputFileSystem, + resourcePath, + ) + if (originalCode !== undefined) { + opts.perf?.count('processAssets.provider.inputFileSystemReads') + } + } + if (opts.perf) { + opts.perf.time( + 'processAssets.provider.originalCode', + originalCodeStartedAt, + ) + } + + code ??= originalCode + if (code === undefined) { + missingSource.add(module) + return undefined + } + + const result: TransformResult = { + code, + filename: resource, + map, + originalCode, + perf: opts.perf, + } + resultByModule.set(module, result) + return result + } + + return { + getTransformResult(module) { + if (missingSource.has(module)) { + return Promise.resolve(undefined) + } + + const cached = resultByModule.get(module) + if (cached) { + return Promise.resolve(cached) } - const result: TransformResult = { - code, - filename: resource, - map, - originalCode, - perf: opts.perf, + const loading = loadingResultByModule.get(module) + if (loading) { + return loading } - resultByModule.set(module, result) + + const result = loadModuleTransformResult(module) + loadingResultByModule.set(module, result) return result }, } @@ -711,7 +767,7 @@ function forEachModules(opts: { for (const module of opts.modules) { const imports: Array = [] - const importedModules = new WeakSet() + const importIndexByModule = new WeakMap() const connections = opts.compilation.moduleGraph.getOutgoingConnectionsInOrder(module) @@ -726,10 +782,22 @@ function forEachModules(opts: { continue } - if (importedModules.has(connectedModule)) { + const existingImportIndex = importIndexByModule.get(connectedModule) + if (existingImportIndex !== undefined) { + const existingImport = imports[existingImportIndex]! + if ( + !getDependencyLocation(existingImport.dependency) && + getDependencyLocation(connection.dependency) + ) { + imports[existingImportIndex] = { + dependency: connection.dependency, + module: connectedModule, + } + } continue } - importedModules.add(connectedModule) + + importIndexByModule.set(connectedModule, imports.length) imports.push({ dependency: connection.dependency, @@ -745,10 +813,8 @@ function forEachModules(opts: { return nodes } -interface PendingMarkerImport { - importer: RspackModule - imported: CompilationImport - source: string +interface MarkerCheckTarget { + module: RspackModule } type FileViolationCandidate = Extract< @@ -768,8 +834,7 @@ function createCompilationViolationScanner(opts: { shouldCheckImporter: (importer: string) => boolean }): CompilationViolationScanner { const mockCandidates: Array = [] - const regularChecks: Array = [] - const importSpecifiersByModule = new WeakMap>() + const regularChecks: Array = [] const mockPayloadByModule = new WeakMap< RspackModule, MockEdgePayload | null @@ -794,29 +859,23 @@ function createCompilationViolationScanner(opts: { } const shouldCheckImporter = opts.shouldCheckImporter(importer) - const importSpecifiers = new Set() for (const imported of node.imports) { const source = imported.dependency.request - if (source) { - importSpecifiers.add(source) - } - - if (!shouldCheckImporter) { - continue - } - const payload = getMockPayload(imported.module) - if (payload?.violation.importer === importer) { - mockCandidates.push({ - type: 'specifier', - payload, - edge: { - importer: node.module, - module: imported.module, - dependency: imported.dependency, - }, - }) + if (shouldCheckImporter) { + const payload = getMockPayload(imported.module) + if (payload?.violation.importer === importer) { + mockCandidates.push({ + type: 'specifier', + payload, + edge: { + importer: node.module, + module: imported.module, + dependency: imported.dependency, + }, + }) + } } if (!source) { @@ -836,7 +895,12 @@ function createCompilationViolationScanner(opts: { continue } - if (importProtectionCheck.type === 'file') { + if (importProtectionCheck.type === 'marker') { + regularChecks.push({ module: imported.module }) + continue + } + + if (shouldCheckImporter) { regularChecks.push({ type: 'file', edge: { @@ -847,15 +911,12 @@ function createCompilationViolationScanner(opts: { source, pattern: importProtectionCheck.fileMatch.pattern, }) - } else { - regularChecks.push({ importer: node.module, imported, source }) } } - - importSpecifiersByModule.set(node.module, importSpecifiers) }, finish() { const candidates = [...mockCandidates] + const checkedMarkerModules = new WeakSet() for (const check of regularChecks) { if ('type' in check) { @@ -863,26 +924,27 @@ function createCompilationViolationScanner(opts: { continue } - const markerKind = getMarkerKindForModule({ - config: opts.config, - importSpecifiersByModule, - module: check.imported.module, - }) + if (checkedMarkerModules.has(check.module)) { + continue + } + checkedMarkerModules.add(check.module) + + if (!opts.shouldCheckImporter(getModuleResource(check.module))) { + continue + } + + const marker = getMarkerForModule(check.module) const violatesMarker = - (opts.envType === 'client' && markerKind === 'server') || - (opts.envType === 'server' && markerKind === 'client') + (opts.envType === 'client' && marker?.kind === 'server') || + (opts.envType === 'server' && marker?.kind === 'client') if (!violatesMarker) { continue } candidates.push({ type: 'marker', - edge: { - importer: check.importer, - module: check.imported.module, - dependency: check.imported.dependency, - }, - source: check.source, + importer: check.module, + source: marker.source, }) } @@ -971,11 +1033,14 @@ async function mapCompilationLocation(opts: { importerModule: RspackModule dependencyLoc?: Loc }): Promise { + const transformResult = await opts.provider.getTransformResult( + opts.importerModule, + ) if (!opts.dependencyLoc) { return undefined } - const map = opts.provider.getTransformResult(opts.importerModule)?.map + const map = transformResult?.map if (!map) { return undefined } @@ -1015,6 +1080,8 @@ const compilationSourceMapConsumerCache = new WeakMap< object, Promise >() +const compilationImportSpecifierLocationIndex = + createImportSpecifierLocationIndex() function getCompilationSourceMapConsumer( map: SourceMapLike, @@ -1048,6 +1115,7 @@ async function resolveImporterLocation(opts: { importerModule: RspackModule source: string resolved?: string + transformedSources?: Array dependencyLoc?: Loc envType: 'client' | 'server' }): Promise { @@ -1061,15 +1129,43 @@ async function resolveImporterLocation(opts: { return dependencyLoc } + const transformResult = await opts.provider.getTransformResult( + opts.importerModule, + ) const provider: TransformResultProvider = { - getTransformResult: () => - opts.provider.getTransformResult(opts.importerModule), + getTransformResult: () => transformResult, } - for (const source of buildSourceCandidates( + const originalResult: TransformResult | undefined = + transformResult?.originalCode !== undefined + ? { + code: transformResult.originalCode, + filename: transformResult.filename, + map: undefined, + originalCode: transformResult.originalCode, + perf: transformResult.perf, + } + : undefined + const originalProvider: TransformResultProvider = { + getTransformResult: () => originalResult, + } + const sourceCandidates = buildSourceCandidates( opts.source, opts.resolved, opts.config.root, - )) { + ) + for (const transformedSource of opts.transformedSources ?? []) { + for (const candidate of buildSourceCandidates( + transformedSource, + undefined, + opts.config.root, + )) { + sourceCandidates.add(candidate) + } + } + + const importLocCache = new ImportLocCache() + const originalImportLocCache = new ImportLocCache() + for (const source of sourceCandidates) { const loc = (await findPostCompileUsageLocation(provider, opts.importer, source)) ?? findOriginalUsageLocation( @@ -1078,7 +1174,69 @@ async function resolveImporterLocation(opts: { source, opts.envType, opts.config.root, - ) + ) ?? + (await findImportStatementLocationFromTransformed( + provider, + opts.importer, + source, + importLocCache, + compilationImportSpecifierLocationIndex.find, + )) ?? + (await findImportStatementLocationFromTransformed( + originalProvider, + opts.importer, + source, + originalImportLocCache, + compilationImportSpecifierLocationIndex.find, + )) + if (loc) { + return loc + } + } + + return undefined +} + +async function resolveTraceEdgeLocation(opts: { + root: string + provider: CompilationTransformResultProvider + importLocCache: ImportLocCache + importer: string + edge: CompilationEdge + specifier?: string +}): Promise { + const dependencyLoc = await mapCompilationLocation({ + provider: opts.provider, + importer: opts.importer, + importerModule: opts.edge.importerModule, + dependencyLoc: getDependencyLocation(opts.edge.dependency), + }) + if (dependencyLoc) { + return dependencyLoc + } + + if (!opts.specifier) { + return undefined + } + + const transformResult = await opts.provider.getTransformResult( + opts.edge.importerModule, + ) + const provider: TransformResultProvider = { + getTransformResult: () => transformResult, + } + for (const source of buildSourceCandidates( + opts.specifier, + opts.edge.resolved, + opts.root, + )) { + const loc = await findImportStatementLocationFromTransformed( + provider, + opts.importer, + source, + opts.importLocCache, + compilationImportSpecifierLocationIndex.find, + ) if (loc) { return loc } @@ -1088,6 +1246,7 @@ async function resolveImporterLocation(opts: { } async function rebuildAndAnnotateTrace(opts: { + root: string provider: CompilationTransformResultProvider importGraph: ImportGraph edgeIndex: CompilationEdgeIndex @@ -1097,6 +1256,7 @@ async function rebuildAndAnnotateTrace(opts: { maxTraceDepth: number }): Promise> { const trace = buildTrace(opts.importGraph, opts.importer, opts.maxTraceDepth) + const importLocCache = new ImportLocCache() for (let i = 0; i < trace.length - 1; i++) { const step = trace[i]! @@ -1108,11 +1268,13 @@ async function rebuildAndAnnotateTrace(opts: { step.specifier, ) const loc = edge - ? await mapCompilationLocation({ + ? await resolveTraceEdgeLocation({ + root: opts.root, provider: opts.provider, + importLocCache, importer: step.file, - importerModule: edge.importerModule, - dependencyLoc: getDependencyLocation(edge.dependency), + edge, + specifier: edge.specifier ?? step.specifier, }) : undefined if (loc) { @@ -1147,6 +1309,7 @@ async function buildViolationInfo(opts: { importerModule: RspackModule source: string resolved?: string + transformedSources?: Array importLoc?: Loc type: 'specifier' | 'file' | 'marker' pattern?: string | RegExp @@ -1162,6 +1325,7 @@ async function buildViolationInfo(opts: { importerModule: opts.importerModule, source: opts.source, resolved: opts.resolved, + transformedSources: opts.transformedSources, dependencyLoc: opts.importLoc, envType: opts.envType, }) @@ -1171,6 +1335,7 @@ async function buildViolationInfo(opts: { const traceStartedAt = opts.perf ? performance.now() : 0 const trace = await rebuildAndAnnotateTrace({ + root: opts.config.root, provider: opts.provider, importGraph: opts.importGraph, edgeIndex: opts.edgeIndex, @@ -1184,11 +1349,13 @@ async function buildViolationInfo(opts: { } const snippetStartedAt = opts.perf ? performance.now() : 0 + const transformResult = importerLoc + ? await opts.provider.getTransformResult(opts.importerModule) + : undefined const snippet = importerLoc ? buildCodeSnippet( { - getTransformResult: () => - opts.provider.getTransformResult(opts.importerModule), + getTransformResult: () => transformResult, }, opts.importer, importerLoc, @@ -1219,36 +1386,34 @@ async function buildViolationInfo(opts: { return info } -function getMarkerKindForModule(opts: { - config: PluginConfig - importSpecifiersByModule: WeakMap> - module: RspackModule -}): 'server' | 'client' | undefined { - const file = getModuleResource(opts.module) +function getMarkerForModule( + module: RspackModule, +): ImportProtectionMarker | undefined { + const file = getModuleResource(module) if (!isImportProtectionSourceFile(file)) { return undefined } - const markerKind = getMarkerKindFromBuildInfo(opts.module) - if (markerKind) { - return markerKind + const marker = module.buildInfo[IMPORT_PROTECTION_BUILD_INFO_FIELD] + if (!marker || typeof marker !== 'object') { + return undefined } - const imports = opts.importSpecifiersByModule.get(opts.module) - let hasServerOnly = false - let hasClientOnly = false - for (const source of imports ?? []) { - hasServerOnly ||= opts.config.markerSpecifiers.serverOnly.has(source) - hasClientOnly ||= opts.config.markerSpecifiers.clientOnly.has(source) + if (!('kind' in marker) || !('source' in marker)) { + return undefined } - if (hasServerOnly && !hasClientOnly) { - return 'server' + if ( + (marker.kind !== 'server' && marker.kind !== 'client') || + typeof marker.source !== 'string' + ) { + return undefined } - if (hasClientOnly && !hasServerOnly) { - return 'client' + + return { + kind: marker.kind, + source: marker.source, } - return undefined } async function reportViolation(opts: { @@ -1540,8 +1705,7 @@ export function registerImportProtection( moduleByResource?.delete(id) if (module) { - module.buildInfo[IMPORT_PROTECTION_BUILD_INFO_FIELD] = - EMPTY_IMPORT_PROTECTION_BUILD_INFO + delete module.buildInfo[IMPORT_PROTECTION_BUILD_INFO_FIELD] } if (!config.enabled) { @@ -1572,35 +1736,33 @@ export function registerImportProtection( const importSources = getImportSourcesFromResult(transformResult) perf?.count('transform.importSources', importSources.length) - const hasServerOnlyMarker = importSources.some((source) => + const serverOnlyMarker = importSources.find((source) => config.markerSpecifiers.serverOnly.has(source), ) - const hasClientOnlyMarker = importSources.some((source) => + const clientOnlyMarker = importSources.find((source) => config.markerSpecifiers.clientOnly.has(source), ) - if (hasServerOnlyMarker && hasClientOnlyMarker) { + if (serverOnlyMarker && clientOnlyMarker) { throw new Error( `[import-protection] File "${relativeFile}" has both server-only and client-only markers. This is not allowed.`, ) } - const markerKind = hasServerOnlyMarker - ? ('server' as const) - : hasClientOnlyMarker - ? ('client' as const) + const marker: ImportProtectionMarker | undefined = serverOnlyMarker + ? { kind: 'server', source: serverOnlyMarker } + : clientOnlyMarker + ? { kind: 'client', source: clientOnlyMarker } : undefined - if (module && markerKind) { - module.buildInfo[IMPORT_PROTECTION_BUILD_INFO_FIELD] = { - markerKind, - } + if (module && marker) { + module.buildInfo[IMPORT_PROTECTION_BUILD_INFO_FIELD] = marker } const fileMatch = checkFileDenial(relativeFile, matchers) const markerViolation = - (envType === 'client' && markerKind === 'server') || - (envType === 'server' && markerKind === 'client') + (envType === 'client' && marker?.kind === 'server') || + (envType === 'server' && marker?.kind === 'client') if (fileMatch || markerViolation) { let exportNames: Array = [] @@ -1797,6 +1959,7 @@ export function registerImportProtection( provider = buildTransformResultProvider({ root: config.root, perf, + inputFileSystem: context.compilation.inputFileSystem, }) if (perf) { perf.time('processAssets.provider.build', providerStartedAt) @@ -1822,10 +1985,26 @@ export function registerImportProtection( importerModule: candidate.edge.importer, source: payload.violation.specifier, resolved: payload.violation.resolved, + transformedSources: [getModuleResource(candidate.edge.module)], importLoc: getDependencyLocation(candidate.edge.dependency), type: 'specifier', pattern: payload.violation.patternText, }) + } else if (candidate.type === 'marker') { + const importer = getModuleResource(candidate.importer) + info = await buildViolationInfo({ + config, + provider: getProvider(), + importGraph, + edgeIndex, + perf, + envName, + envType, + importer, + importerModule: candidate.importer, + source: candidate.source, + type: 'marker', + }) } else { const { edge, source } = candidate const importer = getModuleResource(edge.importer) @@ -1843,10 +2022,8 @@ export function registerImportProtection( source, resolved, importLoc: getDependencyLocation(edge.dependency), - type: candidate.type, - ...(candidate.type === 'file' - ? { pattern: candidate.pattern } - : {}), + type: 'file', + pattern: candidate.pattern, }) } From cbc89b0dbccb21ec8019510c4c5d1462ab207b26 Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Wed, 26 Aug 2026 20:08:07 +0800 Subject: [PATCH 12/19] refactor(start): clarify Rsbuild violation scanner state --- .../src/rsbuild/import-protection.ts | 43 ++++++++----------- 1 file changed, 18 insertions(+), 25 deletions(-) diff --git a/packages/start-plugin-core/src/rsbuild/import-protection.ts b/packages/start-plugin-core/src/rsbuild/import-protection.ts index ba1b7b2c16c..6faeb9c8174 100644 --- a/packages/start-plugin-core/src/rsbuild/import-protection.ts +++ b/packages/start-plugin-core/src/rsbuild/import-protection.ts @@ -276,7 +276,7 @@ interface ModuleGraphEdge { module: RspackModule } -type CompilationViolationCandidate = +type CompilationViolation = | { type: 'specifier' payload: MockEdgePayload @@ -817,14 +817,11 @@ interface MarkerCheckTarget { module: RspackModule } -type FileViolationCandidate = Extract< - CompilationViolationCandidate, - { type: 'file' } -> +type FileViolation = Extract interface CompilationViolationScanner { visitNode: (node: RspackModuleGraphNode) => void - finish: () => Array + finish: () => Array } function createCompilationViolationScanner(opts: { @@ -833,8 +830,9 @@ function createCompilationViolationScanner(opts: { matchers: FileMatchers shouldCheckImporter: (importer: string) => boolean }): CompilationViolationScanner { - const mockCandidates: Array = [] - const regularChecks: Array = [] + const specifierViolations: Array = [] + const fileViolations: Array = [] + const markerCheckTargets: Array = [] const mockPayloadByModule = new WeakMap< RspackModule, MockEdgePayload | null @@ -866,7 +864,7 @@ function createCompilationViolationScanner(opts: { if (shouldCheckImporter) { const payload = getMockPayload(imported.module) if (payload?.violation.importer === importer) { - mockCandidates.push({ + specifierViolations.push({ type: 'specifier', payload, edge: { @@ -896,12 +894,12 @@ function createCompilationViolationScanner(opts: { } if (importProtectionCheck.type === 'marker') { - regularChecks.push({ module: imported.module }) + markerCheckTargets.push({ module: imported.module }) continue } if (shouldCheckImporter) { - regularChecks.push({ + fileViolations.push({ type: 'file', edge: { importer: node.module, @@ -915,25 +913,20 @@ function createCompilationViolationScanner(opts: { } }, finish() { - const candidates = [...mockCandidates] + const violations = [...specifierViolations, ...fileViolations] const checkedMarkerModules = new WeakSet() - for (const check of regularChecks) { - if ('type' in check) { - candidates.push(check) - continue - } - - if (checkedMarkerModules.has(check.module)) { + for (const target of markerCheckTargets) { + if (checkedMarkerModules.has(target.module)) { continue } - checkedMarkerModules.add(check.module) + checkedMarkerModules.add(target.module) - if (!opts.shouldCheckImporter(getModuleResource(check.module))) { + if (!opts.shouldCheckImporter(getModuleResource(target.module))) { continue } - const marker = getMarkerForModule(check.module) + const marker = getMarkerForModule(target.module) const violatesMarker = (opts.envType === 'client' && marker?.kind === 'server') || (opts.envType === 'server' && marker?.kind === 'client') @@ -941,14 +934,14 @@ function createCompilationViolationScanner(opts: { continue } - candidates.push({ + violations.push({ type: 'marker', - importer: check.module, + importer: target.module, source: marker.source, }) } - return candidates + return violations }, } } From fd583b76523fbd4e8125d4585f3fe1d670c2d7a5 Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Thu, 27 Aug 2026 10:24:01 +0800 Subject: [PATCH 13/19] update readme --- .../rsbuild/INTERNALS-import-protection.md | 116 +++++++++--------- 1 file changed, 57 insertions(+), 59 deletions(-) diff --git a/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md b/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md index f43c9cf8049..ed8877b6a62 100644 --- a/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md +++ b/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md @@ -44,16 +44,17 @@ Per environment, Rsbuild keeps a smaller runtime state than Vite: - `resolveCache` - `seenViolations` -A per-environment resource map associates each loader resource with its Rspack -module. It is populated by Rspack's loader hook and consumed by the matching -post-transform callback; durable marker state lives on `module.buildInfo`. - -Shared state is for virtual module transport: +Shared adapter state contains: - `virtualModules` - `vmPlugins` - `readyVmPlugins` - `pendingWrites` +- `moduleByResource` + +`moduleByResource` associates each loader resource with its Rspack module. The +loader hook populates it, the matching post transform consumes it, and durable +marker metadata lives on `module.buildInfo`. Notably absent compared to Vite: @@ -74,7 +75,7 @@ The transform phase is responsible for: - self-denial for forbidden files - self-denial for marker-protected files in the wrong environment -- persisting detected marker kinds in Rspack `module.buildInfo` +- persisting detected marker metadata in Rspack `module.buildInfo` - direct specifier rewrites to mock-edge modules The transform treats the code it receives as authoritative. It does not read, @@ -107,47 +108,44 @@ adapter queues them and flushes during compilation setup. It reconstructs the final view of the compilation from Rspack data by: -1. collecting every module's active outgoing connections into - `RspackModuleGraphNode[]`, while a separate visitor classifies each node as - soon as it is created -2. finishing marker checks after all modules are known -3. returning immediately when collection produces no candidates -4. building the `ImportGraph` and diagnostic indexes only for confirmed - candidates - -Each `RspackModuleGraphNode` contains only a module and its active -`{ dependency, module }` imports. For multiple active connections to the same -target `Module`, collection keeps only the first connection in Rspack's outgoing -order. Collection does not filter by source-file eligibility, because every -intermediate module is required to preserve complete entry-to-violation traces. -The classification visitor applies source-file and rule eligibility separately; -it does not traverse the node array afterward. Marker fallback retains only -pending imports until every eligible node's specifier set is available. Module -identity keeps query, layer, and other same-resource variants distinct. -Normalized file paths remain the user-facing identity for rules, traces, source -mapping, and diagnostics. - -When at least one candidate exists, the adapter replays the in-memory node array -to build `ImportGraph`; it never calls -`getOutgoingConnectionsInOrder(module)` a second time. A successful compilation -therefore avoids allocating `ImportGraph`, entry data, and path-based trace -indexes entirely. - -`processAssets` does not parse module source. Import requests come from -the retained `connection.dependency.request`. Diagnostic locations come from -that dependency's `loc`, then map through the compiled module sourcemap. The -adapter does not distinguish import and usage locations. When Rspack does not -expose a dependency location, the diagnostic remains valid but may omit its -source location and snippet. - -When `sourceAndMap()` does not provide a sourcemap, generated dependency -locations are not reported as original source locations. Importer and trace -locations, along with the source snippet, are omitted in that case. - -`module.originalSource()` plus `sourceAndMap()` are called only for modules -required to build a confirmed violation. A compilation with no violations -therefore does not read dependency locations, module sources, or compilation -entries. +1. snapshotting each module and its outgoing connections +2. collecting specifier and file violations plus possible marker modules +3. deduplicating marker modules and validating their persisted metadata +4. returning early when no violations remain +5. building the `ImportGraph` and diagnostic indexes only when needed + +Each `RspackModuleGraphNode` stores a module and its +`{ dependency, module }` imports. Missing and errored target modules are skipped. +Connections are not filtered by `getActiveState()` because inactive connections +can still carry diagnostic evidence. Duplicate connections to the same target +module collapse to one; a connection with `dependency.loc` replaces one without +it. + +Snapshotting does not apply source-file eligibility. Intermediate modules remain +available for entry-to-violation traces, while the scanner applies importer and +rule checks. Normalized resource ids are used for rules, traces, and diagnostics; +`resourceResolveData.path` is preferred for original-source lookup. + +When violations exist, the adapter replays the snapshot to build `ImportGraph`; +it does not query outgoing connections again. A clean compilation avoids entry +traversal, graph indexes, and module-source loading. + +Diagnostic enrichment is lazy. The transform-result provider reads +`module.originalSource().sourceAndMap()` when available. It gets original code +from sourcemap `sourcesContent`, then falls back to +`compilation.inputFileSystem.readFile()`. Results and in-flight reads are cached +per module. + +Importer locations use this order: + +1. map `dependency.loc` through the compiled sourcemap +2. find unsafe usage in compiled code +3. find unsafe usage in original code +4. find the import statement in compiled, then original code + +Trace edges first map `dependency.loc`, then search compiled import statements. +A raw dependency location is not reported as an original location without a +sourcemap. Source parsing can still recover a location and snippet. This is the core Rsbuild-native replacement for Vite's `generateBundle` verification plus dev pending-violation flow. @@ -165,30 +163,30 @@ Transform-time: Compilation-time: - `module.resourceResolveData?.resource` +- `module.resourceResolveData?.path` - `module.identifier()` (normalized fallback) +- `module.buildInfo` - `module.originalSource().sourceAndMap()` (confirmed diagnostics only) - sourcemap `sourcesContent` +- `compilation.inputFileSystem.readFile()` (original-source fallback) - `moduleGraph.getOutgoingConnectionsInOrder(module)` - `connection.dependency.request` -- `connection.dependency.loc` (confirmed diagnostics only) - -Diagnostics use the retained first connection's dependency location and map it -back through the composed compilation sourcemap. +- `connection.dependency.loc` ## Marker Handling Unlike Vite, Rsbuild does not introduce plugin-owned virtual marker modules for normal operation. -The real package marker files are used as source-level markers. Rspack's loader -hook records the module under the exact loader resource. The matching post -transform consumes that association and writes the detected marker kind to the -module's `buildInfo` before replacing a wrong-environment module. This preserves -the marker after self-denial mocking and when Rspack restores modules from its -persistent cache. +The real package marker files are source-level markers. Rspack's loader hook +records the module under the exact loader resource. The matching post transform +writes `{ kind, source }` to `module.buildInfo` before replacing a +wrong-environment module. The metadata survives self-denial mocking and +persistent-cache restores. -`processAssets` reads the persisted marker kind first. Dependency requests in -the final module graph remain a fallback for modules without metadata. +`processAssets` treats non-excluded, non-file-denied imports as possible marker +modules, then checks their `buildInfo`. It does not infer marker kind from final +dependency requests. ## Practical Maintainer Rule From 073afc33dd31a475b53455ed1dfaa4f643ef98ba Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Thu, 27 Aug 2026 10:37:21 +0800 Subject: [PATCH 14/19] fix(start-plugin-core): avoid dependency locations in Rsbuild diagnostics --- .../rsbuild/INTERNALS-import-protection.md | 19 +-- .../src/rsbuild/import-protection.ts | 142 +----------------- 2 files changed, 9 insertions(+), 152 deletions(-) diff --git a/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md b/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md index ed8877b6a62..31c80f138b8 100644 --- a/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md +++ b/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md @@ -114,12 +114,11 @@ It reconstructs the final view of the compilation from Rspack data by: 4. returning early when no violations remain 5. building the `ImportGraph` and diagnostic indexes only when needed -Each `RspackModuleGraphNode` stores a module and its -`{ dependency, module }` imports. Missing and errored target modules are skipped. +Each `RspackModuleGraphNode` stores a module and its imported modules. Missing and +errored target modules are skipped. Connections are not filtered by `getActiveState()` because inactive connections can still carry diagnostic evidence. Duplicate connections to the same target -module collapse to one; a connection with `dependency.loc` replaces one without -it. +module collapse to one. Snapshotting does not apply source-file eligibility. Intermediate modules remain available for entry-to-violation traces, while the scanner applies importer and @@ -138,14 +137,13 @@ per module. Importer locations use this order: -1. map `dependency.loc` through the compiled sourcemap +1. find unsafe usage in original code 2. find unsafe usage in compiled code -3. find unsafe usage in original code -4. find the import statement in compiled, then original code +3. find the import statement in compiled, then original code -Trace edges first map `dependency.loc`, then search compiled import statements. -A raw dependency location is not reported as an original location without a -sourcemap. Source parsing can still recover a location and snippet. +Trace edges search compiled import statements. The adapter does not use +`dependency.loc`, which may identify a transformed declaration rather than the +actual import usage. This is the core Rsbuild-native replacement for Vite's `generateBundle` verification plus dev pending-violation flow. @@ -171,7 +169,6 @@ Compilation-time: - `compilation.inputFileSystem.readFile()` (original-source fallback) - `moduleGraph.getOutgoingConnectionsInOrder(module)` - `connection.dependency.request` -- `connection.dependency.loc` ## Marker Handling diff --git a/packages/start-plugin-core/src/rsbuild/import-protection.ts b/packages/start-plugin-core/src/rsbuild/import-protection.ts index 6faeb9c8174..5e365ea3683 100644 --- a/packages/start-plugin-core/src/rsbuild/import-protection.ts +++ b/packages/start-plugin-core/src/rsbuild/import-protection.ts @@ -1,8 +1,6 @@ import { writeFileSync } from 'node:fs' import { extname, resolve as resolvePath } from 'node:path' -import { SourceMapConsumer } from 'source-map' - import { getDefaultImportProtectionRules, getMarkerSpecifiers, @@ -72,7 +70,6 @@ import type { Rspack, rspack as rspackNamespaceType, } from '@rsbuild/core' -import type { RawSourceMap } from 'source-map' type RspackNamespace = typeof rspackNamespaceType type RspackVirtualModulesPlugin = InstanceType< @@ -221,7 +218,6 @@ interface SharedState { } interface CompilationEdge { - dependency: RspackDependency importerModule: RspackModule specifier?: string resolved: string @@ -271,7 +267,6 @@ interface MockEdgePayload { } interface ModuleGraphEdge { - dependency: RspackDependency importer: RspackModule module: RspackModule } @@ -573,25 +568,6 @@ function getModuleResourcePath(module: RspackModule): string { ) } -function getDependencyLocation(dependency: RspackDependency): Loc | undefined { - const loc = dependency.loc - if (!loc || !('start' in loc)) { - return undefined - } - - const start = loc.start - const line = start.line - const column = start.column - if (typeof line !== 'number') { - return undefined - } - - return { - line, - column: typeof column === 'number' ? column : 1, - } -} - const IMPORT_PROTECTION_PARSEABLE_EXTENSIONS = new Set([ '.ts', '.tsx', @@ -784,16 +760,6 @@ function forEachModules(opts: { const existingImportIndex = importIndexByModule.get(connectedModule) if (existingImportIndex !== undefined) { - const existingImport = imports[existingImportIndex]! - if ( - !getDependencyLocation(existingImport.dependency) && - getDependencyLocation(connection.dependency) - ) { - imports[existingImportIndex] = { - dependency: connection.dependency, - module: connectedModule, - } - } continue } @@ -870,7 +836,6 @@ function createCompilationViolationScanner(opts: { edge: { importer: node.module, module: imported.module, - dependency: imported.dependency, }, }) } @@ -904,7 +869,6 @@ function createCompilationViolationScanner(opts: { edge: { importer: node.module, module: imported.module, - dependency: imported.dependency, }, source, pattern: importProtectionCheck.fileMatch.pattern, @@ -970,7 +934,6 @@ function buildCompilationImportGraph(opts: { specifier, resolved, resolvedModule: imported.module, - dependency: imported.dependency, } edges.push(edge) importGraph.addEdge(resolved, importer, specifier) @@ -1020,87 +983,9 @@ function findCompilationEdge( )?.[0] } -async function mapCompilationLocation(opts: { - provider: CompilationTransformResultProvider - importer: string - importerModule: RspackModule - dependencyLoc?: Loc -}): Promise { - const transformResult = await opts.provider.getTransformResult( - opts.importerModule, - ) - if (!opts.dependencyLoc) { - return undefined - } - - const map = transformResult?.map - if (!map) { - return undefined - } - - const fallback: Loc = { - file: normalizeFilePath(opts.importer), - line: opts.dependencyLoc.line, - column: opts.dependencyLoc.column, - } - const consumer = await getCompilationSourceMapConsumer(map) - if (!consumer) { - return fallback - } - - try { - const original = consumer.originalPositionFor({ - line: opts.dependencyLoc.line, - column: Math.max(0, opts.dependencyLoc.column - 1), - }) - if (original.line != null && original.column != null) { - return { - file: original.source - ? normalizeFilePath(original.source) - : fallback.file, - line: original.line, - column: original.column + 1, - } - } - } catch { - // Malformed sourcemap - } - - return fallback -} - -const compilationSourceMapConsumerCache = new WeakMap< - object, - Promise ->() const compilationImportSpecifierLocationIndex = createImportSpecifierLocationIndex() -function getCompilationSourceMapConsumer( - map: SourceMapLike, -): Promise { - const cached = compilationSourceMapConsumerCache.get(map) - if (cached) { - return cached - } - - const consumer = (async () => { - try { - const rawMap: RawSourceMap = { - ...map, - file: map.file ?? '', - version: Number(map.version), - sourcesContent: map.sourcesContent?.map((source) => source ?? '') ?? [], - } - return await new SourceMapConsumer(rawMap) - } catch { - return null - } - })() - compilationSourceMapConsumerCache.set(map, consumer) - return consumer -} - async function resolveImporterLocation(opts: { config: PluginConfig provider: CompilationTransformResultProvider @@ -1109,19 +994,8 @@ async function resolveImporterLocation(opts: { source: string resolved?: string transformedSources?: Array - dependencyLoc?: Loc envType: 'client' | 'server' }): Promise { - const dependencyLoc = await mapCompilationLocation({ - provider: opts.provider, - importer: opts.importer, - importerModule: opts.importerModule, - dependencyLoc: opts.dependencyLoc, - }) - if (dependencyLoc) { - return dependencyLoc - } - const transformResult = await opts.provider.getTransformResult( opts.importerModule, ) @@ -1160,7 +1034,6 @@ async function resolveImporterLocation(opts: { const originalImportLocCache = new ImportLocCache() for (const source of sourceCandidates) { const loc = - (await findPostCompileUsageLocation(provider, opts.importer, source)) ?? findOriginalUsageLocation( provider, opts.importer, @@ -1168,6 +1041,7 @@ async function resolveImporterLocation(opts: { opts.envType, opts.config.root, ) ?? + (await findPostCompileUsageLocation(provider, opts.importer, source)) ?? (await findImportStatementLocationFromTransformed( provider, opts.importer, @@ -1198,16 +1072,6 @@ async function resolveTraceEdgeLocation(opts: { edge: CompilationEdge specifier?: string }): Promise { - const dependencyLoc = await mapCompilationLocation({ - provider: opts.provider, - importer: opts.importer, - importerModule: opts.edge.importerModule, - dependencyLoc: getDependencyLocation(opts.edge.dependency), - }) - if (dependencyLoc) { - return dependencyLoc - } - if (!opts.specifier) { return undefined } @@ -1303,7 +1167,6 @@ async function buildViolationInfo(opts: { source: string resolved?: string transformedSources?: Array - importLoc?: Loc type: 'specifier' | 'file' | 'marker' pattern?: string | RegExp }): Promise { @@ -1319,7 +1182,6 @@ async function buildViolationInfo(opts: { source: opts.source, resolved: opts.resolved, transformedSources: opts.transformedSources, - dependencyLoc: opts.importLoc, envType: opts.envType, }) if (opts.perf) { @@ -1979,7 +1841,6 @@ export function registerImportProtection( source: payload.violation.specifier, resolved: payload.violation.resolved, transformedSources: [getModuleResource(candidate.edge.module)], - importLoc: getDependencyLocation(candidate.edge.dependency), type: 'specifier', pattern: payload.violation.patternText, }) @@ -2014,7 +1875,6 @@ export function registerImportProtection( importerModule: edge.importer, source, resolved, - importLoc: getDependencyLocation(edge.dependency), type: 'file', pattern: candidate.pattern, }) From b55ce55e231e7e31fae668826aab700cdd11407f Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Thu, 27 Aug 2026 10:56:44 +0800 Subject: [PATCH 15/19] fix(start): respect importer scope for Rsbuild markers --- .../rsbuild/INTERNALS-import-protection.md | 8 +- .../src/rsbuild/import-protection.ts | 12 +- .../tests/rsbuild/import-protection.test.ts | 124 +++++++++++++++++- 3 files changed, 135 insertions(+), 9 deletions(-) diff --git a/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md b/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md index 31c80f138b8..62799a35583 100644 --- a/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md +++ b/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md @@ -53,8 +53,8 @@ Shared adapter state contains: - `moduleByResource` `moduleByResource` associates each loader resource with its Rspack module. The -loader hook populates it, the matching post transform consumes it, and durable -marker metadata lives on `module.buildInfo`. +loader hook populates it, the matching post-transform hook consumes it, and +durable marker metadata lives on `module.buildInfo`. Notably absent compared to Vite: @@ -176,8 +176,8 @@ Unlike Vite, Rsbuild does not introduce plugin-owned virtual marker modules for normal operation. The real package marker files are source-level markers. Rspack's loader hook -records the module under the exact loader resource. The matching post transform -writes `{ kind, source }` to `module.buildInfo` before replacing a +records the module under the exact loader resource. The matching post-transform +hook writes `{ kind, source }` to `module.buildInfo` before replacing a wrong-environment module. The metadata survives self-denial mocking and persistent-cache restores. diff --git a/packages/start-plugin-core/src/rsbuild/import-protection.ts b/packages/start-plugin-core/src/rsbuild/import-protection.ts index 5e365ea3683..bcf8801abea 100644 --- a/packages/start-plugin-core/src/rsbuild/import-protection.ts +++ b/packages/start-plugin-core/src/rsbuild/import-protection.ts @@ -780,6 +780,7 @@ function forEachModules(opts: { } interface MarkerCheckTarget { + importer: RspackModule module: RspackModule } @@ -859,7 +860,10 @@ function createCompilationViolationScanner(opts: { } if (importProtectionCheck.type === 'marker') { - markerCheckTargets.push({ module: imported.module }) + markerCheckTargets.push({ + importer: node.module, + module: imported.module, + }) continue } @@ -881,14 +885,14 @@ function createCompilationViolationScanner(opts: { const checkedMarkerModules = new WeakSet() for (const target of markerCheckTargets) { - if (checkedMarkerModules.has(target.module)) { + if (!opts.shouldCheckImporter(getModuleResource(target.importer))) { continue } - checkedMarkerModules.add(target.module) - if (!opts.shouldCheckImporter(getModuleResource(target.module))) { + if (checkedMarkerModules.has(target.module)) { continue } + checkedMarkerModules.add(target.module) const marker = getMarkerForModule(target.module) const violatesMarker = diff --git a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts index c751e231601..2cd34b1f2e3 100644 --- a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, test } from 'vitest' +import { describe, expect, test, vi } from 'vitest' import { compileMatchers } from '../../src/import-protection/matchers' import { getRsbuildResolvedImportProtectionCheck, + registerImportProtection, } from '../../src/rsbuild/import-protection' describe('getRsbuildResolvedImportProtectionCheck', () => { @@ -51,3 +52,124 @@ describe('getRsbuildResolvedImportProtectionCheck', () => { ).toEqual({ type: 'marker' }) }) }) + +describe('registerImportProtection marker scope', () => { + async function runMarkerBuild(importerFiles: Array) { + let beforeBuild: (() => void) | undefined + let processAssetsHandler: ((context: any) => Promise) | undefined + const onViolation = vi.fn(() => false) + + registerImportProtection( + { + context: { action: 'build' }, + onBeforeBuild(handler: () => void) { + beforeBuild = handler + }, + onBeforeDevCompile() {}, + modifyRspackConfig() {}, + transform() {}, + processAssets( + _options: unknown, + handler: (context: any) => Promise, + ) { + processAssetsHandler = handler + }, + } as any, + { + framework: 'react', + environments: [{ name: 'client', type: 'client' }], + getConfig: () => + ({ + startConfig: { + importProtection: { + ignoreImporters: ['**/ignored.ts'], + onViolation, + }, + }, + resolvedStartConfig: { + root: '/app', + srcDirectory: '/app/src', + }, + }) as any, + }, + ) + + if (!beforeBuild || !processAssetsHandler) { + throw new Error('Expected import-protection hooks to be registered') + } + beforeBuild() + + const createModule = (file: string, marker = false) => ({ + buildInfo: marker + ? { + 'tanstack.start.importProtection': { + kind: 'server', + source: '@tanstack/react-start/server-only', + }, + } + : {}, + resourceResolveData: { path: file, resource: file }, + identifier: () => file, + originalSource: () => ({ + sourceAndMap: () => ({ + source: marker ? "import '@tanstack/react-start/server-only'" : '', + map: null, + }), + }), + }) + + const markedModule = createModule('/app/src/marked.ts', true) + const importerModules = importerFiles.map((file) => createModule(file)) + const connectionsByModule = new Map( + importerModules.map((module) => [ + module, + [ + { + dependency: { request: './marked' }, + module: markedModule, + }, + ], + ]), + ) + + await processAssetsHandler({ + environment: { name: 'client' }, + compilation: { + entries: new Map(), + errors: [], + inputFileSystem: null, + modules: new Set([...importerModules, markedModule]), + moduleGraph: { + getOutgoingConnectionsInOrder(module: unknown) { + return connectionsByModule.get(module as any) ?? [] + }, + }, + warnings: [], + }, + compiler: { rspack: {} }, + }) + + return onViolation + } + + test('skips marker violations imported only by an ignored importer', async () => { + const onViolation = await runMarkerBuild(['/app/src/ignored.ts']) + + expect(onViolation).not.toHaveBeenCalled() + }) + + test('reports a marker shared with a non-ignored importer', async () => { + const onViolation = await runMarkerBuild([ + '/app/src/ignored.ts', + '/app/src/entry.ts', + ]) + + expect(onViolation).toHaveBeenCalledTimes(1) + expect(onViolation).toHaveBeenCalledWith( + expect.objectContaining({ + importer: '/app/src/marked.ts', + type: 'marker', + }), + ) + }) +}) From 2ec986eda2e61106d44fcc6a4730cdec373ec3f2 Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Thu, 27 Aug 2026 12:03:45 +0800 Subject: [PATCH 16/19] refactor(start): move Rsbuild import protection transform to loader --- packages/start-plugin-core/package.json | 1 + .../rsbuild/INTERNALS-import-protection.md | 41 +- .../src/rsbuild/import-protection-loader.ts | 342 +++++++++++++++ .../src/rsbuild/import-protection.ts | 395 ++++-------------- .../tests/rsbuild/import-protection.test.ts | 66 +++ packages/start-plugin-core/vite.config.ts | 1 + pnpm-lock.yaml | 3 + 7 files changed, 517 insertions(+), 332 deletions(-) create mode 100644 packages/start-plugin-core/src/rsbuild/import-protection-loader.ts diff --git a/packages/start-plugin-core/package.json b/packages/start-plugin-core/package.json index 493e418f4e5..e61624e0f98 100644 --- a/packages/start-plugin-core/package.json +++ b/packages/start-plugin-core/package.json @@ -88,6 +88,7 @@ "@babel/code-frame": "7.27.1", "@babel/core": "^7.28.5", "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", "@tanstack/router-core": "workspace:*", "@tanstack/router-generator": "workspace:*", "@tanstack/router-plugin": "workspace:*", diff --git a/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md b/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md index 62799a35583..eca4051281b 100644 --- a/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md +++ b/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md @@ -7,7 +7,7 @@ import-protection core in `src/import-protection/INTERNALS.md`. Rsbuild owns: -- post-transform enforcement through `api.transform({ order: 'post' })` +- post-transform enforcement through a Rspack post-loader - virtual-module transport through `VirtualModulesPlugin` - compilation-truth reporting in `processAssets` - final graph reconstruction from Rspack compilation data @@ -34,8 +34,10 @@ object: 1. `onBeforeBuild` 2. `onBeforeDevCompile` 3. `modifyRspackConfig` -4. `transform(..., { order: 'post' })` -5. `processAssets(..., { stage: 'report' })` +4. `processAssets(..., { stage: 'report' })` + +`modifyRspackConfig` installs both the virtual-modules plugin and the +environment-scoped import-protection post-loader. ## State Model @@ -50,11 +52,6 @@ Shared adapter state contains: - `vmPlugins` - `readyVmPlugins` - `pendingWrites` -- `moduleByResource` - -`moduleByResource` associates each loader resource with its Rspack module. The -loader hook populates it, the matching post-transform hook consumes it, and -durable marker metadata lives on `module.buildInfo`. Notably absent compared to Vite: @@ -65,7 +62,10 @@ Notably absent compared to Vite: ## Transform Phase -Rsbuild enforcement runs after the Start compiler in a `post` transform. +Rsbuild enforcement runs after the Start compiler in a Rspack loader with +`enforce: 'post'`. The complete transform pipeline lives in +`import-protection-loader.ts`; mutable configuration and per-environment state +are passed through loader options. That matters because many compiler-safe imports are already stripped by the time import protection runs. This naturally suppresses a large class of false @@ -154,9 +154,16 @@ The Rsbuild adapter intentionally prefers native Rspack APIs where possible. Transform-time: -- `ctx.resource` -- `ctx.context` -- `ctx.resolve(...)` +- `loaderContext.resource` +- `loaderContext.resourcePath` +- `loaderContext.context` +- `loaderContext.resolve(...)` +- `loaderContext._module.buildInfo` + +`_module` is a deprecated Rspack loader-context API. It is used deliberately +because a loader invocation is bound to one exact module instance, including +its layer. Keying modules by resource would collapse distinct modules that use +the same resource in different layers. Do not add a resource-map fallback. Compilation-time: @@ -175,11 +182,11 @@ Compilation-time: Unlike Vite, Rsbuild does not introduce plugin-owned virtual marker modules for normal operation. -The real package marker files are source-level markers. Rspack's loader hook -records the module under the exact loader resource. The matching post-transform -hook writes `{ kind, source }` to `module.buildInfo` before replacing a -wrong-environment module. The metadata survives self-denial mocking and -persistent-cache restores. +The real package marker files are source-level markers. The post-loader writes +`{ kind, source }` directly to its current `_module.buildInfo` before replacing +a wrong-environment module. The metadata therefore stays attached to the exact +resource-and-layer module and survives self-denial mocking and persistent-cache +restores. `processAssets` treats non-excluded, non-file-denied imports as possible marker modules, then checks their `buildInfo`. It does not infer marker kind from final diff --git a/packages/start-plugin-core/src/rsbuild/import-protection-loader.ts b/packages/start-plugin-core/src/rsbuild/import-protection-loader.ts new file mode 100644 index 00000000000..eba703f1212 --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/import-protection-loader.ts @@ -0,0 +1,342 @@ +import remapping from '@jridgewell/remapping' + +import { matchesAny } from '../import-protection/matchers' +import { + getImportProtectionEnvType, + getImportProtectionRelativePath, +} from '../import-protection/adapterUtils' +import { + getImportSourcesFromResult, + getMockExportNamesBySourceFromResult, + getNamedExportsFromResult, +} from '../import-protection/analysis' +import { rewriteDeniedImports } from '../import-protection/rewrite' +import { normalizeSourceMap } from '../import-protection/sourceLocation' +import { + generateDevSelfDenialModule, + generateSelfContainedMockModule, +} from '../import-protection/virtualModules' +import { + canonicalizeResolvedId, + checkFileDenial, + normalizeFilePath, +} from '../import-protection/utils' +import { + IMPORT_PROTECTION_BUILD_INFO_FIELD, + ensureMockEdgeModule, + ensureRuntimeMockModule, + ensureSilentMockModule, + getOrCreateEnvState, + getRulesForEnvironment, + serializePattern, + shouldCheckImporterWithCache, +} from './import-protection' +import type { + EnvRuntimeState, + ImportProtectionMarker, + PerfCollector, + PluginConfig, + SharedState, +} from './import-protection' +import type { ExtensionlessAbsoluteIdResolver } from '../import-protection/extensionlessAbsoluteIdResolver' +import type { TransformResult } from '../import-protection/sourceLocation' +import type { SourceMapInput } from '@jridgewell/remapping' +import type { Rspack } from '@rsbuild/core' + +export interface ImportProtectionLoaderOptions { + config: PluginConfig + envName: string + envStates: Map + extensionlessResolver: ExtensionlessAbsoluteIdResolver + perf?: PerfCollector + shared: SharedState + shouldCheckImporterCache: Map +} + +type ImportProtectionLoaderContext = + Rspack.LoaderContext + +type ImportProtectionTransformResult = + | string + | { + code: string + map?: ReturnType | null + } + +async function resolveAgainstImporter(opts: { + envState: EnvRuntimeState + config: PluginConfig + context: string | null + importerId: string + source: string + resolve: ImportProtectionLoaderContext['resolve'] + extensionlessResolver: ExtensionlessAbsoluteIdResolver + perf?: PerfCollector +}): Promise { + const importerDir = + opts.context ?? opts.importerId.replace(/[/\\][^/\\]*$/, '') + const normalizedImporterDir = normalizeFilePath(importerDir) + const cacheKey = `${normalizedImporterDir}:${opts.source}` + + if (opts.envState.resolveCache.has(cacheKey)) { + opts.perf?.count('resolve.cached') + return opts.envState.resolveCache.get(cacheKey) ?? null + } + + const startedAt = opts.perf ? performance.now() : 0 + opts.perf?.count('resolve.calls') + const resolved = await new Promise((resolve, reject) => { + opts.resolve(importerDir, opts.source, (error, result) => { + if (error) { + reject(error) + return + } + + resolve(typeof result === 'string' ? result : null) + }) + }) + .catch(() => null) + .finally(() => { + if (opts.perf) { + opts.perf.time('resolve', startedAt) + } + }) + + if (!resolved) { + opts.envState.resolveCache.set(cacheKey, null) + return null + } + + const canonical = canonicalizeResolvedId( + resolved, + opts.config.root, + (value) => opts.extensionlessResolver.resolve(value), + ) + + opts.envState.resolveCache.set(cacheKey, canonical) + return canonical +} + +async function transformImportProtection( + loaderContext: ImportProtectionLoaderContext, + code: string, + options: ImportProtectionLoaderOptions, +): Promise { + const startedAt = options.perf ? performance.now() : 0 + const { config, envName, perf, shared } = options + perf?.count('transform.calls') + perf?.count(`transform.env.${envName}`) + + try { + const id = loaderContext.resource + delete loaderContext._module.buildInfo[IMPORT_PROTECTION_BUILD_INFO_FIELD] + + if (!config.enabled) { + return code + } + + const envType = getImportProtectionEnvType(config, envName) + const envState = getOrCreateEnvState(options.envStates, envName) + const file = normalizeFilePath(loaderContext.resourcePath) + + if ( + !shouldCheckImporterWithCache({ + config, + cache: options.shouldCheckImporterCache, + perf, + file, + }) + ) { + perf?.count('transform.skippedImporter') + return code + } + + const matchers = getRulesForEnvironment(config, envName) + const relativeFile = getImportProtectionRelativePath(config.root, file) + const transformResult: TransformResult = { + code, + filename: file, + map: undefined, + originalCode: undefined, + perf, + } + const importSources = getImportSourcesFromResult(transformResult) + perf?.count('transform.importSources', importSources.length) + + const serverOnlyMarker = importSources.find((source) => + config.markerSpecifiers.serverOnly.has(source), + ) + const clientOnlyMarker = importSources.find((source) => + config.markerSpecifiers.clientOnly.has(source), + ) + + if (serverOnlyMarker && clientOnlyMarker) { + throw new Error( + `[import-protection] File "${relativeFile}" has both server-only and client-only markers. This is not allowed.`, + ) + } + + const marker: ImportProtectionMarker | undefined = serverOnlyMarker + ? { kind: 'server', source: serverOnlyMarker } + : clientOnlyMarker + ? { kind: 'client', source: clientOnlyMarker } + : undefined + + if (marker) { + loaderContext._module.buildInfo[IMPORT_PROTECTION_BUILD_INFO_FIELD] = + marker + } + + const fileMatch = checkFileDenial(relativeFile, matchers) + const markerViolation = + (envType === 'client' && marker?.kind === 'server') || + (envType === 'server' && marker?.kind === 'client') + + if (fileMatch || markerViolation) { + let exportNames: Array = [] + + try { + exportNames = getNamedExportsFromResult(transformResult) + } catch { + exportNames = [] + } + + if (config.command === 'build') { + return generateSelfContainedMockModule(exportNames) + } + + const runtimeId = ensureRuntimeMockModule({ + shared, + envName, + mode: config.mockAccess, + env: envName, + importer: file, + specifier: relativeFile, + }) + + return generateDevSelfDenialModule(exportNames, runtimeId) + } + + const deniedSpecifierReplacements = new Map() + let exportsBySource: Map> | undefined + const getExportsBySource = () => { + if (exportsBySource) { + return exportsBySource + } + + try { + exportsBySource = getMockExportNamesBySourceFromResult(transformResult) + } catch { + exportsBySource = new Map>() + } + return exportsBySource + } + + for (const source of importSources) { + const specifierMatch = matchesAny(source, matchers.specifiers) + if (!specifierMatch) { + continue + } + + const resolved = await resolveAgainstImporter({ + envState, + config, + context: loaderContext.context, + importerId: id, + source, + resolve: loaderContext.resolve.bind(loaderContext), + extensionlessResolver: options.extensionlessResolver, + perf, + }) + + const runtimeId = + config.command === 'build' + ? ensureSilentMockModule(shared, envName) + : ensureRuntimeMockModule({ + shared, + envName, + mode: config.mockAccess, + env: envName, + importer: file, + specifier: source, + }) + + const replacement = ensureMockEdgeModule({ + shared, + envName, + payload: { + exports: getExportsBySource().get(source) ?? [], + runtimeId, + violation: { + env: envName, + envType, + importer: file, + specifier: source, + ...(resolved ? { resolved } : {}), + patternText: serializePattern(specifierMatch.pattern), + }, + }, + }) + + deniedSpecifierReplacements.set(source, replacement) + } + + if (deniedSpecifierReplacements.size === 0) { + return code + } + + const rewritten = rewriteDeniedImports( + code, + id, + new Set(deniedSpecifierReplacements.keys()), + (source) => deniedSpecifierReplacements.get(source) ?? source, + ) + + if (!rewritten) { + return code + } + + return { + code: rewritten.code, + map: normalizeSourceMap(rewritten.map) ?? null, + } + } finally { + if (perf) { + perf.time('transform', startedAt) + } + } +} + +const importProtectionLoader: Rspack.LoaderDefinition = + function (source, sourceMap): void { + const callback = this.async() + const options = this.getOptions() + + transformImportProtection(this, source, options).then( + (result) => { + if (typeof result === 'string') { + callback(null, result, sourceMap) + return + } + + const mergedMap = + sourceMap && result.map + ? remapping( + [result.map as SourceMapInput, sourceMap as SourceMapInput], + () => null, + ) + : (result.map ?? sourceMap) + + callback( + null, + result.code, + mergedMap as unknown as Exclude, + ) + }, + (error: unknown) => { + callback(error instanceof Error ? error : new Error(String(error))) + }, + ) + } + +export default importProtectionLoader diff --git a/packages/start-plugin-core/src/rsbuild/import-protection.ts b/packages/start-plugin-core/src/rsbuild/import-protection.ts index bcf8801abea..2539988f16b 100644 --- a/packages/start-plugin-core/src/rsbuild/import-protection.ts +++ b/packages/start-plugin-core/src/rsbuild/import-protection.ts @@ -1,5 +1,6 @@ import { writeFileSync } from 'node:fs' -import { extname, resolve as resolvePath } from 'node:path' +import { dirname, extname, resolve as resolvePath } from 'node:path' +import { fileURLToPath } from 'node:url' import { getDefaultImportProtectionRules, @@ -7,19 +8,13 @@ import { } from '../import-protection/defaults' import { normalizePath } from '../utils' import { ExtensionlessAbsoluteIdResolver } from '../import-protection/extensionlessAbsoluteIdResolver' -import { compileMatchers, matchesAny } from '../import-protection/matchers' +import { compileMatchers } from '../import-protection/matchers' import { getImportProtectionEnvType, getImportProtectionRelativePath, getImportProtectionRulesForEnvironment, shouldCheckImportProtectionImporter, } from '../import-protection/adapterUtils' -import { - getImportSourcesFromResult, - getMockExportNamesBySourceFromResult, - getNamedExportsFromResult, -} from '../import-protection/analysis' -import { rewriteDeniedImports } from '../import-protection/rewrite' import { ImportLocCache, buildCodeSnippet, @@ -36,16 +31,12 @@ import { formatViolation, } from '../import-protection/trace' import { - generateDevSelfDenialModule, - generateSelfContainedMockModule, loadMockEdgeModule, loadMockRuntimeModule, loadSilentMockModule, } from '../import-protection/virtualModules' import { buildSourceCandidates, - canonicalizeResolvedId, - checkFileDenial, clearNormalizeFilePathCache, dedupePatterns, dedupeViolationKey, @@ -78,21 +69,19 @@ type RspackVirtualModulesPlugin = InstanceType< type ProcessAssetsContext = Parameters< Parameters[1] >[0] -type TransformContext = Parameters< - Parameters[1] ->[0] type RspackCompilation = Rspack.Compilation type RspackModule = Rspack.Module type RspackDependency = Rspack.Dependency type RspackInputFileSystem = NonNullable -type ImportProtectionMarkerKind = 'server' | 'client' -interface ImportProtectionMarker { +export type ImportProtectionMarkerKind = 'server' | 'client' +export interface ImportProtectionMarker { kind: ImportProtectionMarkerKind source: string } -const IMPORT_PROTECTION_BUILD_INFO_FIELD = 'tanstack.start.importProtection' +export const IMPORT_PROTECTION_BUILD_INFO_FIELD = + 'tanstack.start.importProtection' type PerfTiming = { count: number @@ -100,7 +89,7 @@ type PerfTiming = { maxMs: number } -type PerfCollector = { +export type PerfCollector = { count: (name: string, value?: number) => void time: (name: string, startedAt: number) => void flush: (root: string, envName: string, phase: string) => void @@ -170,13 +159,13 @@ function createPerfCollector(): PerfCollector { } } -interface EnvRules { +export interface EnvRules { specifiers: Array files: Array excludeFiles: Array } -interface PluginConfig { +export interface PluginConfig { enabled: boolean root: string command: 'build' | 'serve' @@ -203,18 +192,17 @@ interface PluginConfig { ) => boolean | void | Promise } -interface EnvRuntimeState { +export interface EnvRuntimeState { resolveCache: Map seenViolations: Set } -interface SharedState { +export interface SharedState { root: string virtualModules: Map vmPlugins: Record readyVmPlugins: Record pendingWrites: Map> - moduleByResource: Record> } interface CompilationEdge { @@ -297,6 +285,11 @@ const IMPORT_PROTECTION_VIRTUAL_DIR = 'node_modules/.virtual/import-protection' const MOCK_EDGE_FILE_PREFIX = 'mock-edge-' const MOCK_RUNTIME_FILE_PREFIX = 'mock-runtime-' const MOCK_SILENT_FILE = 'mock-silent.mjs' +const currentDir = dirname(fileURLToPath(import.meta.url)) +const importProtectionLoader = resolvePath( + currentDir, + 'import-protection-loader.js', +) function toBase64Url(input: unknown): string { return Buffer.from(JSON.stringify(input), 'utf8').toString('base64url') @@ -306,14 +299,14 @@ function fromBase64Url(input: string): T { return JSON.parse(Buffer.from(input, 'base64url').toString('utf8')) as T } -function getRulesForEnvironment( +export function getRulesForEnvironment( config: PluginConfig, envName: string, ): EnvRules { return getImportProtectionRulesForEnvironment(config, envName) as EnvRules } -function serializePattern(pattern: string | RegExp): string { +export function serializePattern(pattern: string | RegExp): string { return typeof pattern === 'string' ? pattern : pattern.toString() } @@ -339,7 +332,7 @@ export function getRsbuildResolvedImportProtectionCheck( return { type: 'marker' } } -function getOrCreateEnvState( +export function getOrCreateEnvState( envStates: Map, envName: string, ): EnvRuntimeState { @@ -356,6 +349,27 @@ function getOrCreateEnvState( return env } +export function shouldCheckImporterWithCache(opts: { + config: PluginConfig + cache: Map + perf?: PerfCollector + file: string +}): boolean { + const normalizedFile = normalizeFilePath(opts.file) + const cached = opts.cache.get(normalizedFile) + if (cached !== undefined) { + opts.perf?.count('shouldCheckImporter.cached') + return cached + } + + const result = shouldCheckImportProtectionImporter( + opts.config, + normalizedFile, + ) + opts.cache.set(normalizedFile, result) + return result +} + function getVirtualModulePath( root: string, envName: string, @@ -420,7 +434,10 @@ function flushPendingWrites(shared: SharedState, envName: string): void { } } -function ensureSilentMockModule(shared: SharedState, envName: string): string { +export function ensureSilentMockModule( + shared: SharedState, + envName: string, +): string { return tryWriteVirtualModule( shared, envName, @@ -429,7 +446,7 @@ function ensureSilentMockModule(shared: SharedState, envName: string): string { ) } -function ensureRuntimeMockModule(opts: { +export function ensureRuntimeMockModule(opts: { shared: SharedState envName: string mode: 'error' | 'warn' | 'off' @@ -457,7 +474,7 @@ function ensureRuntimeMockModule(opts: { ) } -function ensureMockEdgeModule(opts: { +export function ensureMockEdgeModule(opts: { shared: SharedState envName: string payload: MockEdgePayload @@ -491,59 +508,6 @@ function getMockEdgePayloadFromFile( } } -async function resolveAgainstImporter(opts: { - envState: EnvRuntimeState - config: PluginConfig - ctx: TransformContext - importerId: string - source: string - extensionlessResolver: ExtensionlessAbsoluteIdResolver - perf?: PerfCollector -}): Promise { - const importerDir = - opts.ctx.context ?? opts.importerId.replace(/[/\\][^/\\]*$/, '') - const normalizedImporterDir = normalizeFilePath(importerDir) - const cacheKey = `${normalizedImporterDir}:${opts.source}` - - if (opts.envState.resolveCache.has(cacheKey)) { - opts.perf?.count('resolve.cached') - return opts.envState.resolveCache.get(cacheKey) ?? null - } - - const startedAt = opts.perf ? performance.now() : 0 - opts.perf?.count('resolve.calls') - const resolved = await new Promise((resolve, reject) => { - opts.ctx.resolve(importerDir, opts.source, (error, result) => { - if (error) { - reject(error) - return - } - - resolve(typeof result === 'string' ? result : null) - }) - }) - .catch(() => null) - .finally(() => { - if (opts.perf) { - opts.perf.time('resolve', startedAt) - } - }) - - if (!resolved) { - opts.envState.resolveCache.set(cacheKey, null) - return null - } - - const canonical = canonicalizeResolvedId( - resolved, - opts.config.root, - (value) => opts.extensionlessResolver.resolve(value), - ) - - opts.envState.resolveCache.set(cacheKey, canonical) - return canonical -} - function getModuleResource(module: RspackModule): string { const resourceResolveData = ( module as RspackModule & { @@ -1368,7 +1332,6 @@ export function registerImportProtection( vmPlugins: {}, readyVmPlugins: {}, pendingWrites: new Map(), - moduleByResource: {}, } function applyUserConfig(): void { @@ -1452,16 +1415,12 @@ export function registerImportProtection( } function shouldCheckImporter(file: string): boolean { - const normalizedFile = normalizeFilePath(file) - const cached = shouldCheckImporterCache.get(normalizedFile) - if (cached !== undefined) { - perf?.count('shouldCheckImporter.cached') - return cached - } - - const result = shouldCheckImportProtectionImporter(config, normalizedFile) - shouldCheckImporterCache.set(normalizedFile, result) - return result + return shouldCheckImporterWithCache({ + config, + cache: shouldCheckImporterCache, + perf, + file, + }) } api.onBeforeBuild(() => { @@ -1496,40 +1455,42 @@ export function registerImportProtection( applyUserConfig() const envName = utils.environment.name + if ( + !opts.environments.some((environment) => environment.name === envName) + ) { + return + } + const VMP = utils.rspack.experiments.VirtualModulesPlugin const vmPlugin = new VMP({}) shared.vmPlugins[envName] = vmPlugin shared.readyVmPlugins[envName] = false - const moduleByResource = new Map() - shared.moduleByResource[envName] = moduleByResource + + const rules = rspackConfig.module.rules ?? [] + rules.push({ + test: /\.[cm]?[tj]sx?$/, + enforce: 'post', + use: [ + { + loader: importProtectionLoader, + options: { + config, + envName, + envStates, + extensionlessResolver, + perf, + shared, + shouldCheckImporterCache, + }, + }, + ], + }) + rspackConfig.module.rules = rules rspackConfig.plugins.push(vmPlugin) rspackConfig.plugins.push({ apply(compiler: Rspack.Compiler) { - compiler.hooks.compilation.tap( - 'TanStackStartImportProtectionBuildInfo', - (compilation) => { - utils.rspack.NormalModule.getCompilationHooks( - compilation, - ).loader.tap( - 'TanStackStartImportProtectionBuildInfo', - (loaderContext, module) => { - if (!isImportProtectionSourceFile(loaderContext.resourcePath)) { - return - } - - moduleByResource.set(loaderContext.resource, module) - }, - ) - }, - ) - - compiler.hooks.compile.tap( - 'TanStackStartImportProtectionModuleCleanup', - () => moduleByResource.clear(), - ) - compiler.hooks.thisCompilation.tap( 'TanStackStartImportProtectionVirtualModulesReady', () => { @@ -1544,202 +1505,6 @@ export function registerImportProtection( } }) - for (const environment of opts.environments) { - api.transform( - { - test: /\.[cm]?[tj]sx?$/, - environments: [environment.name], - order: 'post', - }, - async (ctx) => { - const startedAt = perf ? performance.now() : 0 - perf?.count('transform.calls') - perf?.count(`transform.env.${environment.name}`) - - try { - const envName = environment.name - const id = ctx.resource - const moduleByResource = shared.moduleByResource[envName] - const module = moduleByResource?.get(id) - moduleByResource?.delete(id) - - if (module) { - delete module.buildInfo[IMPORT_PROTECTION_BUILD_INFO_FIELD] - } - - if (!config.enabled) { - return ctx.code - } - - const envType = getImportProtectionEnvType(config, envName) - const envState = getOrCreateEnvState(envStates, envName) - const file = normalizeFilePath(ctx.resourcePath) - - if (!shouldCheckImporter(file)) { - perf?.count('transform.skippedImporter') - return ctx.code - } - - const matchers = getRulesForEnvironment(config, envName) - const relativeFile = getImportProtectionRelativePath( - config.root, - file, - ) - const transformResult: TransformResult = { - code: ctx.code, - filename: file, - map: undefined, - originalCode: undefined, - perf, - } - const importSources = getImportSourcesFromResult(transformResult) - perf?.count('transform.importSources', importSources.length) - - const serverOnlyMarker = importSources.find((source) => - config.markerSpecifiers.serverOnly.has(source), - ) - const clientOnlyMarker = importSources.find((source) => - config.markerSpecifiers.clientOnly.has(source), - ) - - if (serverOnlyMarker && clientOnlyMarker) { - throw new Error( - `[import-protection] File "${relativeFile}" has both server-only and client-only markers. This is not allowed.`, - ) - } - - const marker: ImportProtectionMarker | undefined = serverOnlyMarker - ? { kind: 'server', source: serverOnlyMarker } - : clientOnlyMarker - ? { kind: 'client', source: clientOnlyMarker } - : undefined - - if (module && marker) { - module.buildInfo[IMPORT_PROTECTION_BUILD_INFO_FIELD] = marker - } - - const fileMatch = checkFileDenial(relativeFile, matchers) - const markerViolation = - (envType === 'client' && marker?.kind === 'server') || - (envType === 'server' && marker?.kind === 'client') - - if (fileMatch || markerViolation) { - let exportNames: Array = [] - - try { - exportNames = getNamedExportsFromResult(transformResult) - } catch { - exportNames = [] - } - - if (config.command === 'build') { - return generateSelfContainedMockModule(exportNames) - } - - const runtimeId = ensureRuntimeMockModule({ - shared, - envName, - mode: config.mockAccess, - env: envName, - importer: file, - specifier: relativeFile, - }) - - return generateDevSelfDenialModule(exportNames, runtimeId) - } - - const deniedSpecifierReplacements = new Map() - let exportsBySource: Map> | undefined - const getExportsBySource = () => { - if (exportsBySource) { - return exportsBySource - } - - try { - exportsBySource = - getMockExportNamesBySourceFromResult(transformResult) - } catch { - exportsBySource = new Map>() - } - return exportsBySource - } - - for (const source of importSources) { - const specifierMatch = matchesAny(source, matchers.specifiers) - if (!specifierMatch) { - continue - } - - const resolved = await resolveAgainstImporter({ - envState, - config, - ctx, - importerId: id, - source, - extensionlessResolver, - perf, - }) - - const runtimeId = - config.command === 'build' - ? ensureSilentMockModule(shared, envName) - : ensureRuntimeMockModule({ - shared, - envName, - mode: config.mockAccess, - env: envName, - importer: file, - specifier: source, - }) - - const replacement = ensureMockEdgeModule({ - shared, - envName, - payload: { - exports: getExportsBySource().get(source) ?? [], - runtimeId, - violation: { - env: envName, - envType, - importer: file, - specifier: source, - ...(resolved ? { resolved } : {}), - patternText: serializePattern(specifierMatch.pattern), - }, - }, - }) - - deniedSpecifierReplacements.set(source, replacement) - } - - if (deniedSpecifierReplacements.size === 0) { - return ctx.code - } - - const rewritten = rewriteDeniedImports( - ctx.code, - id, - new Set(deniedSpecifierReplacements.keys()), - (source) => deniedSpecifierReplacements.get(source) ?? source, - ) - - if (!rewritten) { - return ctx.code - } - - return { - code: rewritten.code, - map: normalizeSourceMap(rewritten.map) ?? null, - } - } finally { - if (perf) { - perf.time('transform', startedAt) - } - } - }, - ) - } - api.processAssets( { stage: 'report', diff --git a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts index 2cd34b1f2e3..b0825d9d039 100644 --- a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts @@ -53,6 +53,72 @@ describe('getRsbuildResolvedImportProtectionCheck', () => { }) }) +describe('registerImportProtection loader registration', () => { + test('registers a post loader instead of an Rsbuild transform', () => { + let modifyRspackConfig: ((config: any, utils: any) => void) | undefined + const transform = vi.fn() + + registerImportProtection( + { + context: { action: 'build' }, + onBeforeBuild() {}, + onBeforeDevCompile() {}, + modifyRspackConfig(handler: (config: any, utils: any) => void) { + modifyRspackConfig = handler + }, + transform, + processAssets() {}, + } as any, + { + framework: 'react', + environments: [{ name: 'client', type: 'client' }], + getConfig: () => + ({ + startConfig: {}, + resolvedStartConfig: { + root: '/app', + srcDirectory: '/app/src', + }, + }) as any, + }, + ) + + if (!modifyRspackConfig) { + throw new Error('Expected modifyRspackConfig to be registered') + } + + class VirtualModulesPlugin { + writeModule() {} + } + + const config: any = { + module: { rules: [] }, + plugins: [], + } + modifyRspackConfig(config, { + environment: { name: 'client' }, + rspack: { + experiments: { VirtualModulesPlugin }, + }, + }) + + expect(transform).not.toHaveBeenCalled() + expect(config.module.rules).toHaveLength(1) + expect(config.module.rules[0]).toMatchObject({ + enforce: 'post', + use: [ + { + loader: expect.stringMatching(/import-protection-loader\.js$/), + options: { + envName: 'client', + }, + }, + ], + }) + expect(config.module.rules[0].test).toEqual(/\.[cm]?[tj]sx?$/) + }) +}) + describe('registerImportProtection marker scope', () => { async function runMarkerBuild(importerFiles: Array) { let beforeBuild: (() => void) | undefined diff --git a/packages/start-plugin-core/vite.config.ts b/packages/start-plugin-core/vite.config.ts index fc76204069d..39b3e69a9d0 100644 --- a/packages/start-plugin-core/vite.config.ts +++ b/packages/start-plugin-core/vite.config.ts @@ -21,6 +21,7 @@ export default mergeConfig( './src/vite/index.ts', './src/rsbuild/index.ts', './src/rsbuild/types.ts', + './src/rsbuild/import-protection-loader.ts', './src/rsbuild/start-compiler-metadata-loader.ts', ], srcDir: './src', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99fb327308b..18b98cba1f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14173,6 +14173,9 @@ importers: '@babel/types': specifier: ^7.28.5 version: 7.28.5 + '@jridgewell/remapping': + specifier: ^2.3.5 + version: 2.3.5 '@tanstack/router-core': specifier: workspace:* version: link:../router-core From 22bd8412eddd2688caac81d9dfc1759b6b6ad63f Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Thu, 27 Aug 2026 12:42:38 +0800 Subject: [PATCH 17/19] test(start): type Rsbuild import protection mocks --- .../src/rsbuild/import-protection.ts | 45 +++- .../tests/rsbuild/import-protection.test.ts | 192 +++++++++++------- 2 files changed, 163 insertions(+), 74 deletions(-) diff --git a/packages/start-plugin-core/src/rsbuild/import-protection.ts b/packages/start-plugin-core/src/rsbuild/import-protection.ts index 2539988f16b..9317f9fcdf8 100644 --- a/packages/start-plugin-core/src/rsbuild/import-protection.ts +++ b/packages/start-plugin-core/src/rsbuild/import-protection.ts @@ -57,18 +57,55 @@ import type { import type { Loc, TraceStep, ViolationInfo } from '../import-protection/trace' import type { CompileStartFrameworkOptions, GetConfigFn } from '../types' import type { + ModifyRspackConfigFn, RsbuildPluginAPI, Rspack, rspack as rspackNamespaceType, } from '@rsbuild/core' type RspackNamespace = typeof rspackNamespaceType -type RspackVirtualModulesPlugin = InstanceType< - RspackNamespace['experiments']['VirtualModulesPlugin'] +type RspackVirtualModulesPlugin = Pick< + InstanceType, + 'writeModule' > type ProcessAssetsContext = Parameters< Parameters[1] >[0] +type ModifyRspackConfig = Parameters[0] +type ModifyRspackConfigUtils = Parameters[1] +type ImportProtectionRspackConfig = { + module: Pick + plugins: Array +} +type ImportProtectionModifyRspackConfigUtils = { + environment: Pick + rspack: { + experiments: { + VirtualModulesPlugin: new ( + modules: Record, + ) => RspackVirtualModulesPlugin + } + } +} +type ImportProtectionRsbuildPluginAPI = { + context: Pick + onBeforeBuild: (handler: () => void) => void + onBeforeDevCompile: (handler: () => void) => void + modifyRspackConfig: ( + handler: ( + config: ImportProtectionRspackConfig, + utils: ImportProtectionModifyRspackConfigUtils, + ) => void, + ) => void + processAssets: RsbuildPluginAPI['processAssets'] +} +type ImportProtectionGetConfigFn = () => { + startConfig: Pick['startConfig'], 'importProtection'> + resolvedStartConfig: Pick< + ReturnType['resolvedStartConfig'], + 'root' | 'srcDirectory' + > +} type RspackCompilation = Rspack.Compilation type RspackModule = Rspack.Module type RspackDependency = Rspack.Dependency @@ -1282,9 +1319,9 @@ async function reportViolation(opts: { } export function registerImportProtection( - api: RsbuildPluginAPI, + api: ImportProtectionRsbuildPluginAPI, opts: { - getConfig: GetConfigFn + getConfig: ImportProtectionGetConfigFn framework: CompileStartFrameworkOptions environments: Array<{ name: string; type: 'client' | 'server' }> }, diff --git a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts index b0825d9d039..f7b676eb7d2 100644 --- a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts @@ -5,6 +5,53 @@ import { registerImportProtection, } from '../../src/rsbuild/import-protection' +type ImportProtectionApi = Parameters[0] +type ModifyRspackConfigHandler = Parameters< + ImportProtectionApi['modifyRspackConfig'] +>[0] +type ProcessAssetsHandler = Parameters[1] +type ProcessAssetsContext = Parameters[0] + +interface MockRspackModule { + buildInfo: Record + resourceResolveData: { path: string; resource: string } + identifier: () => string + originalSource: () => { + sourceAndMap: () => { + source: string + map: null + } + } +} + +interface MockRspackConnection { + dependency: { request: string } + module: MockRspackModule +} + +interface MockProcessAssetsContext { + environment: { name: string } + compilation: { + entries: Map + errors: Array + inputFileSystem: null + modules: Set + moduleGraph: { + getOutgoingConnectionsInOrder: ( + module: MockRspackModule, + ) => Array + } + warnings: Array + } + compiler: { rspack: Record } +} + +function asProcessAssetsContext( + context: MockProcessAssetsContext, +): ProcessAssetsContext { + return context as unknown as ProcessAssetsContext +} + describe('getRsbuildResolvedImportProtectionCheck', () => { test('skips file and marker checks for excluded resolved files', () => { const matchers = { @@ -55,56 +102,62 @@ describe('getRsbuildResolvedImportProtectionCheck', () => { describe('registerImportProtection loader registration', () => { test('registers a post loader instead of an Rsbuild transform', () => { - let modifyRspackConfig: ((config: any, utils: any) => void) | undefined + let modifyRspackConfig: ModifyRspackConfigHandler | undefined const transform = vi.fn() - registerImportProtection( - { - context: { action: 'build' }, - onBeforeBuild() {}, - onBeforeDevCompile() {}, - modifyRspackConfig(handler: (config: any, utils: any) => void) { - modifyRspackConfig = handler - }, - transform, - processAssets() {}, - } as any, - { - framework: 'react', - environments: [{ name: 'client', type: 'client' }], - getConfig: () => - ({ - startConfig: {}, - resolvedStartConfig: { - root: '/app', - srcDirectory: '/app/src', - }, - }) as any, + const api = { + context: { action: 'build' }, + onBeforeBuild() {}, + onBeforeDevCompile() {}, + modifyRspackConfig(handler) { + modifyRspackConfig = handler }, - ) + transform, + processAssets() {}, + } satisfies ImportProtectionApi & { transform: typeof transform } + + registerImportProtection(api, { + framework: 'react', + environments: [{ name: 'client', type: 'client' }], + getConfig: () => ({ + startConfig: {}, + resolvedStartConfig: { + root: '/app', + srcDirectory: '/app/src', + }, + }), + }) if (!modifyRspackConfig) { throw new Error('Expected modifyRspackConfig to be registered') } class VirtualModulesPlugin { - writeModule() {} + constructor(_modules: Record) {} + + writeModule(_filePath: string, _contents: string) {} } - const config: any = { + const config: Parameters[0] = { module: { rules: [] }, plugins: [], } - modifyRspackConfig(config, { + const utils: Parameters[1] = { environment: { name: 'client' }, rspack: { experiments: { VirtualModulesPlugin }, }, - }) + } + modifyRspackConfig(config, utils) expect(transform).not.toHaveBeenCalled() - expect(config.module.rules).toHaveLength(1) - expect(config.module.rules[0]).toMatchObject({ + const rules = config.module.rules + expect(rules).toHaveLength(1) + const rule = rules?.[0] + if (!rule || typeof rule !== 'object' || !('test' in rule)) { + throw new Error('Expected an import-protection Rspack rule') + } + expect(rule).toMatchObject({ enforce: 'post', use: [ { @@ -115,57 +168,51 @@ describe('registerImportProtection loader registration', () => { }, ], }) - expect(config.module.rules[0].test).toEqual(/\.[cm]?[tj]sx?$/) + expect(rule.test).toEqual(/\.[cm]?[tj]sx?$/) }) }) describe('registerImportProtection marker scope', () => { async function runMarkerBuild(importerFiles: Array) { let beforeBuild: (() => void) | undefined - let processAssetsHandler: ((context: any) => Promise) | undefined + let processAssetsHandler: ProcessAssetsHandler | undefined const onViolation = vi.fn(() => false) - registerImportProtection( - { - context: { action: 'build' }, - onBeforeBuild(handler: () => void) { - beforeBuild = handler + const api = { + context: { action: 'build' }, + onBeforeBuild(handler) { + beforeBuild = handler + }, + onBeforeDevCompile() {}, + modifyRspackConfig() {}, + processAssets(_options, handler) { + processAssetsHandler = handler + }, + } satisfies ImportProtectionApi + + registerImportProtection(api, { + framework: 'react', + environments: [{ name: 'client', type: 'client' }], + getConfig: () => ({ + startConfig: { + importProtection: { + ignoreImporters: ['**/ignored.ts'], + onViolation, + }, }, - onBeforeDevCompile() {}, - modifyRspackConfig() {}, - transform() {}, - processAssets( - _options: unknown, - handler: (context: any) => Promise, - ) { - processAssetsHandler = handler + resolvedStartConfig: { + root: '/app', + srcDirectory: '/app/src', }, - } as any, - { - framework: 'react', - environments: [{ name: 'client', type: 'client' }], - getConfig: () => - ({ - startConfig: { - importProtection: { - ignoreImporters: ['**/ignored.ts'], - onViolation, - }, - }, - resolvedStartConfig: { - root: '/app', - srcDirectory: '/app/src', - }, - }) as any, - }, - ) + }), + }) if (!beforeBuild || !processAssetsHandler) { throw new Error('Expected import-protection hooks to be registered') } beforeBuild() - const createModule = (file: string, marker = false) => ({ + const createModule = (file: string, marker = false): MockRspackModule => ({ buildInfo: marker ? { 'tanstack.start.importProtection': { @@ -186,7 +233,10 @@ describe('registerImportProtection marker scope', () => { const markedModule = createModule('/app/src/marked.ts', true) const importerModules = importerFiles.map((file) => createModule(file)) - const connectionsByModule = new Map( + const connectionsByModule = new Map< + MockRspackModule, + Array + >( importerModules.map((module) => [ module, [ @@ -198,7 +248,7 @@ describe('registerImportProtection marker scope', () => { ]), ) - await processAssetsHandler({ + const context: MockProcessAssetsContext = { environment: { name: 'client' }, compilation: { entries: new Map(), @@ -206,14 +256,16 @@ describe('registerImportProtection marker scope', () => { inputFileSystem: null, modules: new Set([...importerModules, markedModule]), moduleGraph: { - getOutgoingConnectionsInOrder(module: unknown) { - return connectionsByModule.get(module as any) ?? [] + getOutgoingConnectionsInOrder(module) { + return connectionsByModule.get(module) ?? [] }, }, warnings: [], }, compiler: { rspack: {} }, - }) + } + + await processAssetsHandler(asProcessAssetsContext(context)) return onViolation } From 20065a8b0f255e71f2762ea1b6d2fd0635031c8d Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Thu, 27 Aug 2026 12:47:36 +0800 Subject: [PATCH 18/19] fix(start): close Rsbuild import protection reporting gaps --- .../src/rsbuild/import-protection.ts | 41 ++++++---- .../tests/rsbuild/import-protection.test.ts | 80 +++++++++++++++---- 2 files changed, 91 insertions(+), 30 deletions(-) diff --git a/packages/start-plugin-core/src/rsbuild/import-protection.ts b/packages/start-plugin-core/src/rsbuild/import-protection.ts index 9317f9fcdf8..8e28e669605 100644 --- a/packages/start-plugin-core/src/rsbuild/import-protection.ts +++ b/packages/start-plugin-core/src/rsbuild/import-protection.ts @@ -552,16 +552,6 @@ function getModuleResource(module: RspackModule): string { } ).resourceResolveData - return normalizeFilePath(resourceResolveData?.resource ?? module.identifier()) -} - -function getModuleResourcePath(module: RspackModule): string { - const resourceResolveData = ( - module as RspackModule & { - resourceResolveData?: { path?: string; resource?: string } - } - ).resourceResolveData - return normalizeFilePath( resourceResolveData?.path ?? resourceResolveData?.resource ?? @@ -629,7 +619,6 @@ function buildTransformResultProvider(opts: { ): Promise { opts.perf?.count('processAssets.provider.modulesLoaded') const resource = getModuleResource(module) - const resourcePath = getModuleResourcePath(module) let code: string | undefined let map: SourceMapLike | undefined @@ -649,12 +638,12 @@ function buildTransformResultProvider(opts: { const originalCodeStartedAt = opts.perf ? performance.now() : 0 let originalCode = map?.sourcesContent - ? pickOriginalCodeFromSourcesContent(map, resourcePath, opts.root) + ? pickOriginalCodeFromSourcesContent(map, resource, opts.root) : undefined if (originalCode === undefined) { originalCode = await readModuleSourceFromInputFileSystem( opts.inputFileSystem, - resourcePath, + resource, ) if (originalCode !== undefined) { opts.perf?.count('processAssets.provider.inputFileSystemReads') @@ -719,9 +708,9 @@ function getCompilationModulesKey(importer: string, resolved: string): string { return `${importer}\0${resolved}` } -function addEntryModulesToGraph(opts: { +function forEachEntryModule(opts: { compilation: RspackCompilation - importGraph: ImportGraph + visitModule: (module: RspackModule) => void }): void { for (const entry of opts.compilation.entries.values()) { for (const dependency of entry.dependencies) { @@ -730,11 +719,23 @@ function addEntryModulesToGraph(opts: { if (!module) { continue } - opts.importGraph.addEntry(getModuleResource(module)) + opts.visitModule(module) } } } +function addEntryModulesToGraph(opts: { + compilation: RspackCompilation + importGraph: ImportGraph +}): void { + forEachEntryModule({ + compilation: opts.compilation, + visitModule(module) { + opts.importGraph.addEntry(getModuleResource(module)) + }, + }) +} + function forEachModules(opts: { compilation: RspackCompilation modules: Array @@ -788,6 +789,7 @@ interface MarkerCheckTarget { type FileViolation = Extract interface CompilationViolationScanner { + visitEntry: (module: RspackModule) => void visitNode: (node: RspackModuleGraphNode) => void finish: () => Array } @@ -818,6 +820,9 @@ function createCompilationViolationScanner(opts: { } return { + visitEntry(module) { + markerCheckTargets.push({ importer: module, module }) + }, visitNode(node) { const importer = getModuleResource(node.module) if (!isImportProtectionSourceFile(importer)) { @@ -1570,6 +1575,10 @@ export function registerImportProtection( matchers, shouldCheckImporter, }) + forEachEntryModule({ + compilation: context.compilation, + visitModule: violationScanner.visitEntry, + }) const forEachStartedAt = perf ? performance.now() : 0 const moduleGraphNodes: Array = [] forEachModules({ diff --git a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts index f7b676eb7d2..ab6cb645ca1 100644 --- a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts @@ -24,26 +24,37 @@ interface MockRspackModule { } } +interface MockRspackDependency { + request?: string +} + interface MockRspackConnection { - dependency: { request: string } + dependency: MockRspackDependency module: MockRspackModule } +interface MockRspackEntry { + dependencies: Array +} + interface MockProcessAssetsContext { environment: { name: string } compilation: { - entries: Map + entries: Map errors: Array inputFileSystem: null modules: Set moduleGraph: { + getConnection: ( + dependency: MockRspackDependency, + ) => MockRspackConnection | undefined getOutgoingConnectionsInOrder: ( module: MockRspackModule, ) => Array } warnings: Array } - compiler: { rspack: Record } + compiler: { rspack: { WebpackError: typeof Error } } } function asProcessAssetsContext( @@ -173,7 +184,14 @@ describe('registerImportProtection loader registration', () => { }) describe('registerImportProtection marker scope', () => { - async function runMarkerBuild(importerFiles: Array) { + async function runMarkerBuild( + importerFiles: Array, + options: { + markedModuleIsEntry?: boolean + importerResourceQuery?: string + reportBuildError?: boolean + } = {}, + ) { let beforeBuild: (() => void) | undefined let processAssetsHandler: ProcessAssetsHandler | undefined const onViolation = vi.fn(() => false) @@ -197,7 +215,7 @@ describe('registerImportProtection marker scope', () => { startConfig: { importProtection: { ignoreImporters: ['**/ignored.ts'], - onViolation, + onViolation: options.reportBuildError ? undefined : onViolation, }, }, resolvedStartConfig: { @@ -212,7 +230,11 @@ describe('registerImportProtection marker scope', () => { } beforeBuild() - const createModule = (file: string, marker = false): MockRspackModule => ({ + const createModule = ( + file: string, + marker = false, + resourceQuery = '', + ): MockRspackModule => ({ buildInfo: marker ? { 'tanstack.start.importProtection': { @@ -221,8 +243,8 @@ describe('registerImportProtection marker scope', () => { }, } : {}, - resourceResolveData: { path: file, resource: file }, - identifier: () => file, + resourceResolveData: { path: file, resource: `${file}${resourceQuery}` }, + identifier: () => `${file}${resourceQuery}`, originalSource: () => ({ sourceAndMap: () => ({ source: marker ? "import '@tanstack/react-start/server-only'" : '', @@ -232,7 +254,9 @@ describe('registerImportProtection marker scope', () => { }) const markedModule = createModule('/app/src/marked.ts', true) - const importerModules = importerFiles.map((file) => createModule(file)) + const importerModules = importerFiles.map((file) => + createModule(file, false, options.importerResourceQuery), + ) const connectionsByModule = new Map< MockRspackModule, Array @@ -247,37 +271,47 @@ describe('registerImportProtection marker scope', () => { ], ]), ) + const entryDependency: MockRspackDependency = { request: './marked' } + const entryConnection: MockRspackConnection = { + dependency: entryDependency, + module: markedModule, + } const context: MockProcessAssetsContext = { environment: { name: 'client' }, compilation: { - entries: new Map(), + entries: options.markedModuleIsEntry + ? new Map([['main', { dependencies: [entryDependency] }]]) + : new Map(), errors: [], inputFileSystem: null, modules: new Set([...importerModules, markedModule]), moduleGraph: { + getConnection(dependency) { + return dependency === entryDependency ? entryConnection : undefined + }, getOutgoingConnectionsInOrder(module) { return connectionsByModule.get(module) ?? [] }, }, warnings: [], }, - compiler: { rspack: {} }, + compiler: { rspack: { WebpackError: Error } }, } await processAssetsHandler(asProcessAssetsContext(context)) - return onViolation + return { errors: context.compilation.errors, onViolation } } test('skips marker violations imported only by an ignored importer', async () => { - const onViolation = await runMarkerBuild(['/app/src/ignored.ts']) + const { onViolation } = await runMarkerBuild(['/app/src/ignored.ts']) expect(onViolation).not.toHaveBeenCalled() }) test('reports a marker shared with a non-ignored importer', async () => { - const onViolation = await runMarkerBuild([ + const { onViolation } = await runMarkerBuild([ '/app/src/ignored.ts', '/app/src/entry.ts', ]) @@ -290,4 +324,22 @@ describe('registerImportProtection marker scope', () => { }), ) }) + + test('reports a marker imported by a resource-query module', async () => { + const { onViolation } = await runMarkerBuild(['/app/src/entry.ts'], { + importerResourceQuery: '?tsr-split=component', + }) + + expect(onViolation).toHaveBeenCalledTimes(1) + }) + + test('reports a marker violation when the marked module is an entry', async () => { + const { errors } = await runMarkerBuild([], { + markedModuleIsEntry: true, + reportBuildError: true, + }) + + expect(errors).toHaveLength(1) + expect(errors[0]?.message).toContain('@tanstack/react-start/server-only') + }) }) From fb379da7e1fa393cc4399e9ac43e0cf4661e9b69 Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Thu, 27 Aug 2026 12:56:58 +0800 Subject: [PATCH 19/19] fix(start): skip errored Rsbuild import protection modules --- .../src/rsbuild/import-protection.ts | 12 ++++++++++-- .../tests/rsbuild/import-protection.test.ts | 19 ++++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/start-plugin-core/src/rsbuild/import-protection.ts b/packages/start-plugin-core/src/rsbuild/import-protection.ts index 8e28e669605..c5e58ee6cf2 100644 --- a/packages/start-plugin-core/src/rsbuild/import-protection.ts +++ b/packages/start-plugin-core/src/rsbuild/import-protection.ts @@ -708,6 +708,10 @@ function getCompilationModulesKey(importer: string, resolved: string): string { return `${importer}\0${resolved}` } +function hasModuleError(module: RspackModule): boolean { + return 'error' in module && Boolean(module.error) +} + function forEachEntryModule(opts: { compilation: RspackCompilation visitModule: (module: RspackModule) => void @@ -716,7 +720,7 @@ function forEachEntryModule(opts: { for (const dependency of entry.dependencies) { const connection = opts.compilation.moduleGraph.getConnection(dependency) const module = connection?.module - if (!module) { + if (!module || hasModuleError(module)) { continue } opts.visitModule(module) @@ -744,6 +748,10 @@ function forEachModules(opts: { const nodes: Array = [] for (const module of opts.modules) { + if (hasModuleError(module)) { + continue + } + const imports: Array = [] const importIndexByModule = new WeakMap() const connections = @@ -756,7 +764,7 @@ function forEachModules(opts: { } // Only consider modules that are not errored - if ('error' in connectedModule && connectedModule.error) { + if (hasModuleError(connectedModule)) { continue } diff --git a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts index ab6cb645ca1..c419b2f6e88 100644 --- a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts @@ -14,6 +14,7 @@ type ProcessAssetsContext = Parameters[0] interface MockRspackModule { buildInfo: Record + error?: Error resourceResolveData: { path: string; resource: string } identifier: () => string originalSource: () => { @@ -188,6 +189,7 @@ describe('registerImportProtection marker scope', () => { importerFiles: Array, options: { markedModuleIsEntry?: boolean + importerError?: Error importerResourceQuery?: string reportBuildError?: boolean } = {}, @@ -234,6 +236,7 @@ describe('registerImportProtection marker scope', () => { file: string, marker = false, resourceQuery = '', + error?: Error, ): MockRspackModule => ({ buildInfo: marker ? { @@ -243,6 +246,7 @@ describe('registerImportProtection marker scope', () => { }, } : {}, + ...(error ? { error } : {}), resourceResolveData: { path: file, resource: `${file}${resourceQuery}` }, identifier: () => `${file}${resourceQuery}`, originalSource: () => ({ @@ -255,7 +259,12 @@ describe('registerImportProtection marker scope', () => { const markedModule = createModule('/app/src/marked.ts', true) const importerModules = importerFiles.map((file) => - createModule(file, false, options.importerResourceQuery), + createModule( + file, + false, + options.importerResourceQuery, + options.importerError, + ), ) const connectionsByModule = new Map< MockRspackModule, @@ -333,6 +342,14 @@ describe('registerImportProtection marker scope', () => { expect(onViolation).toHaveBeenCalledTimes(1) }) + test('skips marker violations from an errored importer module', async () => { + const { onViolation } = await runMarkerBuild(['/app/src/entry.ts'], { + importerError: new Error('Failed to build importer'), + }) + + expect(onViolation).not.toHaveBeenCalled() + }) + test('reports a marker violation when the marked module is an entry', async () => { const { errors } = await runMarkerBuild([], { markedModuleIsEntry: true,