From fa266c9717e2bd65b6b19c2cc995b0976af42425 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Fri, 23 Oct 2026 14:22:20 +0000 Subject: [PATCH 1/4] fix(start-plugin-core): restore Rsbuild import-protection mocks from persistent cache Rspack's persistent cache restores modules without rerunning loaders, so the import-protection loader never rewrote the virtual mock modules that restored importers reference, and warm builds and dev restarts failed with Module not found while dropping their violations. Regenerate a requested mock from its file name, which fully encodes its contents, before Rspack resolves it. --- ...uild-import-protection-persistent-cache.md | 5 ++ .../tests/import-protection.spec.ts | 67 +++++++++++++++++++ .../rsbuild/INTERNALS-import-protection.md | 5 ++ .../src/rsbuild/import-protection.ts | 49 ++++++++++++++ 4 files changed, 126 insertions(+) create mode 100644 .changeset/rsbuild-import-protection-persistent-cache.md diff --git a/.changeset/rsbuild-import-protection-persistent-cache.md b/.changeset/rsbuild-import-protection-persistent-cache.md new file mode 100644 index 00000000000..4f6baa05549 --- /dev/null +++ b/.changeset/rsbuild-import-protection-persistent-cache.md @@ -0,0 +1,5 @@ +--- +'@tanstack/start-plugin-core': patch +--- + +Fix Rsbuild import protection failing to resolve its mock modules when Rspack's persistent cache restores the modules that import them. diff --git a/e2e/react-start/import-protection/tests/import-protection.spec.ts b/e2e/react-start/import-protection/tests/import-protection.spec.ts index 16b5b073eb6..d4f33f229cc 100644 --- a/e2e/react-start/import-protection/tests/import-protection.spec.ts +++ b/e2e/react-start/import-protection/tests/import-protection.spec.ts @@ -1,10 +1,12 @@ import path from 'node:path' import fs from 'node:fs' +import os from 'node:os' import { expect } from '@playwright/test' import { test } from '@tanstack/router-e2e-utils' import { getViolationArtifactName } from './violations.utils' import type { Violation } from './violations.utils' import type { Page } from '@playwright/test' +import type { RsbuildPlugin } from '@rsbuild/core' const toolchain = process.env.E2E_TOOLCHAIN ?? 'vite' const fixtureRoot = path.resolve(import.meta.dirname, '..') @@ -1024,3 +1026,68 @@ for (const mode of ['build', 'dev', 'dev.warm'] as const) { expect(hit).toBeDefined() }) } + +test('persistent cache restores import-protection mocks and violations', async () => { + test.skip(toolchain !== 'rsbuild', 'Rspack persistent cache') + test.setTimeout(120_000) + const { createRsbuild, loadConfig, mergeRsbuildConfig } = + await import('@rsbuild/core') + const directory = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'tss-import-protection-cache-'), + ) + + const build = async () => { + const results: Record< + string, + { errors: Array; warnings: Array } + > = {} + const { content } = await loadConfig({ cwd: fixtureRoot }) + const rsbuild = await createRsbuild({ + cwd: fixtureRoot, + rsbuildConfig: mergeRsbuildConfig(content, { + root: fixtureRoot, + output: { distPath: { root: path.join(directory, 'dist') } }, + performance: { + buildCache: { cacheDirectory: path.join(directory, 'cache') }, + }, + plugins: [ + { + name: 'collect-import-protection-diagnostics', + setup(api) { + api.onAfterEnvironmentCompile(({ environment, stats }) => { + if (stats) { + results[environment.name] = { + errors: stats.compilation.errors.map((e) => e.message), + warnings: stats.compilation.warnings + .map((w) => w.message) + .sort(), + } + } + }) + }, + } satisfies RsbuildPlugin, + ], + }), + }) + await rsbuild.build().catch(() => undefined) + return results + } + + try { + const cold = await build() + const warm = await build() + + for (const environment of ['client', 'ssr']) { + expect(cold[environment]?.errors).toEqual([]) + expect(warm[environment]?.errors).toEqual([]) + expect(warm[environment]?.warnings).toEqual(cold[environment]?.warnings) + } + expect( + warm.client?.warnings.some((warning) => + warning.includes('secret.server'), + ), + ).toBe(true) + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }) + } +}) 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 eca4051281b..9fce8650da3 100644 --- a/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md +++ b/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md @@ -102,6 +102,11 @@ The important forms are: Writes may happen before the `VirtualModulesPlugin` instance is ready, so the adapter queues them and flushes during compilation setup. +Persistent cache restores importers without rerunning the loader that writes +their mocks. A `beforeResolve` hook therefore regenerates any requested mock in +the environment's virtual directory from its file name, which fully encodes its +contents. + ## Reporting Phase `processAssets({ stage: 'report' })` is the authoritative reporting step. diff --git a/packages/start-plugin-core/src/rsbuild/import-protection.ts b/packages/start-plugin-core/src/rsbuild/import-protection.ts index f9d46505e37..103c53d06f6 100644 --- a/packages/start-plugin-core/src/rsbuild/import-protection.ts +++ b/packages/start-plugin-core/src/rsbuild/import-protection.ts @@ -530,6 +530,44 @@ export function ensureMockEdgeModule(opts: { ) } +/** + * Persistent cache restores importers without rerunning the loader that writes + * their mocks, so recreate a mock from its file name when it is requested. + */ +function restoreVirtualMockModule( + shared: SharedState, + envName: string, + request: string, +): void { + if (!request.includes(IMPORT_PROTECTION_VIRTUAL_DIR)) { + return + } + + const directory = `${getVirtualModulePath(shared.root, envName, '')}/` + const filePath = normalizePath(request) + if (!filePath.startsWith(directory)) { + return + } + + const filename = filePath.slice(directory.length) + if (filename === MOCK_SILENT_FILE) { + ensureSilentMockModule(shared, envName) + return + } + + const match = /^(mock-edge-|mock-runtime-)([\w-]+)\.mjs$/.exec(filename) + if (match) { + tryWriteVirtualModule( + shared, + envName, + filePath, + match[1] === MOCK_EDGE_FILE_PREFIX + ? loadMockEdgeModule(match[2]!).code + : loadMockRuntimeModule(match[2]!).code, + ) + } +} + function getMockEdgePayloadFromFile( filePath: string, ): MockEdgePayload | undefined { @@ -1551,6 +1589,17 @@ export function registerImportProtection( flushPendingWrites(shared, envName) }, ) + compiler.hooks.normalModuleFactory.tap( + 'TanStackStartImportProtectionVirtualModulesRestore', + (normalModuleFactory) => { + normalModuleFactory.hooks.beforeResolve.tap( + 'TanStackStartImportProtectionVirtualModulesRestore', + (resolveData) => { + restoreVirtualMockModule(shared, envName, resolveData.request) + }, + ) + }, + ) }, }) if (perf) { From 13527941629b82d6abf3fa59a526def2ec2c0f7e Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 26 Oct 2026 16:03:30 +0000 Subject: [PATCH 2/4] perf(start-plugin-core): restore Rsbuild import-protection mocks only with a persistent cache --- .../src/rsbuild/INTERNALS-import-protection.md | 7 ++++--- .../start-plugin-core/src/rsbuild/import-protection.ts | 6 ++++++ 2 files changed, 10 insertions(+), 3 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 9fce8650da3..54cda864d25 100644 --- a/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md +++ b/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md @@ -103,9 +103,10 @@ Writes may happen before the `VirtualModulesPlugin` instance is ready, so the adapter queues them and flushes during compilation setup. Persistent cache restores importers without rerunning the loader that writes -their mocks. A `beforeResolve` hook therefore regenerates any requested mock in -the environment's virtual directory from its file name, which fully encodes its -contents. +their mocks. When the compiler uses a persistent cache, a `beforeResolve` hook +therefore regenerates any requested mock in the environment's virtual directory +from its file name, which fully encodes its contents. The hook is not registered +otherwise because a JavaScript `beforeResolve` tap runs on every resolve. ## Reporting Phase diff --git a/packages/start-plugin-core/src/rsbuild/import-protection.ts b/packages/start-plugin-core/src/rsbuild/import-protection.ts index 103c53d06f6..31f5e1fafcf 100644 --- a/packages/start-plugin-core/src/rsbuild/import-protection.ts +++ b/packages/start-plugin-core/src/rsbuild/import-protection.ts @@ -1589,6 +1589,12 @@ export function registerImportProtection( flushPendingWrites(shared, envName) }, ) + // A JS beforeResolve tap runs on every resolve; only a cache that + // outlives the process can restore importers whose mocks are missing. + const { cache } = compiler.options + if (typeof cache !== 'object' || cache.type === 'memory') { + return + } compiler.hooks.normalModuleFactory.tap( 'TanStackStartImportProtectionVirtualModulesRestore', (normalModuleFactory) => { From 536caf2f2e9a68b9c3d341b69424862de180fa7e Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Wed, 28 Oct 2026 17:54:55 +0800 Subject: [PATCH 3/4] test(start): verify cached import-protection diagnostics --- .../tests/import-protection.spec.ts | 49 ++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/e2e/react-start/import-protection/tests/import-protection.spec.ts b/e2e/react-start/import-protection/tests/import-protection.spec.ts index d4f33f229cc..1c937b1b92f 100644 --- a/e2e/react-start/import-protection/tests/import-protection.spec.ts +++ b/e2e/react-start/import-protection/tests/import-protection.spec.ts @@ -3,7 +3,10 @@ import fs from 'node:fs' import os from 'node:os' import { expect } from '@playwright/test' import { test } from '@tanstack/router-e2e-utils' -import { getViolationArtifactName } from './violations.utils' +import { + extractViolationsFromLog, + getViolationArtifactName, +} from './violations.utils' import type { Violation } from './violations.utils' import type { Page } from '@playwright/test' import type { RsbuildPlugin } from '@rsbuild/core' @@ -1039,7 +1042,11 @@ test('persistent cache restores import-protection mocks and violations', async ( const build = async () => { const results: Record< string, - { errors: Array; warnings: Array } + { + errors: Array + warnings: Array + importerBuilt?: boolean + } > = {} const { content } = await loadConfig({ cwd: fixtureRoot }) const rsbuild = await createRsbuild({ @@ -1056,7 +1063,22 @@ test('persistent cache restores import-protection mocks and violations', async ( setup(api) { api.onAfterEnvironmentCompile(({ environment, stats }) => { if (stats) { + const modules = stats.toJson({ + all: false, + modules: true, + nestedModules: true, + cachedModules: true, + orphanModules: true, + }).modules + const importer = modules + ?.flatMap((module) => [module, ...(module.modules ?? [])]) + .find((module) => + module.nameForCondition + ?.replaceAll('\\', '/') + .endsWith('/src/violations/leaky-server-import.ts'), + ) results[environment.name] = { + importerBuilt: importer?.built, errors: stats.compilation.errors.map((e) => e.message), warnings: stats.compilation.warnings .map((w) => w.message) @@ -1087,6 +1109,29 @@ test('persistent cache restores import-protection mocks and violations', async ( warning.includes('secret.server'), ), ).toBe(true) + // A warm build must actually reuse the importer containing mock requests. + expect(cold.client?.importerBuilt).toBe(true) + expect(warm.client?.importerBuilt).toBe(false) + + // These imports share a specifier but have different importer/usage metadata. + // Checking their presence prevents cold/warm equality hiding lost diagnostics. + const violations = extractViolationsFromLog( + warm.client!.warnings.join('\n'), + ) + for (const importer of ['leaky-server-import', 'beforeload-server-leak']) { + const violation = violations.find( + (info) => + info.type === 'specifier' && + info.specifier === '@tanstack/react-start/server' && + info.importer.includes(importer), + ) + expect(violation).toBeDefined() + expect(violation!.importer).toMatch(/:\d+:\d+$/) + expect( + violation!.trace.some((step) => step.file.includes(importer)), + ).toBe(true) + expect(violation!.snippet?.lines.join('\n')).toContain('getRequest()') + } } finally { await fs.promises.rm(directory, { recursive: true, force: true }) } From 1302c7e2c402472de926b0bb49801044512fa438 Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Wed, 28 Oct 2026 18:03:39 +0800 Subject: [PATCH 4/4] fix(start): build import-protection mocks with a dedicated loader --- .../rsbuild/INTERNALS-import-protection.md | 50 ++- .../src/rsbuild/import-protection-loader.ts | 42 +-- .../src/rsbuild/import-protection.ts | 293 ++---------------- .../src/rsbuild/mock-loader.ts | 26 ++ .../src/rsbuild/mock-module.ts | 72 +++++ .../start-plugin-core/src/rsbuild/mock.ts | 3 + .../tests/rsbuild/import-protection.test.ts | 32 +- packages/start-plugin-core/vite.config.ts | 2 + 8 files changed, 171 insertions(+), 349 deletions(-) create mode 100644 packages/start-plugin-core/src/rsbuild/mock-loader.ts create mode 100644 packages/start-plugin-core/src/rsbuild/mock-module.ts create mode 100644 packages/start-plugin-core/src/rsbuild/mock.ts 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 54cda864d25..74047fa12c9 100644 --- a/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md +++ b/packages/start-plugin-core/src/rsbuild/INTERNALS-import-protection.md @@ -8,7 +8,7 @@ import-protection core in `src/import-protection/INTERNALS.md`. Rsbuild owns: - post-transform enforcement through a Rspack post-loader -- virtual-module transport through `VirtualModulesPlugin` +- query-specific mock modules built through `mock-loader` - compilation-truth reporting in `processAssets` - final graph reconstruction from Rspack compilation data @@ -36,8 +36,8 @@ object: 3. `modifyRspackConfig` 4. `processAssets(..., { stage: 'report' })` -`modifyRspackConfig` installs both the virtual-modules plugin and the -environment-scoped import-protection post-loader. +`modifyRspackConfig` installs the mock loader and the environment-scoped +import-protection post-loader. ## State Model @@ -46,13 +46,6 @@ Per environment, Rsbuild keeps a smaller runtime state than Vite: - `resolveCache` - `seenViolations` -Shared adapter state contains: - -- `virtualModules` -- `vmPlugins` -- `readyVmPlugins` -- `pendingWrites` - Notably absent compared to Vite: - no `pendingViolations` @@ -83,30 +76,25 @@ 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 - -Rsbuild reuses the shared mock generators, but not the Vite id transport. - -Instead it writes environment-scoped virtual files under: - -```text -/node_modules/.virtual/import-protection// -``` +## Mock Module Transport -The important forms are: +Rsbuild reuses the shared mock generators through `mock-loader.ts`. The +import-protection post-loader only creates self-contained requests for the real, +package-owned `mock.js` entry, with the mock kind, environment, and encoded +payload in its query. The mock loader returns the module's source directly. -- `mock-silent.mjs` -- `mock-runtime-.mjs` -- `mock-edge-.mjs` +Kinds are `silent`, `runtime`, and `edge`. Edge payloads retain the expected +exports, runtime request, and violation metadata for compilation-time reporting. +The mock entry is excluded from the import-protection post-loader. -Writes may happen before the `VirtualModulesPlugin` instance is ready, so the -adapter queues them and flushes during compilation setup. +Persistent cache can restore either the importer or the mock independently. +When a mock needs rebuilding, its real entry still resolves and its own loader +regenerates it from the query. No importer-loader side effect needs replaying. -Persistent cache restores importers without rerunning the loader that writes -their mocks. When the compiler uses a persistent cache, a `beforeResolve` hook -therefore regenerates any requested mock in the environment's virtual directory -from its file name, which fully encodes its contents. The hook is not registered -otherwise because a JavaScript `beforeResolve` tap runs on every resolve. +Mock resources retain their complete query in the diagnostic graph and source +location lookup; using only the shared physical entry path would merge distinct +edges and lose their payloads. Ordinary resources keep their physical paths for +file rules and original-source lookup. ## Reporting Phase @@ -204,6 +192,6 @@ When changing Rsbuild import protection, ask: 1. Can the change be expressed in shared analysis/rewrite/mock codegen instead? 2. If it is adapter-specific, is it really about post-transform enforcement, - virtual-file transport, or compilation-time reporting? + mock-module transport, or compilation-time reporting? 3. Can final compilation truth answer the question more simply than adding a new pending state machine? diff --git a/packages/start-plugin-core/src/rsbuild/import-protection-loader.ts b/packages/start-plugin-core/src/rsbuild/import-protection-loader.ts index eba703f1212..c32f16e844e 100644 --- a/packages/start-plugin-core/src/rsbuild/import-protection-loader.ts +++ b/packages/start-plugin-core/src/rsbuild/import-protection-loader.ts @@ -23,20 +23,17 @@ import { } from '../import-protection/utils' import { IMPORT_PROTECTION_BUILD_INFO_FIELD, - ensureMockEdgeModule, - ensureRuntimeMockModule, - ensureSilentMockModule, getOrCreateEnvState, getRulesForEnvironment, serializePattern, shouldCheckImporterWithCache, } from './import-protection' +import { createMockModuleId, createRuntimeMockModuleId } from './mock-module' import type { EnvRuntimeState, ImportProtectionMarker, PerfCollector, PluginConfig, - SharedState, } from './import-protection' import type { ExtensionlessAbsoluteIdResolver } from '../import-protection/extensionlessAbsoluteIdResolver' import type { TransformResult } from '../import-protection/sourceLocation' @@ -49,7 +46,6 @@ export interface ImportProtectionLoaderOptions { envStates: Map extensionlessResolver: ExtensionlessAbsoluteIdResolver perf?: PerfCollector - shared: SharedState shouldCheckImporterCache: Map } @@ -123,7 +119,7 @@ async function transformImportProtection( options: ImportProtectionLoaderOptions, ): Promise { const startedAt = options.perf ? performance.now() : 0 - const { config, envName, perf, shared } = options + const { config, envName, perf } = options perf?.count('transform.calls') perf?.count(`transform.env.${envName}`) @@ -205,11 +201,9 @@ async function transformImportProtection( return generateSelfContainedMockModule(exportNames) } - const runtimeId = ensureRuntimeMockModule({ - shared, + const runtimeId = createRuntimeMockModuleId({ envName, mode: config.mockAccess, - env: envName, importer: file, specifier: relativeFile, }) @@ -251,30 +245,24 @@ async function transformImportProtection( const runtimeId = config.command === 'build' - ? ensureSilentMockModule(shared, envName) - : ensureRuntimeMockModule({ - shared, + ? createMockModuleId('silent', envName) + : createRuntimeMockModuleId({ 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), - }, + const replacement = createMockModuleId('edge', envName, { + exports: getExportsBySource().get(source) ?? [], + runtimeId, + violation: { + env: envName, + envType, + importer: file, + specifier: source, + ...(resolved ? { resolved } : {}), + patternText: serializePattern(specifierMatch.pattern), }, }) diff --git a/packages/start-plugin-core/src/rsbuild/import-protection.ts b/packages/start-plugin-core/src/rsbuild/import-protection.ts index 31f5e1fafcf..0d3ca3142e7 100644 --- a/packages/start-plugin-core/src/rsbuild/import-protection.ts +++ b/packages/start-plugin-core/src/rsbuild/import-protection.ts @@ -6,7 +6,7 @@ import { getDefaultImportProtectionRules, getMarkerSpecifiers, } from '../import-protection/defaults' -import { normalizePath } from '../utils' +import { escapeRegExp, normalizePath } from '../utils' import { ExtensionlessAbsoluteIdResolver } from '../import-protection/extensionlessAbsoluteIdResolver' import { compileMatchers } from '../import-protection/matchers' import { @@ -30,11 +30,6 @@ import { buildTrace, formatViolation, } from '../import-protection/trace' -import { - loadMockEdgeModule, - loadMockRuntimeModule, - loadSilentMockModule, -} from '../import-protection/virtualModules' import { buildSourceCandidates, clearNormalizeFilePathCache, @@ -43,6 +38,8 @@ import { isFileExcluded, normalizeFilePath, } from '../import-protection/utils' +import { MOCK_MODULE_PATH, getMockEdgePayload } from './mock-module' +import type { MockEdgePayload } from './mock-module' import type { ImportProtectionBehavior, ImportProtectionOptions, @@ -64,10 +61,6 @@ import type { } from '@rsbuild/core' type RspackNamespace = typeof rspackNamespaceType -type RspackVirtualModulesPlugin = Pick< - InstanceType, - 'writeModule' -> type ProcessAssetsContext = Parameters< Parameters[1] >[0] @@ -75,17 +68,9 @@ 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 @@ -234,14 +219,6 @@ export interface EnvRuntimeState { seenViolations: Set } -export interface SharedState { - root: string - virtualModules: Map - vmPlugins: Record - readyVmPlugins: Record - pendingWrites: Map> -} - interface CompilationEdge { importerModule: RspackModule specifier?: string @@ -278,19 +255,6 @@ interface RspackModuleGraphNode { imports: Array } -interface MockEdgePayload { - exports: Array - runtimeId: string - violation: { - env: string - envType: 'client' | 'server' - importer: string - specifier: string - resolved?: string - patternText: string - } -} - interface ModuleGraphEdge { importer: RspackModule module: RspackModule @@ -318,24 +282,12 @@ type ResolvedImportProtectionCheck = | { type: 'file'; fileMatch: FileMatchers['files'][number] } | { type: 'marker' } -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') -} - -function fromBase64Url(input: string): T { - return JSON.parse(Buffer.from(input, 'base64url').toString('utf8')) as T -} - export function getRulesForEnvironment( config: PluginConfig, envName: string, @@ -407,182 +359,6 @@ export function shouldCheckImporterWithCache(opts: { return result } -function getVirtualModulePath( - root: string, - envName: string, - filename: string, -): string { - return normalizePath( - resolvePath(root, IMPORT_PROTECTION_VIRTUAL_DIR, envName, filename), - ) -} - -function queuePendingWrite( - shared: SharedState, - envName: string, - filePath: string, - code: string, -): void { - let writes = shared.pendingWrites.get(envName) - if (!writes) { - writes = new Map() - shared.pendingWrites.set(envName, writes) - } - - writes.set(filePath, code) -} - -function tryWriteVirtualModule( - shared: SharedState, - envName: string, - filePath: string, - code: string, -): string { - const current = shared.virtualModules.get(filePath) - if (current === code) { - return filePath - } - - shared.virtualModules.set(filePath, code) - - const vmPlugin = shared.vmPlugins[envName] - if (!vmPlugin || !shared.readyVmPlugins[envName]) { - queuePendingWrite(shared, envName, filePath, code) - return filePath - } - - vmPlugin.writeModule(filePath, code) - return filePath -} - -function flushPendingWrites(shared: SharedState, envName: string): void { - const writes = shared.pendingWrites.get(envName) - if (!writes?.size || !shared.readyVmPlugins[envName]) { - return - } - - for (const [filePath, code] of writes) { - shared.vmPlugins[envName]?.writeModule(filePath, code) - writes.delete(filePath) - } - - if (writes.size === 0) { - shared.pendingWrites.delete(envName) - } -} - -export function ensureSilentMockModule( - shared: SharedState, - envName: string, -): string { - return tryWriteVirtualModule( - shared, - envName, - getVirtualModulePath(shared.root, envName, MOCK_SILENT_FILE), - loadSilentMockModule().code, - ) -} - -export function ensureRuntimeMockModule(opts: { - shared: SharedState - envName: string - mode: 'error' | 'warn' | 'off' - env: string - importer: string - specifier: string -}): string { - const encoded = toBase64Url({ - mode: opts.mode, - env: opts.env, - importer: opts.importer, - specifier: opts.specifier, - trace: [], - }) - - return tryWriteVirtualModule( - opts.shared, - opts.envName, - getVirtualModulePath( - opts.shared.root, - opts.envName, - `${MOCK_RUNTIME_FILE_PREFIX}${encoded}.mjs`, - ), - loadMockRuntimeModule(encoded).code, - ) -} - -export function ensureMockEdgeModule(opts: { - shared: SharedState - envName: string - payload: MockEdgePayload -}): string { - const encoded = toBase64Url(opts.payload) - - return tryWriteVirtualModule( - opts.shared, - opts.envName, - getVirtualModulePath( - opts.shared.root, - opts.envName, - `${MOCK_EDGE_FILE_PREFIX}${encoded}.mjs`, - ), - loadMockEdgeModule(encoded).code, - ) -} - -/** - * Persistent cache restores importers without rerunning the loader that writes - * their mocks, so recreate a mock from its file name when it is requested. - */ -function restoreVirtualMockModule( - shared: SharedState, - envName: string, - request: string, -): void { - if (!request.includes(IMPORT_PROTECTION_VIRTUAL_DIR)) { - return - } - - const directory = `${getVirtualModulePath(shared.root, envName, '')}/` - const filePath = normalizePath(request) - if (!filePath.startsWith(directory)) { - return - } - - const filename = filePath.slice(directory.length) - if (filename === MOCK_SILENT_FILE) { - ensureSilentMockModule(shared, envName) - return - } - - const match = /^(mock-edge-|mock-runtime-)([\w-]+)\.mjs$/.exec(filename) - if (match) { - tryWriteVirtualModule( - shared, - envName, - filePath, - match[1] === MOCK_EDGE_FILE_PREFIX - ? loadMockEdgeModule(match[2]!).code - : loadMockRuntimeModule(match[2]!).code, - ) - } -} - -function getMockEdgePayloadFromFile( - filePath: string, -): MockEdgePayload | undefined { - const match = /(?:^|[\\/])mock-edge-([^/\\]+)\.mjs$/.exec(filePath) - if (!match) { - return undefined - } - - try { - return fromBase64Url(match[1]!) - } catch { - return undefined - } -} - function getModuleResource(module: RspackModule): string { const resourceResolveData = ( module as RspackModule & { @@ -590,11 +366,16 @@ function getModuleResource(module: RspackModule): string { } ).resourceResolveData - return normalizeFilePath( + const file = normalizeFilePath( resourceResolveData?.path ?? resourceResolveData?.resource ?? module.identifier(), ) + // Mock queries carry both module identity and per-import diagnostic metadata. + if (file === MOCK_MODULE_PATH && resourceResolveData?.resource) { + return normalizePath(resourceResolveData.resource) + } + return file } const IMPORT_PROTECTION_PARSEABLE_EXTENSIONS = new Set([ @@ -860,7 +641,7 @@ function createCompilationViolationScanner(opts: { return cached ?? undefined } - const payload = getMockEdgePayloadFromFile(getModuleResource(module)) + const payload = getMockEdgePayload(getModuleResource(module)) mockPayloadByModule.set(module, payload ?? null) return payload } @@ -1414,20 +1195,11 @@ export function registerImportProtection( onViolation: undefined, } - const shared: SharedState = { - root: '', - virtualModules: new Map(), - vmPlugins: {}, - readyVmPlugins: {}, - pendingWrites: new Map(), - } - function applyUserConfig(): void { const { startConfig, resolvedStartConfig } = opts.getConfig() config.root = resolvedStartConfig.root config.srcDirectory = resolvedStartConfig.srcDirectory - shared.root = resolvedStartConfig.root const userOpts: ImportProtectionOptions | undefined = startConfig.importProtection @@ -1552,15 +1324,18 @@ export function registerImportProtection( return } - const VMP = utils.rspack.experiments.VirtualModulesPlugin - const vmPlugin = new VMP({}) - - shared.vmPlugins[envName] = vmPlugin - shared.readyVmPlugins[envName] = false - const rules = rspackConfig.module.rules ?? [] + const mockResource = new RegExp( + `^${escapeRegExp(MOCK_MODULE_PATH).replaceAll('/', '[\\\\/]')}$`, + ) + rules.push({ + test: mockResource, + type: 'javascript/esm', + use: [{ loader: resolvePath(currentDir, 'mock-loader.js') }], + }) rules.push({ test: /\.[cm]?[tj]sx?$/, + exclude: mockResource, enforce: 'post', use: [ { @@ -1571,7 +1346,6 @@ export function registerImportProtection( envStates, extensionlessResolver, perf, - shared, shouldCheckImporterCache, }, }, @@ -1579,35 +1353,6 @@ export function registerImportProtection( }) rspackConfig.module.rules = rules - rspackConfig.plugins.push(vmPlugin) - rspackConfig.plugins.push({ - apply(compiler: Rspack.Compiler) { - compiler.hooks.thisCompilation.tap( - 'TanStackStartImportProtectionVirtualModulesReady', - () => { - shared.readyVmPlugins[envName] = true - flushPendingWrites(shared, envName) - }, - ) - // A JS beforeResolve tap runs on every resolve; only a cache that - // outlives the process can restore importers whose mocks are missing. - const { cache } = compiler.options - if (typeof cache !== 'object' || cache.type === 'memory') { - return - } - compiler.hooks.normalModuleFactory.tap( - 'TanStackStartImportProtectionVirtualModulesRestore', - (normalModuleFactory) => { - normalModuleFactory.hooks.beforeResolve.tap( - 'TanStackStartImportProtectionVirtualModulesRestore', - (resolveData) => { - restoreVirtualMockModule(shared, envName, resolveData.request) - }, - ) - }, - ) - }, - }) if (perf) { perf.time('modifyRspackConfig', startedAt) } diff --git a/packages/start-plugin-core/src/rsbuild/mock-loader.ts b/packages/start-plugin-core/src/rsbuild/mock-loader.ts new file mode 100644 index 00000000000..c1c5d5d3f26 --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/mock-loader.ts @@ -0,0 +1,26 @@ +import { + loadMockEdgeModule, + loadMockRuntimeModule, + loadSilentMockModule, +} from '../import-protection/virtualModules' +import type { Rspack } from '@rsbuild/core' + +const mockLoader: Rspack.LoaderDefinition = function () { + const query = new URLSearchParams(this.resourceQuery.slice(1)) + const kind = query.get('tanstack-start-mock') + if (kind === 'silent') { + return loadSilentMockModule().code + } + const payload = query.get('payload') + if (payload) { + if (kind === 'edge') { + return loadMockEdgeModule(payload).code + } + if (kind === 'runtime') { + return loadMockRuntimeModule(payload).code + } + } + throw new Error('Invalid import-protection mock request') +} + +export default mockLoader diff --git a/packages/start-plugin-core/src/rsbuild/mock-module.ts b/packages/start-plugin-core/src/rsbuild/mock-module.ts new file mode 100644 index 00000000000..8b0ebdf4e9c --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/mock-module.ts @@ -0,0 +1,72 @@ +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { normalizePath } from '../utils' + +// Resolve at runtime so Vite does not rewrite the entry as an asset URL. +export const MOCK_MODULE_PATH = normalizePath( + resolve(dirname(fileURLToPath(import.meta.url)), 'mock.js'), +) + +export interface MockEdgePayload { + exports: Array + runtimeId: string + violation: { + env: string + envType: 'client' | 'server' + importer: string + specifier: string + resolved?: string + patternText: string + } +} + +export function createMockModuleId( + kind: 'silent' | 'runtime' | 'edge', + envName: string, + payload?: unknown, +): string { + const query = new URLSearchParams({ + 'tanstack-start-mock': kind, + env: envName, + }) + if (payload !== undefined) { + query.set( + 'payload', + Buffer.from(JSON.stringify(payload)).toString('base64url'), + ) + } + return `${MOCK_MODULE_PATH}?${query}` +} + +export function createRuntimeMockModuleId(opts: { + envName: string + mode: 'error' | 'warn' | 'off' + importer: string + specifier: string +}): string { + return createMockModuleId('runtime', opts.envName, { + mode: opts.mode, + env: opts.envName, + importer: opts.importer, + specifier: opts.specifier, + trace: [], + }) +} + +export function getMockEdgePayload( + resource: string, +): MockEdgePayload | undefined { + if (!resource.startsWith(`${MOCK_MODULE_PATH}?`)) { + return undefined + } + const query = new URLSearchParams(resource.slice(MOCK_MODULE_PATH.length + 1)) + const payload = query.get('payload') + if (query.get('tanstack-start-mock') !== 'edge' || !payload) { + return undefined + } + try { + return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) + } catch { + return undefined + } +} diff --git a/packages/start-plugin-core/src/rsbuild/mock.ts b/packages/start-plugin-core/src/rsbuild/mock.ts new file mode 100644 index 00000000000..576e8257b81 --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/mock.ts @@ -0,0 +1,3 @@ +// A real resource lets Rspack rebuild a cached importer's mock independently. +// mock-loader supplies the contents for each query-specific module. +export {} 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 c419b2f6e88..7e1aa394c09 100644 --- a/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/import-protection.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test, vi } from 'vitest' import { compileMatchers } from '../../src/import-protection/matchers' +import { MOCK_MODULE_PATH } from '../../src/rsbuild/mock-module' import { getRsbuildResolvedImportProtectionCheck, registerImportProtection, @@ -113,9 +114,8 @@ describe('getRsbuildResolvedImportProtectionCheck', () => { }) describe('registerImportProtection loader registration', () => { - test('registers a post loader instead of an Rsbuild transform', () => { + test('registers the post loader and mock module loader', () => { let modifyRspackConfig: ModifyRspackConfigHandler | undefined - const transform = vi.fn() const api = { context: { action: 'build' }, @@ -124,9 +124,8 @@ describe('registerImportProtection loader registration', () => { modifyRspackConfig(handler) { modifyRspackConfig = handler }, - transform, processAssets() {}, - } satisfies ImportProtectionApi & { transform: typeof transform } + } satisfies ImportProtectionApi registerImportProtection(api, { framework: 'react', @@ -144,28 +143,17 @@ describe('registerImportProtection loader registration', () => { 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] + expect(rules).toHaveLength(2) + const rule = rules?.[1] if (!rule || typeof rule !== 'object' || !('test' in rule)) { throw new Error('Expected an import-protection Rspack rule') } @@ -181,6 +169,16 @@ describe('registerImportProtection loader registration', () => { ], }) expect(rule.test).toEqual(/\.[cm]?[tj]sx?$/) + expect(rules?.[0]).toMatchObject({ + type: 'javascript/esm', + use: [{ loader: expect.stringMatching(/mock-loader\.js$/) }], + }) + const mockRule = rules[0] as { test: RegExp } + expect(mockRule.test.test(MOCK_MODULE_PATH)).toBe(true) + expect(mockRule.test.test(MOCK_MODULE_PATH.replaceAll('/', '\\'))).toBe( + true, + ) + expect(rule.exclude).toBe(mockRule.test) }) }) diff --git a/packages/start-plugin-core/vite.config.ts b/packages/start-plugin-core/vite.config.ts index 39b3e69a9d0..f9015a686c7 100644 --- a/packages/start-plugin-core/vite.config.ts +++ b/packages/start-plugin-core/vite.config.ts @@ -22,6 +22,8 @@ export default mergeConfig( './src/rsbuild/index.ts', './src/rsbuild/types.ts', './src/rsbuild/import-protection-loader.ts', + './src/rsbuild/mock-loader.ts', + './src/rsbuild/mock.ts', './src/rsbuild/start-compiler-metadata-loader.ts', ], srcDir: './src',