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/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 d3cc8c40177..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,16 +7,14 @@ 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 -- 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 -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 @@ -36,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 @@ -45,16 +45,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 adapter state contains: - `virtualModules` - `vmPlugins` - `readyVmPlugins` -- `inputFileSystems` - `pendingWrites` Notably absent compared to Vite: @@ -66,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 @@ -76,10 +75,13 @@ The transform phase is responsible for: - self-denial for forbidden files - self-denial for marker-protected files in the wrong environment +- persisting detected marker metadata in Rspack `module.buildInfo` - 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 @@ -106,27 +108,45 @@ 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 original source -6. reporting deferred file violations only when both importer and target truly - survived compilation +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 -This is the core Rsbuild-native replacement for Vite's `generateBundle` -verification plus dev pending-violation flow. +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. + +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. -## Why The Deferred Queue Is Narrow +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. -Rsbuild only needs explicit build deferral for file violations whose direct edge -may disappear after compilation. +Importer locations use this order: -Specifier violations are rediscovered from surviving mock-edge virtual files. -Marker violations are rediscovered from live compiled edges. +1. find unsafe usage in original code +2. find unsafe usage in compiled code +3. find the import statement in compiled, then original code -Only file violations need extra bookkeeping when the final compiled graph can no -longer show the original denied edge directly. +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. ## Source And Compilation APIs @@ -134,29 +154,43 @@ The Rsbuild adapter intentionally prefers native Rspack APIs where possible. Transform-time: -- `ctx.resource` -- `ctx.context` -- `ctx.resolve(...)` -- captured `compiler.inputFileSystem.readFile(...)` +- `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: -- `module.nameForCondition?.()` - `module.resourceResolveData?.resource` -- `module.originalSource().sourceAndMap()` +- `module.resourceResolveData?.path` +- `module.identifier()` (normalized fallback) +- `module.buildInfo` +- `module.originalSource().sourceAndMap()` (confirmed diagnostics only) - 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. +- `compilation.inputFileSystem.readFile()` (original-source fallback) +- `moduleGraph.getOutgoingConnectionsInOrder(module)` +- `connection.dependency.request` ## 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 original source while reporting compiled edges. +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 +dependency requests. ## Practical Maintainer Rule 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 d76dbe9aa96..c5e58ee6cf2 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,32 +8,20 @@ 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 { - findOriginalUnsafeUsagePosFromResult, - 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' @@ -42,24 +31,18 @@ import { formatViolation, } from '../import-protection/trace' import { - generateDevSelfDenialModule, - generateSelfContainedMockModule, loadMockEdgeModule, loadMockRuntimeModule, loadSilentMockModule, } from '../import-protection/virtualModules' import { - buildResolutionCandidates, buildSourceCandidates, - canonicalizeResolvedId, - checkFileDenial, clearNormalizeFilePathCache, dedupePatterns, dedupeViolationKey, isFileExcluded, normalizeFilePath, } from '../import-protection/utils' - import type { ImportProtectionBehavior, ImportProtectionOptions, @@ -74,30 +57,68 @@ 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 TransformContext = 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 RspackModuleGraphConnection = { - module?: RspackModule | null - dependency?: unknown - getActiveState?: (runtime: string | Array | undefined) => unknown +type RspackDependency = Rspack.Dependency +type RspackInputFileSystem = NonNullable + +export type ImportProtectionMarkerKind = 'server' | 'client' +export interface ImportProtectionMarker { + kind: ImportProtectionMarkerKind + source: string } -type OriginalCodeLoader = (file: string) => Promise -const importSpecifierLocationIndex = createImportSpecifierLocationIndex() + +export const IMPORT_PROTECTION_BUILD_INFO_FIELD = + 'tanstack.start.importProtection' type PerfTiming = { count: number @@ -105,7 +126,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 @@ -175,13 +196,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' @@ -208,36 +229,53 @@ interface PluginConfig { ) => boolean | void | Promise } -interface EnvRuntimeState { +export 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 { +export interface SharedState { root: string virtualModules: Map vmPlugins: Record readyVmPlugins: Record - inputFileSystems: Record pendingWrites: Map> } interface CompilationEdge { - importer: string + 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, + ) => Promise +} + +// An identity-only snapshot of one module's 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 { @@ -253,6 +291,29 @@ interface MockEdgePayload { } } +interface ModuleGraphEdge { + importer: RspackModule + module: RspackModule +} + +type CompilationViolation = + | { + type: 'specifier' + payload: MockEdgePayload + edge: ModuleGraphEdge + } + | { + type: 'file' + edge: ModuleGraphEdge + source: string + pattern: string | RegExp + } + | { + type: 'marker' + importer: RspackModule + source: string + } + type ResolvedImportProtectionCheck = | { type: 'file'; fileMatch: FileMatchers['files'][number] } | { type: 'marker' } @@ -261,6 +322,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') @@ -270,14 +336,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() } @@ -303,7 +369,7 @@ export function getRsbuildResolvedImportProtectionCheck( return { type: 'marker' } } -function getOrCreateEnvState( +export function getOrCreateEnvState( envStates: Map, envName: string, ): EnvRuntimeState { @@ -313,9 +379,6 @@ function getOrCreateEnvState( env = { resolveCache: new Map(), seenViolations: new Set(), - buildTransformResults: new Map(), - deferredFileViolations: [], - deferredFileViolationKeys: new Set(), } envStates.set(envName, env) } @@ -323,6 +386,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, @@ -387,7 +471,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, @@ -396,7 +483,7 @@ function ensureSilentMockModule(shared: SharedState, envName: string): string { ) } -function ensureRuntimeMockModule(opts: { +export function ensureRuntimeMockModule(opts: { shared: SharedState envName: string mode: 'error' | 'warn' | 'off' @@ -424,7 +511,7 @@ function ensureRuntimeMockModule(opts: { ) } -function ensureMockEdgeModule(opts: { +export function ensureMockEdgeModule(opts: { shared: SharedState envName: string payload: MockEdgePayload @@ -458,111 +545,20 @@ 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 - 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 | 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?: { path?: string; resource?: string } + } + ).resourceResolveData - return ( - candidate.nameForCondition() ?? - candidate.resourceResolveData?.resource ?? - candidate.resource ?? - candidate.userRequest ?? - candidate.request + return normalizeFilePath( + resourceResolveData?.path ?? + resourceResolveData?.resource ?? + module.identifier(), ) } -function getModuleFile(module: RspackModule): string { - return normalizeFilePath(getModuleResource(module) ?? module.identifier()) -} - const IMPORT_PROTECTION_PARSEABLE_EXTENSIONS = new Set([ '.ts', '.tsx', @@ -586,97 +582,73 @@ 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 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, - envType: 'client' | 'server', -): boolean { - if (!result) { - return false +function readModuleSourceFromInputFileSystem( + inputFileSystem: RspackInputFileSystem | null, + file: string, +): Promise { + if (!inputFileSystem) { + return Promise.resolve(undefined) } - const originalResult = getOrCreateOriginalTransformResult(result) - if (!originalResult) { - return false - } + return new Promise((resolve) => { + inputFileSystem.readFile(file, (error, data) => { + if (error || data == null) { + resolve(undefined) + return + } - return !!findOriginalUnsafeUsagePosFromResult(originalResult, source, envType) + resolve(String(data)) + }) + }) } -async function buildTransformResultProvider(opts: { - modules: Array +function buildTransformResultProvider(opts: { 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) + inputFileSystem: RspackInputFileSystem | null +}): CompilationTransformResultProvider { + const resultByModule = new WeakMap() + const loadingResultByModule = new WeakMap< + RspackModule, + Promise + >() + const missingSource = new WeakSet() + + async function loadModuleTransformResult( + module: RspackModule, + ): Promise { + opts.perf?.count('processAssets.provider.modulesLoaded') + const resource = getModuleResource(module) + let code: string | undefined + let map: SourceMapLike | undefined - for (const module of opts.modules) { const source = module.originalSource() - if (!source) continue - - const sourceAndMapStartedAt = opts.perf ? performance.now() : 0 - const sourceAndMap = source.sourceAndMap() - if (opts.perf) { - opts.perf.time( - 'processAssets.provider.sourceAndMap', - sourceAndMapStartedAt, - ) + if (source) { + const sourceAndMapStartedAt = opts.perf ? performance.now() : 0 + const sourceAndMap = source.sourceAndMap() + if (opts.perf) { + opts.perf.time( + 'processAssets.provider.sourceAndMap', + sourceAndMapStartedAt, + ) + } + code = String(sourceAndMap.source) + map = normalizeSourceMap(sourceAndMap.map as SourceMapLike | null) } - 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) ?? - (resource ? await opts.loadOriginalCode(resource) : undefined)) - : resource - ? await opts.loadOriginalCode(resource) - : undefined + let originalCode = map?.sourcesContent + ? pickOriginalCodeFromSourcesContent(map, resource, opts.root) + : undefined + if (originalCode === undefined) { + originalCode = await readModuleSourceFromInputFileSystem( + opts.inputFileSystem, + resource, + ) + if (originalCode !== undefined) { + opts.perf?.count('processAssets.provider.inputFileSystemReads') + } + } if (opts.perf) { opts.perf.time( 'processAssets.provider.originalCode', @@ -684,215 +656,507 @@ async function buildTransformResultProvider(opts: { ) } + code ??= originalCode + if (code === undefined) { + missingSource.add(module) + return undefined + } + const result: TransformResult = { code, - filename: resource ?? file, + filename: resource, map, originalCode, perf: opts.perf, } - - if (!hasTransformResult(cache, file)) { - addTransformResult(cache, file, result) - } - - if (resource && !hasTransformResult(cache, resource)) { - addTransformResult(cache, resource, result) - } + resultByModule.set(module, result) + return result } return { - getTransformResult(id: string) { - return cache.get(normalizePath(id)) ?? cache.get(normalizeFilePath(id)) + getTransformResult(module) { + if (missingSource.has(module)) { + return Promise.resolve(undefined) + } + + const cached = resultByModule.get(module) + if (cached) { + return Promise.resolve(cached) + } + + const loading = loadingResultByModule.get(module) + if (loading) { + return loading + } + + const result = loadModuleTransformResult(module) + loadingResultByModule.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: { +function hasModuleError(module: RspackModule): boolean { + return 'error' in module && Boolean(module.error) +} + +function forEachEntryModule(opts: { compilation: RspackCompilation - graph: ImportGraph + visitModule: (module: RspackModule) => void }): 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 || hasModuleError(module)) { + continue + } + opts.visitModule(module) } } } -function buildCompilationGraph(opts: { +function addEntryModulesToGraph(opts: { compilation: RspackCompilation - modules: Array -}): { - graph: ImportGraph - edges: Array - inactiveEdges: Array -} { - const graph = new ImportGraph() - const edges: Array = [] - const inactiveEdges: Array = [] - - addEntryModulesToGraph({ + importGraph: ImportGraph +}): void { + forEachEntryModule({ compilation: opts.compilation, - graph, + visitModule(module) { + opts.importGraph.addEntry(getModuleResource(module)) + }, }) +} + +function forEachModules(opts: { + compilation: RspackCompilation + modules: Array + visitNode: (node: RspackModuleGraphNode) => void +}): Array { + const nodes: Array = [] for (const module of opts.modules) { - const importer = getModuleFile(module) + if (hasModuleError(module)) { + continue + } + + const imports: Array = [] + const importIndexByModule = new WeakMap() 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 (hasModuleError(connectedModule)) { continue } - const resolved = getModuleFile(connection.module) - const specifier = getConnectionRequest(connection.dependency) - - if (isActiveConnection(connection)) { - graph.addEdge(resolved, importer, specifier) - edges.push({ importer, specifier, resolved }) - } else { - inactiveEdges.push({ importer, specifier, resolved }) + const existingImportIndex = importIndexByModule.get(connectedModule) + if (existingImportIndex !== undefined) { + continue } + + importIndexByModule.set(connectedModule, imports.length) + + imports.push({ + dependency: connection.dependency, + module: connectedModule, + }) } + + const node = { module, imports } + nodes.push(node) + opts.visitNode(node) } - return { graph, edges, inactiveEdges } + return nodes } -function isActiveConnection(connection: RspackModuleGraphConnection): boolean { - if (typeof connection.getActiveState !== 'function') { - return true - } +interface MarkerCheckTarget { + importer: RspackModule + module: RspackModule +} + +type FileViolation = Extract - return connection.getActiveState(undefined) === true +interface CompilationViolationScanner { + visitEntry: (module: RspackModule) => void + 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 specifierViolations: Array = [] + const fileViolations: Array = [] + const markerCheckTargets: Array = [] + 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 { + visitEntry(module) { + markerCheckTargets.push({ importer: module, module }) + }, + visitNode(node) { + const importer = getModuleResource(node.module) + if (!isImportProtectionSourceFile(importer)) { + return + } - const lineIndex = - originalResult.lineIndex ?? - (originalResult.lineIndex = buildLineIndex(originalResult.code)) - const loc = indexToLineColumn(lineIndex, index) + const shouldCheckImporter = opts.shouldCheckImporter(importer) - return { - file: normalizeFilePath(importer), - line: loc.line, - column: loc.column, - } -} + for (const imported of node.imports) { + const source = imported.dependency.request -async function resolveImporterLocation(opts: { - provider: TransformResultProvider - importLocCache: ImportLocCache - importer: string - sourceCandidates: Iterable - preferOriginalCode?: boolean - envType?: 'client' | 'server' -}): 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 (shouldCheckImporter) { + const payload = getMockPayload(imported.module) + if (payload?.violation.importer === importer) { + specifierViolations.push({ + type: 'specifier', + payload, + edge: { + importer: node.module, + module: imported.module, + }, + }) + } + } + + if (!source) { + continue + } + + const resolved = getModuleResource(imported.module) + const relativeResolved = getImportProtectionRelativePath( + opts.config.root, + resolved, ) - if (loc) { - return loc + const importProtectionCheck = getRsbuildResolvedImportProtectionCheck( + relativeResolved, + opts.matchers, + ) + if (!importProtectionCheck) { + continue + } + + if (importProtectionCheck.type === 'marker') { + markerCheckTargets.push({ + importer: node.module, + module: imported.module, + }) + continue + } + + if (shouldCheckImporter) { + fileViolations.push({ + type: 'file', + edge: { + importer: node.module, + module: imported.module, + }, + source, + pattern: importProtectionCheck.fileMatch.pattern, + }) + } } - } - } + }, + finish() { + const violations = [...specifierViolations, ...fileViolations] + const checkedMarkerModules = new WeakSet() - 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, - )) + for (const target of markerCheckTargets) { + if (!opts.shouldCheckImporter(getModuleResource(target.importer))) { + continue + } - if (loc) { - return loc - } - } + if (checkedMarkerModules.has(target.module)) { + continue + } + checkedMarkerModules.add(target.module) + + const marker = getMarkerForModule(target.module) + const violatesMarker = + (opts.envType === 'client' && marker?.kind === 'server') || + (opts.envType === 'server' && marker?.kind === 'client') + if (!violatesMarker) { + continue + } - if (!opts.preferOriginalCode) { - for (const candidate of opts.sourceCandidates) { - const loc = findImportLocationInOriginalCode( - opts.provider, - opts.importer, - candidate, - ) - if (loc) { - return loc + violations.push({ + type: 'marker', + importer: target.module, + source: marker.source, + }) } - } - } - return undefined + return violations + }, + } } -async function rebuildAndAnnotateTrace(opts: { - provider: TransformResultProvider - graph: ImportGraph - importLocCache: ImportLocCache - importer: string - specifier: string - importerLoc?: Loc - maxTraceDepth: number -}): Promise> { - const trace = buildTrace(opts.graph, opts.importer, opts.maxTraceDepth) +function buildCompilationImportGraph(opts: { + compilation: RspackCompilation + nodes: Array +}): CompilationImportGraph { + const importGraph = new ImportGraph() + const edges: Array = [] + const edgeByKey = new Map() + const edgesByModules = new Map>() - await addTraceImportLocations( - opts.provider, - trace, - opts.importLocCache, - importSpecifierLocationIndex.find, + 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, + } + 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 { + 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] +} + +const compilationImportSpecifierLocationIndex = + createImportSpecifierLocationIndex() + +async function resolveImporterLocation(opts: { + config: PluginConfig + provider: CompilationTransformResultProvider + importer: string + importerModule: RspackModule + source: string + resolved?: string + transformedSources?: Array + envType: 'client' | 'server' +}): Promise { + const transformResult = await opts.provider.getTransformResult( + opts.importerModule, + ) + const provider: TransformResultProvider = { + getTransformResult: () => transformResult, + } + 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 = + findOriginalUsageLocation( + provider, + opts.importer, + source, + opts.envType, + opts.config.root, + ) ?? + (await findPostCompileUsageLocation(provider, opts.importer, source)) ?? + (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 { + 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 + } + } + + return undefined +} + +async function rebuildAndAnnotateTrace(opts: { + root: string + provider: CompilationTransformResultProvider + importGraph: ImportGraph + edgeIndex: CompilationEdgeIndex + importer: string + specifier: string + importerLoc?: Loc + 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]! + const next = trace[i + 1]! + const edge = findCompilationEdge( + opts.edgeIndex, + step.file, + next.file, + step.specifier, + ) + const loc = edge + ? await resolveTraceEdgeLocation({ + root: opts.root, + provider: opts.provider, + importLocCache, + importer: step.file, + edge, + specifier: edge.specifier ?? step.specifier, + }) + : undefined + if (loc) { + step.line = loc.line + step.column = loc.column + } + } if (trace.length > 0) { const last = trace[trace.length - 1]! @@ -910,33 +1174,32 @@ 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 + transformedSources?: Array 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({ + config: opts.config, provider: opts.provider, - importLocCache: opts.importLocCache, importer: opts.importer, - sourceCandidates: buildSourceCandidates( - opts.source, - opts.resolved, - opts.config.root, - ), - preferOriginalCode: opts.preferOriginalCode, + importerModule: opts.importerModule, + source: opts.source, + resolved: opts.resolved, + transformedSources: opts.transformedSources, envType: opts.envType, }) if (opts.perf) { @@ -945,9 +1208,10 @@ async function buildViolationInfo(opts: { const traceStartedAt = opts.perf ? performance.now() : 0 const trace = await rebuildAndAnnotateTrace({ + root: opts.config.root, provider: opts.provider, - graph: opts.graph, - importLocCache: opts.importLocCache, + importGraph: opts.importGraph, + edgeIndex: opts.edgeIndex, importer: opts.importer, specifier: opts.source, importerLoc, @@ -958,8 +1222,17 @@ 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(opts.provider, opts.importer, importerLoc) + ? buildCodeSnippet( + { + getTransformResult: () => transformResult, + }, + opts.importer, + importerLoc, + ) : undefined if (opts.perf && importerLoc) { opts.perf.time('violations.snippet', snippetStartedAt) @@ -986,50 +1259,34 @@ async function buildViolationInfo(opts: { return info } -async function getMarkerKindForFile(opts: { - config: PluginConfig - provider: TransformResultProvider - loadOriginalCode: OriginalCodeLoader - markerKindCache: Map> - file: string -}): Promise<'server' | 'client' | undefined> { - if (!isImportProtectionSourceFile(opts.file)) { +function getMarkerForModule( + module: RspackModule, +): ImportProtectionMarker | undefined { + const file = getModuleResource(module) + if (!isImportProtectionSourceFile(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' - } + const marker = module.buildInfo[IMPORT_PROTECTION_BUILD_INFO_FIELD] + if (!marker || typeof marker !== 'object') { + return undefined + } - if (hasClientOnly && !hasServerOnly) { - return 'client' - } + if (!('kind' in marker) || !('source' in marker)) { + return undefined + } - return undefined - })() - opts.markerKindCache.set(opts.file, cached) + if ( + (marker.kind !== 'server' && marker.kind !== 'client') || + typeof marker.source !== 'string' + ) { + return undefined } - return cached + return { + kind: marker.kind, + source: marker.source, + } } async function reportViolation(opts: { @@ -1075,9 +1332,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' }> }, @@ -1085,9 +1342,7 @@ 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 = { enabled: true, root: '', @@ -1126,7 +1381,6 @@ export function registerImportProtection( virtualModules: new Map(), vmPlugins: {}, readyVmPlugins: {}, - inputFileSystems: {}, pendingWrites: new Map(), } @@ -1211,16 +1465,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(() => { @@ -1228,7 +1478,6 @@ export function registerImportProtection( applyUserConfig() clearNormalizeFilePathCache() extensionlessResolver.clear() - fileReadCache.clear() shouldCheckImporterCache.clear() envStates.clear() if (perf) { @@ -1241,14 +1490,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) @@ -1260,16 +1505,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 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) { - shared.inputFileSystems[envName] = compiler.inputFileSystem compiler.hooks.thisCompilation.tap( 'TanStackStartImportProtectionVirtualModulesReady', () => { @@ -1284,306 +1555,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 { - 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)) { - 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 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), - ) - const hasClientOnlyMarker = importSources.some((source) => - config.markerSpecifiers.clientOnly.has(source), - ) - - if (hasServerOnlyMarker && hasClientOnlyMarker) { - 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) - : undefined - - const fileMatch = checkFileDenial(relativeFile, matchers) - const markerViolation = - (envType === 'client' && markerKind === 'server') || - (envType === 'server' && markerKind === '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 (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 - } - - 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', @@ -1603,301 +1574,134 @@ 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, - ) perf?.count('processAssets.modules.total', allModules.length) - perf?.count('processAssets.modules.relevant', relevantModules.length) - - const providerStartedAt = perf ? performance.now() : 0 - const provider = await buildTransformResultProvider({ - modules: relevantModules, - root: config.root, - loadOriginalCode: loadOriginalCodeFromCompilation, - preloaded: envState.buildTransformResults, - perf, + + const violationScanner = createCompilationViolationScanner({ + config, + envType, + matchers, + shouldCheckImporter, }) - if (perf) { - perf.time('processAssets.provider.build', providerStartedAt) - } - const importLocCache = new ImportLocCache() - const markerKindCache = new Map< - string, - Promise<'server' | 'client' | undefined> - >() - const graphStartedAt = perf ? performance.now() : 0 - const { graph, edges, inactiveEdges } = buildCompilationGraph({ + forEachEntryModule({ + compilation: context.compilation, + visitModule: violationScanner.visitEntry, + }) + const forEachStartedAt = perf ? performance.now() : 0 + const moduleGraphNodes: Array = [] + forEachModules({ 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.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)}`, + perf.time('processAssets.forEachModules', forEachStartedAt) + perf.count('processAssets.modules.collected', moduleGraphNodes.length) + perf.count( + 'processAssets.imports.collected', + moduleGraphNodes.reduce( + (total, node) => total + node.imports.length, + 0, ), - ) - 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)) - 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, + inputFileSystem: context.compilation.inputFileSystem, + }) + 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, + transformedSources: [getModuleResource(candidate.edge.module)], + type: 'specifier', + pattern: payload.violation.patternText, }) - - await reportViolation({ + } else if (candidate.type === 'marker') { + const importer = getModuleResource(candidate.importer) + info = await buildViolationInfo({ config, - envState, - compilation: context.compilation, - rspack: context.compiler.rspack, + provider: getProvider(), + importGraph, + edgeIndex, perf, - info, + envName, + envType, + importer, + importerModule: candidate.importer, + source: candidate.source, + type: 'marker', }) - continue - } - - const markerKind = await getMarkerKindForFile({ - config, - provider, - loadOriginalCode: loadOriginalCodeFromCompilation, - 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, - compilation: context.compilation, - rspack: context.compiler.rspack, - perf, - 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) - if (!didModuleSurvive(edge.resolved)) { - continue - } - if (!didModuleSurvive(edge.importer)) { - continue - } - - 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({ + } else { + const { edge, source } = candidate + const importer = getModuleResource(edge.importer) + const resolved = getModuleResource(edge.module) + info = await buildViolationInfo({ config, - provider, - graph, - importLocCache, + provider: getProvider(), + importGraph, + edgeIndex, perf, envName, envType, - importer: edge.importer, - source: edge.specifier, - resolved: edge.resolved, + importer, + importerModule: edge.importer, + source, + resolved, type: 'file', - pattern: fileMatch.pattern, - preferOriginalCode: true, - }) - - await reportViolation({ - config, - envState, - compilation: context.compilation, - rspack: context.compiler.rspack, - perf, - info, + pattern: candidate.pattern, }) } - } - - 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, 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..c419b2f6e88 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,68 @@ -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' + +type ImportProtectionApi = Parameters[0] +type ModifyRspackConfigHandler = Parameters< + ImportProtectionApi['modifyRspackConfig'] +>[0] +type ProcessAssetsHandler = Parameters[1] +type ProcessAssetsContext = Parameters[0] + +interface MockRspackModule { + buildInfo: Record + error?: Error + resourceResolveData: { path: string; resource: string } + identifier: () => string + originalSource: () => { + sourceAndMap: () => { + source: string + map: null + } + } +} + +interface MockRspackDependency { + request?: string +} + +interface MockRspackConnection { + dependency: MockRspackDependency + module: MockRspackModule +} + +interface MockRspackEntry { + dependencies: Array +} + +interface MockProcessAssetsContext { + environment: { name: string } + compilation: { + entries: Map + errors: Array + inputFileSystem: null + modules: Set + moduleGraph: { + getConnection: ( + dependency: MockRspackDependency, + ) => MockRspackConnection | undefined + getOutgoingConnectionsInOrder: ( + module: MockRspackModule, + ) => Array + } + warnings: Array + } + compiler: { rspack: { WebpackError: typeof Error } } +} + +function asProcessAssetsContext( + context: MockProcessAssetsContext, +): ProcessAssetsContext { + return context as unknown as ProcessAssetsContext +} describe('getRsbuildResolvedImportProtectionCheck', () => { test('skips file and marker checks for excluded resolved files', () => { @@ -49,3 +111,252 @@ describe('getRsbuildResolvedImportProtectionCheck', () => { ).toEqual({ type: 'marker' }) }) }) + +describe('registerImportProtection loader registration', () => { + test('registers a post loader instead of an Rsbuild transform', () => { + let modifyRspackConfig: ModifyRspackConfigHandler | undefined + const transform = vi.fn() + + 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 { + constructor(_modules: Record) {} + + writeModule(_filePath: string, _contents: string) {} + } + + const config: Parameters[0] = { + module: { rules: [] }, + plugins: [], + } + const utils: Parameters[1] = { + environment: { name: 'client' }, + rspack: { + experiments: { VirtualModulesPlugin }, + }, + } + modifyRspackConfig(config, utils) + + expect(transform).not.toHaveBeenCalled() + 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: [ + { + loader: expect.stringMatching(/import-protection-loader\.js$/), + options: { + envName: 'client', + }, + }, + ], + }) + expect(rule.test).toEqual(/\.[cm]?[tj]sx?$/) + }) +}) + +describe('registerImportProtection marker scope', () => { + async function runMarkerBuild( + importerFiles: Array, + options: { + markedModuleIsEntry?: boolean + importerError?: Error + importerResourceQuery?: string + reportBuildError?: boolean + } = {}, + ) { + let beforeBuild: (() => void) | undefined + let processAssetsHandler: ProcessAssetsHandler | undefined + const onViolation = vi.fn(() => false) + + 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: options.reportBuildError ? undefined : onViolation, + }, + }, + resolvedStartConfig: { + root: '/app', + srcDirectory: '/app/src', + }, + }), + }) + + if (!beforeBuild || !processAssetsHandler) { + throw new Error('Expected import-protection hooks to be registered') + } + beforeBuild() + + const createModule = ( + file: string, + marker = false, + resourceQuery = '', + error?: Error, + ): MockRspackModule => ({ + buildInfo: marker + ? { + 'tanstack.start.importProtection': { + kind: 'server', + source: '@tanstack/react-start/server-only', + }, + } + : {}, + ...(error ? { error } : {}), + resourceResolveData: { path: file, resource: `${file}${resourceQuery}` }, + identifier: () => `${file}${resourceQuery}`, + 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, + false, + options.importerResourceQuery, + options.importerError, + ), + ) + const connectionsByModule = new Map< + MockRspackModule, + Array + >( + importerModules.map((module) => [ + module, + [ + { + dependency: { request: './marked' }, + module: markedModule, + }, + ], + ]), + ) + const entryDependency: MockRspackDependency = { request: './marked' } + const entryConnection: MockRspackConnection = { + dependency: entryDependency, + module: markedModule, + } + + const context: MockProcessAssetsContext = { + environment: { name: 'client' }, + compilation: { + 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: { WebpackError: Error } }, + } + + await processAssetsHandler(asProcessAssetsContext(context)) + + 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']) + + 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', + }), + ) + }) + + 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('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, + reportBuildError: true, + }) + + expect(errors).toHaveLength(1) + expect(errors[0]?.message).toContain('@tanstack/react-start/server-only') + }) +}) 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