diff --git a/e2e/react-start/import-protection-custom-config/.gitignore b/e2e/react-start/import-protection-custom-config/.gitignore new file mode 100644 index 00000000000..dd7ed9f0c82 --- /dev/null +++ b/e2e/react-start/import-protection-custom-config/.gitignore @@ -0,0 +1,23 @@ +node_modules +package-lock.json +yarn.lock +.DS_Store +.cache +.env +.vercel +.output +/build/ +/api/ +/server/build +/public/build +.env.sentry-build-plugin +/test-results/ +/playwright-report/ +/dist/ +*.log +violations.*.json +error-build-result.json +error-dev-result.json +# The root .gitignore ignores `lib` globally (build output). +# Override here so src/lib/ test fixtures are tracked. +!src/lib/ diff --git a/e2e/react-start/import-protection-custom-config/package.json b/e2e/react-start/import-protection-custom-config/package.json new file mode 100644 index 00000000000..baa3df4bfef --- /dev/null +++ b/e2e/react-start/import-protection-custom-config/package.json @@ -0,0 +1,31 @@ +{ + "name": "tanstack-react-start-e2e-import-protection-custom-config", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000", + "dev:e2e": "vite dev", + "build": "vite build && tsc --noEmit", + "start": "pnpx srvx --prod -s ../client dist/server/server.js", + "test:e2e:mockMode": "rm -rf port*.txt; playwright test --project=chromium", + "test:e2e:errorMode": "rm -rf port*.txt; BEHAVIOR=error playwright test --project=chromium", + "test:e2e": "pnpm run test:e2e:mockMode && pnpm run test:e2e:errorMode" + }, + "dependencies": { + "@tanstack/react-router": "workspace:^", + "@tanstack/react-start": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "vite": "^7.3.1" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "^22.10.2", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "srvx": "^0.11.7", + "typescript": "^5.7.2" + } +} diff --git a/e2e/react-start/import-protection-custom-config/playwright.config.ts b/e2e/react-start/import-protection-custom-config/playwright.config.ts new file mode 100644 index 00000000000..31a55ba3bc8 --- /dev/null +++ b/e2e/react-start/import-protection-custom-config/playwright.config.ts @@ -0,0 +1,44 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import { isErrorMode } from './tests/utils/isErrorMode' +import packageJson from './package.json' with { type: 'json' } + +const PORT = await getTestServerPort(packageJson.name) +const baseURL = `http://localhost:${PORT}` + +console.log('running in error mode:', isErrorMode.toString()) + +export default defineConfig({ + testDir: './tests', + workers: 1, + + globalSetup: isErrorMode + ? './tests/error-mode.setup.ts' + : './tests/violations.setup.ts', + + reporter: [['line']], + + use: { + baseURL, + }, + + ...(isErrorMode + ? {} + : { + webServer: { + command: `rm -f webserver-build.log violations.build.json violations.dev.json && VITE_SERVER_PORT=${PORT} pnpm build > webserver-build.log 2>&1 && PORT=${PORT} VITE_SERVER_PORT=${PORT} pnpm start`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + cwd: import.meta.dirname, + }, + }), + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + testMatch: isErrorMode ? 'error-mode.spec.ts' : 'custom-config.spec.ts', + }, + ], +}) diff --git a/e2e/react-start/import-protection-custom-config/src/lib/browser-api.frontend.ts b/e2e/react-start/import-protection-custom-config/src/lib/browser-api.frontend.ts new file mode 100644 index 00000000000..36cc0f2ef8e --- /dev/null +++ b/e2e/react-start/import-protection-custom-config/src/lib/browser-api.frontend.ts @@ -0,0 +1,10 @@ +/** + * This file contains browser-only APIs. + * It uses the custom `.frontend.ts` naming convention (NOT the default + * `.client.ts`) to mark it as client-only. + * + * The vite config denies `**\/*.frontend.*` in the server (SSR) environment. + */ +export function getBrowserInfo() { + return typeof window !== 'undefined' ? window.location.href : 'no-window' +} diff --git a/e2e/react-start/import-protection-custom-config/src/lib/credentials.backend.ts b/e2e/react-start/import-protection-custom-config/src/lib/credentials.backend.ts new file mode 100644 index 00000000000..6fd955aa8e5 --- /dev/null +++ b/e2e/react-start/import-protection-custom-config/src/lib/credentials.backend.ts @@ -0,0 +1,12 @@ +/** + * This file contains a secret that should only be available on the server. + * It uses the custom `.backend.ts` naming convention (NOT the default + * `.server.ts`) to mark it as server-only. + * + * The vite config denies `**\/*.backend.*` in the client environment. + */ +export const SECRET_KEY = 'custom-backend-secret-99999' + +export function getBackendSecret() { + return SECRET_KEY +} diff --git a/e2e/react-start/import-protection-custom-config/src/routeTree.gen.ts b/e2e/react-start/import-protection-custom-config/src/routeTree.gen.ts new file mode 100644 index 00000000000..44861559e35 --- /dev/null +++ b/e2e/react-start/import-protection-custom-config/src/routeTree.gen.ts @@ -0,0 +1,104 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as FrontendLeakRouteImport } from './routes/frontend-leak' +import { Route as BackendLeakRouteImport } from './routes/backend-leak' +import { Route as IndexRouteImport } from './routes/index' + +const FrontendLeakRoute = FrontendLeakRouteImport.update({ + id: '/frontend-leak', + path: '/frontend-leak', + getParentRoute: () => rootRouteImport, +} as any) +const BackendLeakRoute = BackendLeakRouteImport.update({ + id: '/backend-leak', + path: '/backend-leak', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/backend-leak': typeof BackendLeakRoute + '/frontend-leak': typeof FrontendLeakRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/backend-leak': typeof BackendLeakRoute + '/frontend-leak': typeof FrontendLeakRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/backend-leak': typeof BackendLeakRoute + '/frontend-leak': typeof FrontendLeakRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/backend-leak' | '/frontend-leak' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/backend-leak' | '/frontend-leak' + id: '__root__' | '/' | '/backend-leak' | '/frontend-leak' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + BackendLeakRoute: typeof BackendLeakRoute + FrontendLeakRoute: typeof FrontendLeakRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/frontend-leak': { + id: '/frontend-leak' + path: '/frontend-leak' + fullPath: '/frontend-leak' + preLoaderRoute: typeof FrontendLeakRouteImport + parentRoute: typeof rootRouteImport + } + '/backend-leak': { + id: '/backend-leak' + path: '/backend-leak' + fullPath: '/backend-leak' + preLoaderRoute: typeof BackendLeakRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + BackendLeakRoute: BackendLeakRoute, + FrontendLeakRoute: FrontendLeakRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/e2e/react-start/import-protection-custom-config/src/router.tsx b/e2e/react-start/import-protection-custom-config/src/router.tsx new file mode 100644 index 00000000000..9d87d8748b5 --- /dev/null +++ b/e2e/react-start/import-protection-custom-config/src/router.tsx @@ -0,0 +1,9 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + return createRouter({ + routeTree, + scrollRestoration: true, + }) +} diff --git a/e2e/react-start/import-protection-custom-config/src/routes/__root.tsx b/e2e/react-start/import-protection-custom-config/src/routes/__root.tsx new file mode 100644 index 00000000000..439c1a37d53 --- /dev/null +++ b/e2e/react-start/import-protection-custom-config/src/routes/__root.tsx @@ -0,0 +1,47 @@ +import { + createRootRoute, + HeadContent, + Link, + linkOptions, + Outlet, + Scripts, +} from '@tanstack/react-router' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + { title: 'Import Protection Custom Config E2E' }, + ], + }), + component: RootComponent, +}) + +const navLinks = linkOptions([ + { to: '/', label: 'Home' }, + { to: '/backend-leak', label: 'Backend Leak' }, + { to: '/frontend-leak', label: 'Frontend Leak' }, +]) + +function RootComponent() { + return ( + + + + + + + + + + + ) +} diff --git a/e2e/react-start/import-protection-custom-config/src/routes/backend-leak.tsx b/e2e/react-start/import-protection-custom-config/src/routes/backend-leak.tsx new file mode 100644 index 00000000000..9fbe83d4084 --- /dev/null +++ b/e2e/react-start/import-protection-custom-config/src/routes/backend-leak.tsx @@ -0,0 +1,18 @@ +import { createFileRoute } from '@tanstack/react-router' +// This import triggers a file-based violation in the CLIENT env: +// backend-leak.tsx -> lib/credentials.backend.ts +// The custom deny pattern `**/*.backend.*` should catch this. +import { getBackendSecret } from '../lib/credentials.backend' + +export const Route = createFileRoute('/backend-leak')({ + component: BackendLeakRoute, +}) + +function BackendLeakRoute() { + return ( +
+

Backend Leak

+

{String(getBackendSecret())}

+
+ ) +} diff --git a/e2e/react-start/import-protection-custom-config/src/routes/frontend-leak.tsx b/e2e/react-start/import-protection-custom-config/src/routes/frontend-leak.tsx new file mode 100644 index 00000000000..554cc189372 --- /dev/null +++ b/e2e/react-start/import-protection-custom-config/src/routes/frontend-leak.tsx @@ -0,0 +1,18 @@ +import { createFileRoute } from '@tanstack/react-router' +// This import triggers a file-based violation in the SERVER (SSR) env: +// frontend-leak.tsx -> lib/browser-api.frontend.ts +// The custom deny pattern `**/*.frontend.*` should catch this. +import { getBrowserInfo } from '../lib/browser-api.frontend' + +export const Route = createFileRoute('/frontend-leak')({ + component: FrontendLeakRoute, +}) + +function FrontendLeakRoute() { + return ( +
+

Frontend Leak

+

{String(getBrowserInfo())}

+
+ ) +} diff --git a/e2e/react-start/import-protection-custom-config/src/routes/index.tsx b/e2e/react-start/import-protection-custom-config/src/routes/index.tsx new file mode 100644 index 00000000000..89ba3024c46 --- /dev/null +++ b/e2e/react-start/import-protection-custom-config/src/routes/index.tsx @@ -0,0 +1,16 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: Home, +}) + +function Home() { + return ( +
+

Import Protection Custom Config E2E

+

+ App loaded successfully with custom file patterns +

+
+ ) +} diff --git a/e2e/react-start/import-protection-custom-config/tests/custom-config.spec.ts b/e2e/react-start/import-protection-custom-config/tests/custom-config.spec.ts new file mode 100644 index 00000000000..ce69c08d26a --- /dev/null +++ b/e2e/react-start/import-protection-custom-config/tests/custom-config.spec.ts @@ -0,0 +1,166 @@ +import path from 'node:path' +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import type { Violation } from './violations.utils' + +async function readViolations( + type: 'build' | 'dev', +): Promise> { + const filename = `violations.${type}.json` + const violationsPath = path.resolve(import.meta.dirname, '..', filename) + const mod = await import(violationsPath, { + with: { type: 'json' }, + } as any) + return (mod.default ?? []) as Array +} + +test.use({ + whitelistErrors: [/mock/i, /Cannot read properties/i, /undefined/i], +}) + +// ----------------------------------------------------------------------- +// App loads +// ----------------------------------------------------------------------- + +test('app loads successfully with custom config mock mode', async ({ + page, +}) => { + await page.goto('/') + await expect(page.getByTestId('heading')).toContainText( + 'Import Protection Custom Config E2E', + ) + await expect(page.getByTestId('status')).toContainText( + 'App loaded successfully with custom file patterns', + ) +}) + +test('backend leak route loads in mock mode', async ({ page }) => { + await page.goto('/backend-leak') + await expect(page.getByTestId('backend-leak-heading')).toContainText( + 'Backend Leak', + ) +}) + +test('frontend leak route loads in mock mode', async ({ page }) => { + await page.goto('/frontend-leak') + await expect(page.getByTestId('frontend-leak-heading')).toContainText( + 'Frontend Leak', + ) +}) + +// ----------------------------------------------------------------------- +// Build violations +// ----------------------------------------------------------------------- + +test('violations.build.json is written', async () => { + const violations = await readViolations('build') + expect(violations.length).toBeGreaterThan(0) +}) + +test('build: client violation for .backend. file (custom pattern)', async () => { + const violations = await readViolations('build') + + const backendViolation = violations.find( + (v) => + v.envType === 'client' && + v.type === 'file' && + (v.specifier.includes('credentials.backend') || + v.resolved?.includes('credentials.backend')), + ) + + expect(backendViolation).toBeDefined() + expect(backendViolation!.envType).toBe('client') +}) + +test('build: server violation for .frontend. file (custom pattern)', async () => { + const violations = await readViolations('build') + + const frontendViolation = violations.find( + (v) => + v.envType === 'server' && + v.type === 'file' && + (v.specifier.includes('browser-api.frontend') || + v.resolved?.includes('browser-api.frontend')), + ) + + expect(frontendViolation).toBeDefined() + expect(frontendViolation!.envType).toBe('server') +}) + +test('build: no false positives for default .server./.client. patterns', async () => { + const violations = await readViolations('build') + + // Since we use custom patterns, .server. and .client. should NOT trigger + // violations (they are not in the deny lists). + const defaultPatternViolation = violations.find( + (v) => + v.type === 'file' && + (v.resolved?.includes('.server.') || v.resolved?.includes('.client.')), + ) + + expect(defaultPatternViolation).toBeUndefined() +}) + +// ----------------------------------------------------------------------- +// Build: mock integrity (no real secrets in client JS bundle) +// ----------------------------------------------------------------------- + +test('build: client JS bundle does not contain real backend secret', async () => { + // Read all client JS chunks and verify the real secret string is absent. + // (The SSR-rendered HTML may contain the real value because the server env + // is allowed to use .backend. files — we only check the client JS here.) + const fs = await import('node:fs') + const path = await import('node:path') + const clientDir = path.resolve( + import.meta.dirname, + '..', + 'dist', + 'client', + 'assets', + ) + const jsFiles = fs.readdirSync(clientDir).filter((f) => f.endsWith('.js')) + const allClientCode = jsFiles + .map((f) => fs.readFileSync(path.join(clientDir, f), 'utf-8')) + .join('\n') + + expect(allClientCode).not.toContain('custom-backend-secret-99999') +}) + +// ----------------------------------------------------------------------- +// Dev violations +// ----------------------------------------------------------------------- + +test('violations.dev.json is written', async () => { + const violations = await readViolations('dev') + expect(violations.length).toBeGreaterThan(0) +}) + +test('dev: client violation for .backend. file (custom pattern)', async () => { + const violations = await readViolations('dev') + + const backendViolation = violations.find( + (v) => + v.envType === 'client' && + v.type === 'file' && + (v.specifier.includes('credentials.backend') || + v.resolved?.includes('credentials.backend')), + ) + + expect(backendViolation).toBeDefined() + expect(backendViolation!.envType).toBe('client') +}) + +test('dev: server violation for .frontend. file (custom pattern)', async () => { + const violations = await readViolations('dev') + + const frontendViolation = violations.find( + (v) => + v.envType === 'server' && + v.type === 'file' && + (v.specifier.includes('browser-api.frontend') || + v.resolved?.includes('browser-api.frontend')), + ) + + expect(frontendViolation).toBeDefined() + expect(frontendViolation!.envType).toBe('server') +}) diff --git a/e2e/react-start/import-protection-custom-config/tests/error-mode.setup.ts b/e2e/react-start/import-protection-custom-config/tests/error-mode.setup.ts new file mode 100644 index 00000000000..3e666ca1df6 --- /dev/null +++ b/e2e/react-start/import-protection-custom-config/tests/error-mode.setup.ts @@ -0,0 +1,150 @@ +import fs from 'node:fs' +import path from 'node:path' +import { execSync, spawn } from 'node:child_process' +import { chromium } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from '../package.json' with { type: 'json' } +import type { FullConfig } from '@playwright/test' + +async function waitForHttpOk(url: string, timeoutMs: number): Promise { + const start = Date.now() + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + if (Date.now() - start > timeoutMs) { + throw new Error(`Timed out waiting for ${url}`) + } + try { + const res = await fetch(url, { signal: AbortSignal.timeout(1000) }) + if (res.ok) return + } catch { + // ignore + } + await new Promise((r) => setTimeout(r, 200)) + } +} + +async function killChild(child: ReturnType): Promise { + if (child.exitCode !== null || child.killed) return + await new Promise((resolve) => { + let resolved = false + const done = () => { + if (resolved) return + resolved = true + resolve() + } + child.once('exit', done) + child.once('error', done) + try { + child.kill('SIGTERM') + } catch { + done() + return + } + setTimeout(() => { + try { + child.kill('SIGKILL') + } catch { + // ignore + } + setTimeout(done, 500) + }, 3000) + }) +} + +function captureBuild(cwd: string): void { + const outFile = path.resolve(cwd, 'error-build-result.json') + for (const f of ['error-build-result.json', 'error-build.log']) { + const p = path.resolve(cwd, f) + if (fs.existsSync(p)) fs.unlinkSync(p) + } + + let stdout = '' + let stderr = '' + let exitCode = 0 + + try { + const output = execSync('pnpm build', { + cwd, + env: { ...process.env, BEHAVIOR: 'error' }, + timeout: 120_000, + maxBuffer: 50 * 1024 * 1024, + }) + stdout = output.toString() + } catch (err: any) { + exitCode = err.status ?? 1 + stdout = err.stdout?.toString() ?? '' + stderr = err.stderr?.toString() ?? '' + } + + const combined = stdout + '\n' + stderr + fs.writeFileSync( + outFile, + JSON.stringify({ exitCode, stdout, stderr, combined }, null, 2), + ) +} + +async function captureDevErrors(cwd: string): Promise { + const port = await getTestServerPort(`${packageJson.name}_error_dev`) + const baseURL = `http://localhost:${port}` + const logChunks: Array = [] + + const child = spawn('pnpm', ['exec', 'vite', 'dev', '--port', String(port)], { + cwd, + env: { + ...process.env, + PORT: String(port), + VITE_SERVER_PORT: String(port), + BEHAVIOR: 'error', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + child.stdout?.on('data', (d: Buffer) => logChunks.push(d.toString())) + child.stderr?.on('data', (d: Buffer) => logChunks.push(d.toString())) + + try { + await waitForHttpOk(baseURL, 30_000) + + const browser = await chromium.launch() + try { + const context = await browser.newContext() + const page = await context.newPage() + for (const route of ['/', '/backend-leak', '/frontend-leak']) { + try { + await page.goto(`${baseURL}${route}`, { + waitUntil: 'load', + timeout: 15_000, + }) + } catch { + // ignore + } + await new Promise((r) => setTimeout(r, 750)) + } + await context.close() + } finally { + await browser.close() + } + + await new Promise((r) => setTimeout(r, 750)) + } finally { + await killChild(child) + } + + const combined = logChunks.join('') + fs.writeFileSync( + path.resolve(cwd, 'error-dev-result.json'), + JSON.stringify( + { exitCode: 0, stdout: combined, stderr: '', combined }, + null, + 2, + ), + ) +} + +export default async function globalSetup(config: FullConfig) { + void config + const cwd = path.resolve(import.meta.dirname, '..') + + captureBuild(cwd) + await captureDevErrors(cwd) +} diff --git a/e2e/react-start/import-protection-custom-config/tests/error-mode.spec.ts b/e2e/react-start/import-protection-custom-config/tests/error-mode.spec.ts new file mode 100644 index 00000000000..f699b9d4a1e --- /dev/null +++ b/e2e/react-start/import-protection-custom-config/tests/error-mode.spec.ts @@ -0,0 +1,78 @@ +import path from 'node:path' +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' + +import { extractViolationsFromLog, stripAnsi } from './violations.utils' + +interface ErrorResult { + exitCode?: number + stdout?: string + stderr?: string + combined: string +} + +async function readResult(name: string): Promise { + const p = path.resolve(import.meta.dirname, '..', name) + const mod: { default: ErrorResult } = await import(p, { + with: { type: 'json' }, + }) + return mod.default +} + +// Build error mode tests + +test('build fails with non-zero exit code in error mode', async () => { + const result = await readResult('error-build-result.json') + expect(result.exitCode).not.toBe(0) +}) + +test('build output contains import-protection violation', async () => { + const result = await readResult('error-build-result.json') + const text = stripAnsi(result.combined) + expect(text).toContain('[import-protection] Import denied in') +}) + +test('build violation mentions environment', async () => { + const result = await readResult('error-build-result.json') + const text = stripAnsi(result.combined) + const hasClient = text.includes('Import denied in client environment') + const hasServer = text.includes('Import denied in server environment') + expect(hasClient || hasServer).toBe(true) +}) + +test('build violation references custom file pattern', async () => { + const result = await readResult('error-build-result.json') + const text = stripAnsi(result.combined) + // The violation should reference .backend. or .frontend. files + const hasBackend = text.includes('credentials.backend') + const hasFrontend = text.includes('browser-api.frontend') + expect(hasBackend || hasFrontend).toBe(true) +}) + +test('build violation is parseable', async () => { + const result = await readResult('error-build-result.json') + const violations = extractViolationsFromLog(result.combined) + expect(violations.length).toBeGreaterThanOrEqual(1) + + const v = violations[0] + expect(v.envType).toMatch(/^(client|server)$/) + expect(v.importer).toBeTruthy() + expect(v.specifier).toBeTruthy() + expect(v.type).toBe('file') +}) + +// Dev error mode tests + +test('dev server logs contain import-protection error', async () => { + const result = await readResult('error-dev-result.json') + const text = stripAnsi(result.combined) + expect(text).toContain('[import-protection] Import denied in') +}) + +test('dev error violation references custom file pattern', async () => { + const result = await readResult('error-dev-result.json') + const text = stripAnsi(result.combined) + const hasBackend = text.includes('credentials.backend') + const hasFrontend = text.includes('browser-api.frontend') + expect(hasBackend || hasFrontend).toBe(true) +}) diff --git a/e2e/react-start/import-protection-custom-config/tests/utils/isErrorMode.ts b/e2e/react-start/import-protection-custom-config/tests/utils/isErrorMode.ts new file mode 100644 index 00000000000..0459af3b057 --- /dev/null +++ b/e2e/react-start/import-protection-custom-config/tests/utils/isErrorMode.ts @@ -0,0 +1 @@ +export const isErrorMode: boolean = process.env.BEHAVIOR === 'error' diff --git a/e2e/react-start/import-protection-custom-config/tests/violations.setup.ts b/e2e/react-start/import-protection-custom-config/tests/violations.setup.ts new file mode 100644 index 00000000000..c5187fe0741 --- /dev/null +++ b/e2e/react-start/import-protection-custom-config/tests/violations.setup.ts @@ -0,0 +1,179 @@ +import fs from 'node:fs' +import path from 'node:path' +import { spawn } from 'node:child_process' +import { chromium } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from '../package.json' with { type: 'json' } + +import { extractViolationsFromLog } from './violations.utils' +import type { FullConfig } from '@playwright/test' +import type { Violation } from './violations.utils' + +async function waitForHttpOk(url: string, timeoutMs: number): Promise { + const start = Date.now() + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + if (Date.now() - start > timeoutMs) { + throw new Error(`Timed out waiting for ${url}`) + } + + try { + const res = await fetch(url, { + signal: AbortSignal.timeout(1000), + }) + if (res.ok) return + } catch { + // ignore + } + + await new Promise((r) => setTimeout(r, 200)) + } +} + +function startDevServer(cwd: string, port: number): ReturnType { + return spawn('pnpm', ['exec', 'vite', 'dev', '--port', String(port)], { + cwd, + env: { + ...process.env, + PORT: String(port), + VITE_SERVER_PORT: String(port), + VITE_NODE_ENV: 'test', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) +} + +async function killChild(child: ReturnType): Promise { + if (child.exitCode !== null || child.killed) return + await new Promise((resolve) => { + let resolved = false + const done = () => { + if (resolved) return + resolved = true + resolve() + } + child.once('exit', done) + child.once('error', done) + try { + child.kill('SIGTERM') + } catch { + done() + return + } + setTimeout(() => { + try { + child.kill('SIGKILL') + } catch { + // ignore + } + setTimeout(done, 500) + }, 3000) + }) +} + +const routes = ['/', '/backend-leak', '/frontend-leak'] as const + +const routeReadyTestIds: Record = { + '/': 'heading', + '/backend-leak': 'backend-leak-heading', + '/frontend-leak': 'frontend-leak-heading', +} + +async function navigateAllRoutes( + baseURL: string, + browser: Awaited>, +): Promise { + const context = await browser.newContext() + const page = await context.newPage() + + for (const route of routes) { + try { + try { + await page.goto(`${baseURL}${route}`, { + waitUntil: 'networkidle', + timeout: 15_000, + }) + } catch { + await page.goto(`${baseURL}${route}`, { + waitUntil: 'load', + timeout: 30_000, + }) + } + + const testId = routeReadyTestIds[route] + if (testId) { + await page.getByTestId(testId).waitFor({ timeout: 10_000 }) + } + } catch { + // ignore navigation errors — we only care about server logs + } finally { + await new Promise((r) => setTimeout(r, 750)) + } + } + + await context.close() +} + +async function runDevPass( + cwd: string, + port: number, +): Promise> { + const baseURL = `http://localhost:${port}` + const logChunks: Array = [] + const child = startDevServer(cwd, port) + + child.stdout?.on('data', (d: Buffer) => logChunks.push(d.toString())) + child.stderr?.on('data', (d: Buffer) => logChunks.push(d.toString())) + + try { + await waitForHttpOk(baseURL, 30_000) + + const browser = await chromium.launch() + try { + await navigateAllRoutes(baseURL, browser) + } finally { + await browser.close() + } + + await new Promise((r) => setTimeout(r, 750)) + } finally { + await killChild(child) + } + + const text = logChunks.join('') + return extractViolationsFromLog(text) +} + +async function captureDevViolations(cwd: string): Promise { + const port = await getTestServerPort(`${packageJson.name}_dev`) + + const coldViolations = await runDevPass(cwd, port) + + fs.writeFileSync( + path.resolve(cwd, 'violations.dev.json'), + JSON.stringify(coldViolations, null, 2), + ) +} + +export default async function globalSetup(config: FullConfig) { + void config + const cwd = path.resolve(import.meta.dirname, '..') + + // webServer.command writes build output to this file. + const logFile = path.resolve(cwd, 'webserver-build.log') + + if (!fs.existsSync(logFile)) { + fs.writeFileSync(path.resolve(cwd, 'violations.build.json'), '[]') + return + } + + const text = fs.readFileSync(logFile, 'utf-8') + const violations = extractViolationsFromLog(text) + fs.writeFileSync( + path.resolve(cwd, 'violations.build.json'), + JSON.stringify(violations, null, 2), + ) + + await captureDevViolations(cwd) +} diff --git a/e2e/react-start/import-protection-custom-config/tests/violations.utils.ts b/e2e/react-start/import-protection-custom-config/tests/violations.utils.ts new file mode 100644 index 00000000000..e2560fcde84 --- /dev/null +++ b/e2e/react-start/import-protection-custom-config/tests/violations.utils.ts @@ -0,0 +1,111 @@ +export type TraceStep = { + file: string + specifier?: string + line?: number + column?: number +} + +export type CodeSnippet = { + lines: Array + location?: string +} + +export type Violation = { + envType?: string + type: string + specifier: string + importer: string + resolved?: string + trace: Array + snippet?: CodeSnippet + message?: string +} + +export function stripAnsi(input: string): string { + // eslint-disable-next-line no-control-regex + return input.replace(/\u001b\[[0-9;]*m/g, '') +} + +export function extractViolationsFromLog(text: string): Array { + const out: Array = [] + const lines = stripAnsi(text).split(/\r?\n/) + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] ?? '' + if (!line.includes('[import-protection] Import denied in')) continue + + const envTypeMatch = line.match( + /Import denied in\s+(client|server)\s+environment/, + ) + const envType = envTypeMatch?.[1] + + const block: Array = [line] + for (let j = i + 1; j < Math.min(lines.length, i + 200); j++) { + const l = lines[j] ?? '' + if (l.includes('[import-protection] Import denied in') && j !== i + 1) { + break + } + block.push(l) + } + + const importerLine = block.find((b) => + b.trimStart().startsWith('Importer:'), + ) + const specLine = block.find((b) => b.trimStart().startsWith('Import:')) + const resolvedLine = block.find((b) => + b.trimStart().startsWith('Resolved:'), + ) + + const importer = importerLine + ? importerLine.split('Importer:')[1]!.trim() + : '' + const specifier = specLine + ? specLine.split('Import:')[1]!.trim().replace(/^"|"$/g, '') + : '' + const resolved = resolvedLine + ? resolvedLine.split('Resolved:')[1]!.trim() + : undefined + + const typeLine = block.find((b) => b.trimStart().startsWith('Denied by')) + const type = typeLine?.includes('marker') + ? 'marker' + : typeLine?.includes('specifier') + ? 'specifier' + : typeLine?.includes('file') + ? 'file' + : 'unknown' + + const trace: Array = [] + const traceStart = block.findIndex((b) => b.trim() === 'Trace:') + if (traceStart !== -1) { + for (let k = traceStart + 1; k < block.length; k++) { + const l = block[k] ?? '' + const m = l.match( + /^\s*\d+\.\s+(.*?)(?:\s+\(entry\))?\s*(?:\(import "(.*)"\))?\s*$/, + ) + if (m) { + const rawFile = m[1]!.trim() + const spec = m[2]?.trim() + + const locMatch = rawFile.match(/^(.*?):(\d+):(\d+)$/) + if (locMatch) { + trace.push({ + file: locMatch[1]!, + line: Number(locMatch[2]), + column: Number(locMatch[3]), + specifier: spec, + }) + } else { + trace.push({ file: rawFile, specifier: spec }) + } + } else if (l.trim() === '' || l.match(/^\s*$/)) { + break + } + } + } + + out.push({ envType, type, specifier, importer, resolved, trace }) + } + + return out +} diff --git a/e2e/react-start/import-protection-custom-config/tsconfig.json b/e2e/react-start/import-protection-custom-config/tsconfig.json new file mode 100644 index 00000000000..8b1cd45e855 --- /dev/null +++ b/e2e/react-start/import-protection-custom-config/tsconfig.json @@ -0,0 +1,19 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2022", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "noEmit": true + } +} diff --git a/e2e/react-start/import-protection-custom-config/vite.config.ts b/e2e/react-start/import-protection-custom-config/vite.config.ts new file mode 100644 index 00000000000..670af29b24f --- /dev/null +++ b/e2e/react-start/import-protection-custom-config/vite.config.ts @@ -0,0 +1,33 @@ +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' + +// Allow env var to override the import protection behavior. +// Default: 'mock' (build completes, violations logged as warnings). +// Set BEHAVIOR=error to test that the build fails on violations. +const behavior = (process.env.BEHAVIOR ?? 'mock') as 'mock' | 'error' + +export default defineConfig({ + server: { + port: 3000, + }, + plugins: [ + tanstackStart({ + importProtection: { + behavior, + log: 'always', + // Custom file patterns: NOT the default `.server.*` / `.client.*`. + // Uses `.backend.*` for server-only files (denied in client env) + // and `.frontend.*` for client-only files (denied in server env). + client: { + files: ['**/*.backend.*'], + }, + server: { + files: ['**/*.frontend.*'], + }, + onViolation: (info) => { + void info + }, + }, + }), + ], +}) diff --git a/e2e/react-start/import-protection/package.json b/e2e/react-start/import-protection/package.json index 4492974074c..3d23413f734 100644 --- a/e2e/react-start/import-protection/package.json +++ b/e2e/react-start/import-protection/package.json @@ -8,8 +8,8 @@ "dev:e2e": "vite dev", "build": "vite build && tsc --noEmit", "start": "pnpx srvx --prod -s ../client dist/server/server.js", - "test:e2e:mockMode": "rm -rf port*.txt; playwright test --project=chromium", - "test:e2e:errorMode": "rm -rf port*.txt; BEHAVIOR=error playwright test --project=chromium", + "test:e2e:mockMode": "rm -rf node_modules/.vite; rm -rf port*.txt; playwright test --project=chromium", + "test:e2e:errorMode": "rm -rf node_modules/.vite; rm -rf port*.txt; BEHAVIOR=error playwright test --project=chromium", "test:e2e": "pnpm run test:e2e:mockMode && pnpm run test:e2e:errorMode" }, "dependencies": { @@ -18,7 +18,8 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-tweet": "^3.3.0", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vite-tsconfig-paths": "^6.1.0" }, "devDependencies": { "@playwright/test": "^1.50.1", diff --git a/e2e/react-start/import-protection/src/routes/__root.tsx b/e2e/react-start/import-protection/src/routes/__root.tsx index 1b67ae01cb1..0b6dd5245d7 100644 --- a/e2e/react-start/import-protection/src/routes/__root.tsx +++ b/e2e/react-start/import-protection/src/routes/__root.tsx @@ -2,6 +2,7 @@ import { createRootRoute, HeadContent, Link, + linkOptions, Outlet, Scripts, } from '@tanstack/react-router' @@ -17,6 +18,20 @@ export const Route = createRootRoute({ component: RootComponent, }) +const navLinks = linkOptions([ + { to: '/', label: 'Home' }, + { to: '/leaky-server-import', label: 'Leaky Import' }, + { to: '/alias-path-namespace-leak', label: 'Alias path namespace leak' }, + { to: '/client-only-violations', label: 'Client-Only Violations' }, + { to: '/client-only-jsx', label: 'Client-Only JSX' }, + { to: '/beforeload-leak', label: 'Beforeload Leak' }, + { to: '/component-server-leak', label: 'Component Server Leak' }, + { to: '/barrel-false-positive', label: 'Barrel False Positive' }, + { to: '/noexternal-client-pkg', label: 'noExternal Client Pkg' }, + { to: '/alias-path-leak', label: 'Alias Path Leak' }, + { to: '/non-alias-namespace-leak', label: 'Non-Alias Namespace Leak' }, +]) + function RootComponent() { return ( @@ -25,21 +40,12 @@ function RootComponent() { diff --git a/e2e/react-start/import-protection/src/routes/alias-path-leak.tsx b/e2e/react-start/import-protection/src/routes/alias-path-leak.tsx new file mode 100644 index 00000000000..639acdb8843 --- /dev/null +++ b/e2e/react-start/import-protection/src/routes/alias-path-leak.tsx @@ -0,0 +1,21 @@ +import { ClientOnly, createFileRoute } from '@tanstack/react-router' +import { getSecret } from '~/violations/secret.server' + +export const Route = createFileRoute('/alias-path-leak')({ + component: AliasPathLeak, +}) + +function AliasPathLeak() { + const secret = getSecret() + + return ( +
+

Alias Path Leak

+

{secret}

+ +

hydrated

+

{secret}

+
+
+ ) +} diff --git a/e2e/react-start/import-protection/src/routes/alias-path-namespace-leak.tsx b/e2e/react-start/import-protection/src/routes/alias-path-namespace-leak.tsx new file mode 100644 index 00000000000..0a7f29d297d --- /dev/null +++ b/e2e/react-start/import-protection/src/routes/alias-path-namespace-leak.tsx @@ -0,0 +1,23 @@ +import { ClientOnly, createFileRoute } from '@tanstack/react-router' +import * as secretModule from '~/violations/secret.server' + +export const Route = createFileRoute('/alias-path-namespace-leak')({ + component: AliasPathNamespaceLeak, +}) + +function AliasPathNamespaceLeak() { + const secret = secretModule.getSecret?.() ?? 'missing-secret' + + return ( +
+

+ Alias Path Namespace Leak +

+

{secret}

+ +

hydrated

+

{secret}

+
+
+ ) +} diff --git a/e2e/react-start/import-protection/src/routes/non-alias-namespace-leak.tsx b/e2e/react-start/import-protection/src/routes/non-alias-namespace-leak.tsx new file mode 100644 index 00000000000..d21a39219f6 --- /dev/null +++ b/e2e/react-start/import-protection/src/routes/non-alias-namespace-leak.tsx @@ -0,0 +1,23 @@ +import { ClientOnly, createFileRoute } from '@tanstack/react-router' +import * as secretModule from '../violations/secret.server' + +export const Route = createFileRoute('/non-alias-namespace-leak')({ + component: NonAliasNamespaceLeak, +}) + +function NonAliasNamespaceLeak() { + const secret = secretModule.getSecret?.() ?? 'missing-secret' + + return ( +
+

+ Non-Alias Namespace Leak +

+

{secret}

+ +

hydrated

+

{secret}

+
+
+ ) +} 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 647ee210a7f..7a8dc8d870e 100644 --- a/e2e/react-start/import-protection/tests/import-protection.spec.ts +++ b/e2e/react-start/import-protection/tests/import-protection.spec.ts @@ -1,7 +1,9 @@ import path from 'node:path' +import fs from 'node:fs' import { expect } from '@playwright/test' import { test } from '@tanstack/router-e2e-utils' import type { Violation } from './violations.utils' +import type { Page } from '@playwright/test' async function readViolations( type: 'build' | 'dev' | 'dev.cold' | 'dev.warm', @@ -14,12 +16,46 @@ async function readViolations( return (mod.default ?? []) as Array } +async function expectRouteHeading( + page: Page, + route: string, + testId: string, + heading: string, +): Promise { + await page.goto(route) + await expect(page.getByTestId(testId)).toContainText(heading) +} + +function findClientSecretServerFileViolation( + violations: Array, + importerFragment: string, + specifierMatches: (specifier: string) => boolean, +): Violation | undefined { + return violations.find( + (v) => + v.envType === 'client' && + v.type === 'file' && + v.importer.includes(importerFragment) && + (specifierMatches(v.specifier) || + v.resolved?.includes('violations/secret.server')), + ) +} + test.use({ // The mock proxy returns undefined-ish values, which may cause // React rendering warnings — whitelist those whitelistErrors: [/mock/i, /Cannot read properties/i, /undefined/i], }) +test.beforeEach(({}, testInfo) => { + const baseURL = testInfo.project.use.baseURL + if (!baseURL) { + throw new Error( + 'Missing Playwright baseURL for import-protection e2e. Run with `pnpm exec playwright test -c e2e/react-start/import-protection/playwright.config.ts` (or configure `use.baseURL`).', + ) + } +}) + test('app loads successfully with mock mode', async ({ page }) => { await page.goto('/') await expect(page.getByTestId('heading')).toContainText( @@ -84,6 +120,21 @@ test('marker violation: client importing server-only marked module', async () => expect(markerViolation).toBeDefined() }) +test('build log does not contain mock-edge missing export warnings', () => { + const buildLogPath = path.resolve( + import.meta.dirname, + '..', + 'webserver-build.log', + ) + + if (!fs.existsSync(buildLogPath)) { + return + } + + const log = fs.readFileSync(buildLogPath, 'utf-8') + expect(log).not.toMatch(/not exported by\s+"[^"\n]*mock:build:/) +}) + for (const mode of ['build', 'dev'] as const) { test(`violations contain trace information in ${mode}`, async () => { const violations = await readViolations(mode) @@ -121,7 +172,12 @@ for (const mode of ['build', 'dev'] as const) { const violations = await readViolations(mode) // Find a violation with a multi-step trace - const v = violations.find((x) => x.type === 'file' && x.trace.length >= 3) + const v = violations.find( + (x) => + x.type === 'file' && + x.trace.length >= 3 && + x.trace.some((s) => s.line != null), + ) expect(v).toBeDefined() // Every non-entry trace step should have a line number, except: @@ -136,6 +192,13 @@ for (const mode of ['build', 'dev'] as const) { // re-enters the same file — its import may not be locatable. const prev = v!.trace[i - 1] if (prev?.specifier?.includes('?tsr-split=')) continue + if ( + step.line == null && + step.file.startsWith('src/routes/') && + prev?.file.includes('routeTree.gen') + ) { + continue + } expect( step.line, @@ -155,7 +218,8 @@ for (const mode of ['build', 'dev'] as const) { x.type === 'file' && x.envType === 'client' && (x.specifier.includes('secret.server') || - x.resolved?.includes('secret.server')), + x.resolved?.includes('secret.server')) && + x.trace[x.trace.length - 1]?.line != null, ) expect(v).toBeDefined() @@ -175,7 +239,8 @@ for (const mode of ['build', 'dev'] as const) { x.type === 'file' && x.envType === 'client' && (x.specifier.includes('secret.server') || - x.resolved?.includes('secret.server')), + x.resolved?.includes('secret.server')) && + !!x.snippet, ) expect(v).toBeDefined() expect(v!.snippet).toBeDefined() @@ -194,7 +259,10 @@ for (const mode of ['build', 'dev'] as const) { test(`compiler leak violation includes line/col in importer in ${mode}`, async () => { const violations = await readViolations(mode) const v = violations.find( - (x) => x.importer.includes('compiler-leak') && x.type === 'file', + (x) => + x.importer.includes('compiler-leak') && + x.type === 'file' && + /:\d+:\d+$/.test(x.importer), ) expect(v).toBeDefined() expect(v!.importer).toMatch(/:\d+:\d+$/) @@ -208,7 +276,8 @@ for (const mode of ['build', 'dev'] as const) { (x) => x.type === 'specifier' && x.specifier === '@tanstack/react-start/server' && - x.importer.includes('leaky-server-import'), + x.importer.includes('leaky-server-import') && + /:\d+:\d+$/.test(x.importer), ) expect(v).toBeDefined() expect(v!.importer).toContain('violations/leaky-server-import') @@ -252,7 +321,10 @@ for (const mode of ['build', 'dev'] as const) { // which shortens the output. The snippet must still show the original // source lines (mapped via sourcesContent in the compiler's sourcemap). const compilerViolation = violations.find( - (v) => v.envType === 'client' && v.importer.includes('compiler-leak'), + (v) => + v.envType === 'client' && + v.importer.includes('compiler-leak') && + !!v.snippet, ) expect(compilerViolation).toBeDefined() @@ -468,10 +540,15 @@ test('warm run detects the same unique violations as cold run', async () => { const cold = await readViolations('dev.cold') const warm = await readViolations('dev.warm') - // Deduplicate by (envType, type, specifier, importer-file) since the same - // logical violation can be reported multiple times via different code paths. + // Deduplicate by (envType, type, normalizedSpecifier, importer-file). + // On warm starts the specifier string may differ from cold starts + // (e.g. alias `~/foo` vs resolved relative `src/foo`, or with/without + // the `.ts` extension) because different detection code-paths fire. + // Normalize to the resolved path (without extension) for a stable key. + const normalizeSpec = (v: Violation) => + (v.resolved ?? v.specifier).replace(/\.[cm]?[tj]sx?$/, '') const uniqueKey = (v: Violation) => - `${v.envType}|${v.type}|${v.specifier}|${v.importer.replace(/:.*/, '')}` + `${v.envType}|${v.type}|${normalizeSpec(v)}|${v.importer.replace(/:.*/, '')}` const coldUniq = [...new Set(cold.map(uniqueKey))].sort() const warmUniq = [...new Set(warm.map(uniqueKey))].sort() @@ -481,7 +558,12 @@ test('warm run detects the same unique violations as cold run', async () => { test('warm run traces include line numbers', async () => { const warm = await readViolations('dev.warm') - const v = warm.find((x) => x.type === 'file' && x.trace.length >= 3) + const v = warm.find( + (x) => + x.type === 'file' && + x.trace.length >= 3 && + x.trace.some((s) => s.line != null), + ) expect(v).toBeDefined() for (let i = 1; i < v!.trace.length; i++) { @@ -490,6 +572,13 @@ test('warm run traces include line numbers', async () => { if (step.file.includes('routeTree.gen')) continue const prev = v!.trace[i - 1] if (prev.specifier?.includes('?tsr-split=')) continue + if ( + step.line == null && + step.file.startsWith('src/routes/') && + prev.file.includes('routeTree.gen') + ) { + continue + } expect( step.line, @@ -645,6 +734,103 @@ test('noexternal-client-pkg route loads in mock mode', async ({ page }) => { ) }) +test('alias-path-leak route loads in mock mode', async ({ page }) => { + await expectRouteHeading( + page, + '/alias-path-leak', + 'alias-path-leak-heading', + 'Alias Path Leak', + ) +}) + +test('alias-path-leak renders real secret in SSR HTML', async ({ page }) => { + const response = await page.request.get('/alias-path-leak') + expect(response.ok()).toBe(true) + + const html = await response.text() + expect(html).toContain('data-testid="alias-path-secret"') + expect(html).toContain('super-secret-server-key-12345') +}) + +test('alias-path-leak does not expose real secret after hydration', async ({ + page, +}) => { + await page.goto('/alias-path-leak') + await expect(page.getByTestId('alias-path-secret-hydration')).toContainText( + 'hydrated', + ) + + await expect(page.getByTestId('alias-path-secret-client')).not.toContainText( + 'super-secret-server-key-12345', + ) +}) + +test('alias-path-namespace-leak route loads in mock mode', async ({ page }) => { + await expectRouteHeading( + page, + '/alias-path-namespace-leak', + 'alias-path-namespace-leak-heading', + 'Alias Path Namespace Leak', + ) +}) + +test('alias-path-namespace-leak renders real secret in SSR HTML', async ({ + page, +}) => { + const response = await page.request.get('/alias-path-namespace-leak') + expect(response.ok()).toBe(true) + + const html = await response.text() + expect(html).toContain('data-testid="alias-path-namespace-leak-secret"') + expect(html).toContain('super-secret-server-key-12345') +}) + +test('alias-path-namespace-leak does not expose real secret after hydration', async ({ + page, +}) => { + await page.goto('/alias-path-namespace-leak') + await expect( + page.getByTestId('alias-path-namespace-leak-secret-hydration'), + ).toContainText('hydrated') + + await expect( + page.getByTestId('alias-path-namespace-leak-secret-client'), + ).not.toContainText('super-secret-server-key-12345') +}) + +test('non-alias-namespace-leak route loads in mock mode', async ({ page }) => { + await expectRouteHeading( + page, + '/non-alias-namespace-leak', + 'non-alias-namespace-leak-heading', + 'Non-Alias Namespace Leak', + ) +}) + +test('non-alias-namespace-leak renders real secret in SSR HTML', async ({ + page, +}) => { + const response = await page.request.get('/non-alias-namespace-leak') + expect(response.ok()).toBe(true) + + const html = await response.text() + expect(html).toContain('data-testid="non-alias-namespace-leak-secret"') + expect(html).toContain('super-secret-server-key-12345') +}) + +test('non-alias-namespace-leak does not expose real secret after hydration', async ({ + page, +}) => { + await page.goto('/non-alias-namespace-leak') + await expect( + page.getByTestId('non-alias-namespace-leak-secret-hydration'), + ).toContainText('hydrated') + + await expect( + page.getByTestId('non-alias-namespace-leak-secret-client'), + ).not.toContainText('super-secret-server-key-12345') +}) + for (const mode of ['build', 'dev'] as const) { test(`no false positive for noExternal react-tweet (.client entry) in ${mode}`, async () => { const violations = await readViolations(mode) @@ -659,3 +845,91 @@ for (const mode of ['build', 'dev'] as const) { expect(hits).toEqual([]) }) } + +for (const mode of ['build', 'dev'] as const) { + test(`alias path mapping does not bypass .server file denial in ${mode}`, async () => { + const violations = await readViolations(mode) + + const hit = findClientSecretServerFileViolation( + violations, + 'alias-path-leak', + (specifier) => specifier.includes('violations/secret.server'), + ) + + expect(hit).toBeDefined() + }) +} + +for (const mode of ['build', 'dev', 'dev.warm'] as const) { + test(`alias path .server imports are not denied in server env in ${mode}`, async () => { + const violations = await readViolations(mode) + + const serverAliasHits = violations.filter( + (v) => + v.envType === 'server' && + (v.type === 'file' || v.type === 'marker' || v.type === 'specifier') && + (v.importer.includes('alias-path-leak') || + v.importer.includes('alias-path-namespace-leak')) && + (v.specifier.includes('secret.server') || + v.resolved?.includes('secret.server')), + ) + + expect(serverAliasHits).toEqual([]) + }) +} + +for (const mode of ['build', 'dev'] as const) { + test(`alias path namespace import does not bypass .server file denial in ${mode}`, async () => { + const violations = await readViolations(mode) + + const hit = findClientSecretServerFileViolation( + violations, + 'alias-path-namespace-leak', + (specifier) => specifier === '~/violations/secret.server', + ) + + expect(hit).toBeDefined() + }) +} + +for (const mode of ['build', 'dev', 'dev.warm'] as const) { + test(`alias routes both emit client file violations in ${mode}`, async () => { + const violations = await readViolations(mode) + + const aliasImporters = new Set( + violations + .filter( + (v) => + v.envType === 'client' && + v.type === 'file' && + (v.specifier.includes('secret.server') || + v.resolved?.includes('secret.server')) && + (v.importer.includes('alias-path-leak') || + v.importer.includes('alias-path-namespace-leak')), + ) + .map((v) => + v.importer.includes('alias-path-namespace-leak') + ? 'alias-path-namespace-leak' + : 'alias-path-leak', + ), + ) + + expect(aliasImporters).toEqual( + new Set(['alias-path-leak', 'alias-path-namespace-leak']), + ) + }) +} + +for (const mode of ['build', 'dev', 'dev.warm'] as const) { + test(`namespace import without path alias is denied in ${mode}`, async () => { + const violations = await readViolations(mode) + + const hit = findClientSecretServerFileViolation( + violations, + 'non-alias-namespace-leak', + (specifier) => specifier.includes('../violations/secret.server'), + ) + + expect(hit).toBeDefined() + }) +} diff --git a/e2e/react-start/import-protection/tests/violations.setup.ts b/e2e/react-start/import-protection/tests/violations.setup.ts index d005718a242..6a10e6b1fde 100644 --- a/e2e/react-start/import-protection/tests/violations.setup.ts +++ b/e2e/react-start/import-protection/tests/violations.setup.ts @@ -73,25 +73,23 @@ async function killChild(child: ReturnType): Promise { }) } -const routes = [ - '/', - '/leaky-server-import', - '/client-only-violations', - '/client-only-jsx', - '/beforeload-leak', - '/component-server-leak', - '/barrel-false-positive', -] - -const routeReadyTestIds: Record = { - '/': 'heading', - '/leaky-server-import': 'leaky-heading', - '/client-only-violations': 'client-only-heading', - '/client-only-jsx': 'client-only-jsx-heading', - '/beforeload-leak': 'beforeload-leak-heading', - '/component-server-leak': 'component-leak-heading', - '/barrel-false-positive': 'barrel-heading', -} +const routeDefinitions = [ + ['/', 'heading'], + ['/leaky-server-import', 'leaky-heading'], + ['/client-only-violations', 'client-only-heading'], + ['/client-only-jsx', 'client-only-jsx-heading'], + ['/beforeload-leak', 'beforeload-leak-heading'], + ['/component-server-leak', 'component-leak-heading'], + ['/barrel-false-positive', 'barrel-heading'], + ['/alias-path-leak', 'alias-path-leak-heading'], + ['/alias-path-namespace-leak', 'alias-path-namespace-leak-heading'], + ['/non-alias-namespace-leak', 'non-alias-namespace-leak-heading'], +] as const + +const routes = routeDefinitions.map(([route]) => route) + +const routeReadyTestIds: Record = + Object.fromEntries(routeDefinitions) async function navigateAllRoutes( baseURL: string, @@ -120,11 +118,11 @@ async function navigateAllRoutes( if (testId) { await page.getByTestId(testId).waitFor({ timeout: 10_000 }) } - - // Allow any deferred transforms/logging to flush. - await new Promise((r) => setTimeout(r, 750)) } catch { // ignore navigation errors — we only care about server logs + } finally { + // Allow deferred transforms/logging to flush even when navigation fails. + await new Promise((r) => setTimeout(r, 750)) } } diff --git a/e2e/react-start/import-protection/vite.config.ts b/e2e/react-start/import-protection/vite.config.ts index c413b72eaa4..5624b9f07d3 100644 --- a/e2e/react-start/import-protection/vite.config.ts +++ b/e2e/react-start/import-protection/vite.config.ts @@ -1,6 +1,7 @@ import path from 'node:path' import { defineConfig } from 'vite' import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import tsconfigPaths from 'vite-tsconfig-paths' // Allow env var to override the import protection behavior. // Default: 'mock' (build completes, violations logged as warnings). @@ -12,6 +13,7 @@ export default defineConfig({ port: 3000, }, plugins: [ + tsconfigPaths({ projects: ['./tsconfig.json'] }), tanstackStart({ importProtection: { behavior, diff --git a/packages/start-plugin-core/src/constants.ts b/packages/start-plugin-core/src/constants.ts index 854b4079a4b..9159cc14820 100644 --- a/packages/start-plugin-core/src/constants.ts +++ b/packages/start-plugin-core/src/constants.ts @@ -19,6 +19,8 @@ export const ENTRY_POINTS = { router: '#tanstack-router-entry', } as const +export const SERVER_FN_LOOKUP = 'server-fn-module-lookup' + // matches // .ts, .tsx, .cts, .mts, .js, .jsx, .cjs, .mjs // with optional query params after diff --git a/packages/start-plugin-core/src/import-protection-plugin/INTERNALS.md b/packages/start-plugin-core/src/import-protection-plugin/INTERNALS.md index acb0410b8fb..0143b111618 100644 --- a/packages/start-plugin-core/src/import-protection-plugin/INTERNALS.md +++ b/packages/start-plugin-core/src/import-protection-plugin/INTERNALS.md @@ -16,7 +16,7 @@ The plugin must handle **two axes of configuration**: ## Plugin Architecture -`importProtectionPlugin()` returns **three** Vite plugins: +`importProtectionPlugin()` returns **two** Vite plugins: ### 1. `tanstack-start-core:import-protection` (enforce: `'pre'`) @@ -31,10 +31,18 @@ transformed code and composed sourcemaps for accurate source-location mapping in violation messages. Also resolves post-transform imports and triggers `processPendingViolations()` for the dev mock deferral path. -### 3. `tanstack-start-core:import-protection-mock-rewrite` (enforce: `'pre'`, dev only) +In both build and dev modes, this plugin performs **self-denial**: when a file +matches a deny pattern in the current environment (e.g. a `.server.ts` file +transformed in the client environment), its entire content is replaced with a +mock module. This is the core mechanism for preventing cross-environment cache +contamination — `resolveId` never returns virtual module IDs for file-based +violations, so there is nothing for third-party resolver caches +(e.g. `vite-tsconfig-paths`) to leak across environments. In dev mode, the +mock imports a `mock-runtime` module for runtime diagnostics; in build mode, +the mock is fully self-contained. -Records expected named exports per importer so that dev mock-edge modules can -provide explicit ESM named exports. Only active in dev + mock mode. +See the [Self-Denial Transform](#self-denial-transform-in-detail) section below +for a detailed walkthrough with code examples. ## Violation Types @@ -71,15 +79,20 @@ and Rollup's tree-shaking then eliminates the `./db.server` dependency entirely. During dev, Vite's `fetchModule(?SERVER_FN_LOOKUP)` call triggers resolves for analysing a module's exports. These are tracked via `serverFnLookupModules` and -`isPreTransformResolve`, and violations are silenced for them. +`isPreTransformResolve`. In dev mock mode, pre-transform violations are +deferred like all other violations (verified via edge-survival and graph +reachability). In dev error mode, they are silenced because no deferred +verification path exists. ## Violation Handling Flow ### Central functions - **`handleViolation()`**: Formats + reports (or silences) the violation. Returns - a mock-edge module ID (string) so `resolveId` can substitute the offending - import. May also return `undefined` (suppressed by `onViolation` or + a resolve result so `resolveId` can substitute the offending import: for + file-based violations, returns the physical file path (self-denial handles + the rest in transform); for specifier/marker violations, returns a mock-edge + module ID. May also return `undefined` (suppressed by `onViolation` or silent+error in dev) or throw via `ctx.error()` (dev+error). - **`reportOrDeferViolation()`**: Dispatch layer. Either defers (stores for later verification) or reports immediately, depending on `shouldDefer`. @@ -87,11 +100,14 @@ analysing a module's exports. These are tracked via `serverFnLookupModules` and ### `shouldDefer` logic ```ts -shouldDefer = (isDevMock && !isPreTransformResolve) || isBuild +shouldDefer = isBuild || isDevMock ``` -- **Dev mock**: Defer to `pendingViolations` → verify via post-transform graph - reachability in `processPendingViolations()`. +- **Dev mock**: ALL violations (including pre-transform resolves) are deferred + to `pendingViolations` → verified via edge-survival and post-transform graph + reachability in `processPendingViolations()`. Pre-transform violations are + tagged with `fromPreTransformResolve` so the pending-violation processor + knows to wait for post-transform data before emitting. - **Build (both mock and error)**: Defer to `deferredBuildViolations` → verify via tree-shaking survival in `generateBundle`. @@ -112,18 +128,43 @@ Violations fire immediately via `ctx.error()` in `resolveId`. No tree-shaking is available, so false positives for barrel patterns are expected and accepted. (Dev + error is typically used only during explicit validation.) +Pre-transform resolves (e.g. server-fn-lookup) are silenced in error mode +because they fire before the Start compiler runs — imports inside `.server()` +callbacks haven't been stripped yet, and error mode has no deferred verification +path. + ### Dev + Mock -1. `resolveId` calls `handleViolation({ silent: true })` — no warning emitted. -2. The mock module ID is returned so the dev server can serve a Proxy-based - mock instead of the real server module. -3. The violation is stored in `pendingViolations` keyed by the importer's file +1. `resolveId` detects the violation and calls `reportOrDeferViolation()`. +2. For **file-based violations**: `handleViolation()` returns the **physical + file path** (same as build mode). The self-denial transform in the + transform-cache plugin will replace the file's content with a dev mock + module that imports `mock-runtime` for runtime diagnostics. +3. For **specifier/marker violations**: `handleViolation()` returns a mock-edge + module ID so the dev server can serve a Proxy-based mock. +4. The violation is stored in `pendingViolations` keyed by the importer's file path. -4. The transform-cache plugin, after resolving post-transform imports, calls +5. The transform-cache plugin, after resolving post-transform imports, calls `processPendingViolations()`. -5. `processPendingViolations()` checks graph reachability from entry points - using only post-transform edges. If the violating importer is reachable - → confirm (warn). If unreachable → discard. If unknown → keep pending. +6. `processPendingViolations()` first applies **edge-survival**: if + post-transform import data is available for the importer, it checks whether + the denied import survived the Start compiler transform. Imports stripped + by the compiler (e.g. inside `.server()` callbacks) are discarded. For + pre-transform violations (`fromPreTransformResolve`), the function waits + until post-transform data is available before proceeding. +7. After edge-survival, `processPendingViolations()` checks graph reachability + from entry points using only post-transform edges. If the violating + importer is reachable → confirm (warn). If unreachable → discard. If + unknown → keep pending or emit conservatively (warm-start fallback). + +Warm-start stability guardrails: + +- Resolve-time edges discovered through pre-transform paths + (`isPreTransformResolve`, especially `SERVER_FN_LOOKUP`) are **not** recorded + into the reachability graph. +- In `'unknown'` reachability status, pre-transform pending violations are + kept pending (not fallback-emitted) until non-lookup transform evidence is + available. This approach can't fully eliminate barrel false-positives in dev because there's no tree-shaking. The barrel's import of `.server` always resolves, @@ -131,18 +172,163 @@ and the barrel is reachable. This is a known and accepted limitation. ### Dev mock modules -In dev, each violation gets a **per-importer mock edge module** that: +Dev violations are handled differently depending on their type: + +- **File-based violations** use **self-denial** (same mechanism as build mode): + the denied file's content is replaced by the transform-cache plugin with a + mock that imports `mock-runtime` for runtime diagnostics. The export list + comes from the **denied file's AST** (what it exports), generated by + `generateDevSelfDenialModule()`. This approach avoids the cold-start problem + where the importer's AST is unavailable at `resolveId` time, and prevents + cross-environment cache contamination from third-party resolver plugins. + +- **Specifier/marker violations** use **mock-edge modules**: each violation + gets a per-importer mock edge module that explicitly exports the names the + importer expects (extracted lazily by `resolveExportsForDeniedSpecifier()` + which parses the importer's AST) and delegates to a **runtime mock module** + that contains a recursive Proxy and optional runtime diagnostics. + +### Mock edge modules (in detail) + +A **mock edge module** is a lightweight, auto-generated virtual module that sits +between an importing file and a base mock module. Its purpose is to provide +**explicit ESM named exports** so that bundlers (Rollup, Rolldown) and Vite's dev +server can correctly resolve `import { foo } from './denied.server'` — even +though the real module has been replaced by a mock. + +#### Why they exist + +The base mock module (`\0tanstack-start-import-protection:mock`) exports only a +`default` export — a recursive Proxy. But consumers of the denied module may use +named imports: + +```typescript +import { getSecret, initDb } from './credentials.server' +``` + +Without explicit named exports, this would fail: the bundler would complain that +`getSecret` and `initDb` don't exist on the mock module. Using +`syntheticNamedExports: true` in Rollup could solve this, but Rolldown (which +Vite can now use) doesn't support it. Mock edge modules solve the problem +portably by generating real ESM export statements. + +#### Structure + +Each mock edge module is identified by a virtual ID: + +```text +\0tanstack-start-import-protection:mock-edge: +``` + +The Base64URL payload encodes two fields: + +- `exports` — the list of named export identifiers the importer needs +- `runtimeId` — the module ID of the backing mock to import from + +When Vite's `load` hook encounters this virtual ID, `loadMockEdgeModule()` +decodes the payload and generates code like this: + +```typescript +// Generated mock edge module for exports: ["getSecret", "initDb"] +import mock from '\0tanstack-start-import-protection:mock' +export const getSecret = mock.getSecret +export const initDb = mock.initDb +export default mock +``` + +Each `mock.getSecret` access returns the Proxy itself (the Proxy's `get` trap +returns `mock` for any property), so the named exports are valid callable/ +constructable values that won't crash at runtime. + +#### How exports are determined + +The export list comes from **parsing the importer's AST** — not the denied +file's AST. The function `resolveExportsForDeniedSpecifier()` performs this: + +1. Gets the importer's code from the transform cache or `getModuleInfo`. +2. Calls `collectMockExportNamesBySource()` to extract which names the + importer uses from each import source (parses the AST internally): + - **Named imports**: `import { getSecret, initDb } from './creds.server'` + → `['getSecret', 'initDb']` + - **Namespace member access**: `import * as creds from './creds.server'` then + `creds.getSecret()` → `['getSecret']` + - **Default import member access**: `import creds from './creds.server'` then + `creds.getSecret` → `['getSecret']` + - **Re-exports**: `export { getSecret } from './creds.server'` + → `['getSecret']` +3. Caches the result per importer so multiple violations from the same file + don't re-parse. + +This "importer-driven" approach means the mock edge module only exports the +names the consumer actually references — not all exports from the denied file. + +#### Dev vs build mock edge modules + +| Aspect | Dev mock edge | Build mock edge | +| ---------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------- | +| **Backing mock** | `mock-runtime:BASE64` (runtime diagnostics) or `mock` (silent) | `mock:build:N` (per-violation unique, silent) | +| **Purpose** | Serve mocks in dev server + runtime warnings | Track tree-shaking survival in `generateBundle` | +| **Created for** | Specifier/marker violations only (file uses self-denial instead) | Specifier/marker violations only (file uses self-denial instead) | +| **Uniqueness** | Per-importer per-specifier | Per-violation (unique counter) | + +In **dev**, the backing `runtimeId` is a `mock-runtime:BASE64` module that +includes diagnostic metadata (environment, import path, trace). When the mock +is accessed in the browser, it logs a console warning or error. For SSR or +when `mockAccess` is `'off'`, the backing mock is the shared silent +`MOCK_MODULE_ID`. + +In **build**, the backing mock uses a unique counter ID +(`mock:build:0`, `mock:build:1`, ...) so `generateBundle` can check whether +each specific violation's mock survived tree-shaking. + +#### Handling non-identifier export names + +ES2022 allows string-keyed exports like `export { x as "foo-bar" }`. Mock edge +modules handle these via an intermediate variable: + +```typescript +import mock from '\0tanstack-start-import-protection:mock' +export const validName = mock.validName +const __tss_str_0 = mock['foo-bar'] +export { __tss_str_0 as 'foo-bar' } +``` + +The `default` export name is always filtered out (handled separately as +`export default mock`). + +### File-based violations: self-denial (dev and build) + +File-based violations (e.g. `import './db.server'` in client env) use +self-denial in **both** dev and build modes. `handleViolation()` returns the +physical file path, and the transform-cache plugin replaces the file's +contents with a mock module. + +- **Build mode**: Uses `generateSelfContainedMockModule()` — a fully + self-contained mock with an inlined Proxy factory (no `import` statements). + This is important because build-mode mocks must be tree-shakeable. + +- **Dev mode**: Uses `generateDevSelfDenialModule()` — a mock that imports + `mock-runtime` for runtime diagnostics (console warnings/errors when the + mock is accessed in the browser). The `mock-runtime` module ID encodes + violation metadata (environment, import path, trace). + +Self-denial avoids creating virtual mock-edge module IDs that could +contaminate third-party resolver caches across Vite environments. + +### Non-file violations: mock-edge modules (dev and build) -- Explicitly exports the names the importer expects (extracted by the - mock-rewrite plugin). -- Delegates to a **runtime mock module** that contains a recursive Proxy and - optional runtime diagnostics (console warnings when mocked values are used). +Specifier and marker violations use **mock-edge modules** because the denied +specifier doesn't resolve to a physical file that could be "self-denied." -This differs from build mode, where each violation gets a **per-violation mock -edge module** wrapping a unique base mock module -(`\0tanstack-start-import-protection:mock:build:N`). The edge module re-exports -the named exports the importer expects, just like in dev, ensuring compatibility -with both Rollup and Rolldown (which doesn't support `syntheticNamedExports`). +- **Build mode**: Each violation gets a **per-violation mock edge module** + wrapping a unique base mock module + (`\0tanstack-start-import-protection:mock:build:N`). The edge module + re-exports the named exports the importer expects, ensuring compatibility + with both Rollup and Rolldown (which doesn't support + `syntheticNamedExports`). + +- **Dev mode**: Each violation gets a **per-importer mock edge module** + wrapping a `mock-runtime` module for runtime diagnostics. ## Build Mode Strategy @@ -150,22 +336,27 @@ with both Rollup and Rolldown (which doesn't support `syntheticNamedExports`). Both mock and error build modes follow the same pattern: -1. **`resolveId`**: Call `handleViolation({ silent: true })`. Generate a - **unique per-violation mock-edge module** that wraps a base mock module - (`\0tanstack-start-import-protection:mock:build:N`) and provides explicit - named exports matching the importer's import bindings. Store the violation + - mock-edge ID in `env.deferredBuildViolations`. Return the mock-edge ID so the - bundler substitutes the offending import. - -2. **`load`**: For the base mock module, return a silent Proxy-based mock. For - the mock-edge module, return code that imports from the base mock and +1. **`resolveId`**: Call `handleViolation({ silent: true })`. + - For **file-based violations**: Returns the physical file path. The + self-denial transform will replace its content. + - For **specifier/marker violations**: Generates a **unique per-violation + mock-edge module** wrapping a base mock module + (`\0tanstack-start-import-protection:mock:build:N`). Stores the violation + + mock-edge ID in `env.deferredBuildViolations`. Returns the mock-edge ID. + +2. **`transform`** (self-denial): For file-based violations, the transform-cache + plugin detects that the current file is denied in this environment and + replaces its content with a self-contained mock module + (`generateSelfContainedMockModule()`). + +3. **`load`**: For base mock modules, returns a silent Proxy-based mock. For + mock-edge modules, returns code that imports from the base mock and re-exports the expected named bindings (e.g. `export const Foo = mock.Foo`). -3. **Tree-shaking**: The bundler processes the bundle normally. If no binding from - the mock-edge module is actually used at runtime, both the edge and base - modules are eliminated. +4. **Tree-shaking**: The bundler processes the bundle normally. If no binding from + the mock module is actually used at runtime, the modules are eliminated. -4. **`generateBundle`**: Inspect the output chunks. For each deferred violation, +5. **`generateBundle`**: Inspect the output chunks. For each deferred violation, check whether its unique mock module ID appears in any chunk's `modules`. - **Survived** → real violation (the import wasn't tree-shaken away). - Error mode: `ctx.error()` — fail the build. @@ -199,19 +390,19 @@ per-environment verification. ## Marker violations vs. file/specifier violations -| | Marker | File / Specifier | -| ----------------------------- | --------------------------------------------------- | -------------------------------------------- | -| **What triggers it** | The _importer_ has a conflicting directive | The _import target_ matches a deny rule | -| **resolveId returns (dev)** | Marker module ID (`RESOLVED_MARKER_*`) \* | Mock edge module ID (per-importer) | -| **resolveId returns (build)** | Unique build mock ID (same as file/specifier) | Unique build mock ID | -| **Can be tree-shaken away?** | Yes — if the importer is eliminated by tree-shaking | Yes — if no binding from the target survives | -| **Deferred in build?** | Yes — deferred to `generateBundle` | Yes — deferred to `generateBundle` | -| **Deferred in dev mock?** | Yes — deferred to pending graph | Yes — deferred to pending graph | +| | Marker | Specifier | File | +| ----------------------------- | --------------------------------------------------- | -------------------------------------------- | --------------------------------------------- | +| **What triggers it** | The _importer_ has a conflicting directive | The _import specifier_ matches a deny rule | The _resolved path_ matches a deny glob | +| **resolveId returns (dev)** | Marker module ID (`RESOLVED_MARKER_*`) \* | Mock edge module ID (per-importer) | Physical file path (self-denial in transform) | +| **resolveId returns (build)** | Unique build mock ID | Unique build mock ID | Physical file path (self-denial in transform) | +| **Can be tree-shaken away?** | Yes — if the importer is eliminated by tree-shaking | Yes — if no binding from the target survives | Yes — if no binding from the target survives | +| **Deferred in build?** | Yes — deferred to `generateBundle` | Yes — deferred to `generateBundle` | Yes — deferred to `generateBundle` | +| **Deferred in dev mock?** | Yes — deferred to pending graph | Yes — deferred to pending graph | Yes — deferred to pending graph | -\* In dev mock, `handleViolation` internally returns a mock edge module ID +\* In dev mock, `handleViolation` internally returns the physical file path (stored as the deferred result in `pendingViolations`), but `resolveId` ignores -it for markers and falls through to return the marker module ID. The mock edge -ID is only used for deferral bookkeeping, not for module resolution. +it for markers and falls through to return the marker module ID. The file path +is only used for deferral bookkeeping, not for module resolution. ## State Management @@ -239,13 +430,13 @@ Per-build/watch iteration, `buildStart` clears `pendingViolations` and resets ## Virtual Module IDs -| ID Pattern | Usage | -| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| `\0tanstack-start-import-protection:mock` | Shared silent mock (dev mock only) | -| `\0tanstack-start-import-protection:mock:build:N` | Per-violation build mock (unique counter) | -| `\0tanstack-start-import-protection:mock-edge:BASE64` | Per-importer dev mock with explicit named exports | -| `\0tanstack-start-import-protection:mock-runtime:BASE64` | Runtime diagnostic mock (dev client, console warnings) | -| `\0tanstack-start-import-protection:marker:*` | Marker module (empty `export {}`). Suffixed `server-only` or `client-only`; derived from `MARKER_PREFIX` in `plugin.ts` | +| ID Pattern | Usage | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `\0tanstack-start-import-protection:mock` | Shared silent mock (dev mock only) | +| `\0tanstack-start-import-protection:mock:build:N` | Per-violation build mock (unique counter) | +| `\0tanstack-start-import-protection:mock-edge:BASE64` | Per-importer mock with explicit named exports (specifier/marker violations, dev + build) | +| `\0tanstack-start-import-protection:mock-runtime:BASE64` | Runtime diagnostic mock (dev client, console warnings) | +| `\0tanstack-start-import-protection:marker:*` | Marker module (empty `export {}`). Suffixed `server-only` or `client-only`; derived from `MARKER_PREFIX` in `virtualModules.ts` | ## Key Design Decisions @@ -284,7 +475,10 @@ build mode provides definitive answers via tree-shaking. ## E2E Test Structure -Tests live in `e2e/react-start/import-protection/`: +Tests live in `e2e/react-start/import-protection/` and +`e2e/react-start/import-protection-custom-config/`: + +### Main test suite (`import-protection/`) - **Mock mode** (default): `globalSetup` builds the app, captures build warnings to `violations.build.json`, starts a dev server capturing to @@ -296,3 +490,211 @@ Tests live in `e2e/react-start/import-protection/`: re-exporting from both a `.server` file and a marker-protected file (`foo.ts` with `import 'server-only'`), where all server-only bindings are tree-shaken away, produces **zero** violations in the build log. + +### Custom-config test suite (`import-protection-custom-config/`) + +Uses non-default deny patterns (`**/*.backend.*` / `**/*.frontend.*`) to verify +that import protection works with user-configured file patterns. This ensures +the plugin doesn't hardcode any assumption about `.server`/`.client` naming +conventions. The `vite.config.ts` provides custom `client.files` and +`server.files` arrays; no `vite-tsconfig-paths` is used. + +## Self-Denial Transform (In Detail) + +The self-denial transform is the mechanism by which the plugin prevents cross- +environment contamination without creating virtual module IDs that could leak +through third-party resolver caches. It applies to **file-based violations in +both dev and build modes**. + +### The problem it solves + +In Vite 7+, client and SSR environments run within the same Vite process. Some +plugins (e.g. `vite-tsconfig-paths`) maintain a global resolution cache shared +across environments. If the import-protection plugin resolves a specifier to a +virtual mock module ID (e.g. +`\0tanstack-start-import-protection:mock-edge:...`) in the client environment, +that virtual ID can leak into the SSR environment's cache. When the SSR +environment later resolves the same specifier, it finds the cached virtual ID +instead of the real file — breaking the server. + +In dev mode, self-denial also solves a **cold-start problem**: on cold start +(no `.vite` cache), the importer's AST is unavailable when `resolveId` runs +(neither the transform cache nor `getModuleInfo` have content yet). If mock-edge +module IDs were generated at `resolveId` time, the export list would be empty +(since it's derived from parsing the importer), producing a mock with no named +exports — causing runtime errors like +`does not provide an export named: 'getSecret'`. + +### How it works + +Instead of returning a virtual module ID from `resolveId`, the self-denial +transform works at the **transform** stage: + +1. **`resolveId`**: For file-based violations, `handleViolation()` returns the + **physical file path**. The import resolves normally. No virtual ID is + created. + +2. **transform-cache plugin**: When the transform-cache hook processes a file, + it checks whether the file matches any deny pattern for the current + environment using `checkFileDenial()`. If the file is denied, the plugin: + + a. Extracts the file's named exports using `collectNamedExports()` (parses + the AST internally). + b. **Build mode**: Generates a self-contained mock module via + `generateSelfContainedMockModule()` — no imports, inlined Proxy factory. + c. **Dev mode**: Generates a dev mock module via + `generateDevSelfDenialModule()` — imports `mock-runtime` for runtime + diagnostics (console warnings/errors when the mock is accessed). + d. Returns the mock code as the transform result, completely replacing the + original file content. + +3. **Fallback**: If AST parsing fails (e.g. due to syntax errors in the denied + file), `exportNames` defaults to `[]` (empty) and the mock module has no + named exports: + - Build: `generateSelfContainedMockModule([])`. + - Dev: `generateDevSelfDenialModule([], runtimeId)` where `runtimeId` is + computed via `mockRuntimeModuleIdFromViolation()` (which may internally + fall back to the shared silent `MOCK_MODULE_ID` for SSR or when + `mockAccess` is `'off'`). + +### Code transformation example + +**Original file** (`src/lib/credentials.server.ts`): + +```typescript +import { db } from './database' + +export function getSecret(): string { + return db.query('SELECT secret FROM config LIMIT 1') +} + +export const API_KEY = process.env.API_KEY! + +export default { getSecret, API_KEY } +``` + +**After self-denial transform in build mode** (client environment): + +```typescript +/* @__NO_SIDE_EFFECTS__ */ +function createMock() { + const handler = { + get: (_, prop) => { + if (prop === Symbol.toPrimitive) return () => 'MOCK' + if (typeof prop === 'symbol') return undefined + return mock + }, + apply: () => mock, + construct: () => mock, + } + const mock = /* @__PURE__ */ new Proxy(function () {}, handler) + return mock +} +const mock = /* @__PURE__ */ createMock() +export const getSecret = mock.getSecret +export const API_KEY = mock.API_KEY +export default mock +``` + +**After self-denial transform in dev mode** (client environment): + +```typescript +import mock from '\0tanstack-start-import-protection:mock-runtime:eyJ...' +export const getSecret = mock.getSecret +export const API_KEY = mock.API_KEY +export default mock +``` + +The `mock-runtime` module ID encodes violation metadata (environment, importer, +specifier, trace) as a Base64URL payload. When the mock is accessed in the +browser, it logs a console warning or error with this metadata. + +### Key properties + +**Build-mode mock** (`generateSelfContainedMockModule`): + +- **Self-contained**: No `import` statements — the Proxy factory is inlined. +- **Tree-shakeable**: All exports are marked pure (`@__NO_SIDE_EFFECTS__`, + `@__PURE__`), so the bundler can eliminate unused exports. + +**Dev-mode mock** (`generateDevSelfDenialModule`): + +- **Runtime diagnostics**: Imports `mock-runtime` which provides console + warnings/errors when the mock is accessed in the browser. +- **Lightweight**: No inlined Proxy factory — delegates to the mock-runtime + virtual module. + +**Both modes**: + +- **Same export interface**: Named exports match the original file's exports, + so consumers' import bindings remain valid. +- **Safe at runtime**: Every access returns the same Proxy, which is callable, + constructable, and returns "MOCK" when coerced to a string. +- **Export accuracy from denied file**: Unlike mock-edge modules (which parse + the importer's AST), self-denial parses the **denied file's own AST**. + This provides a safe over-approximation — all exports the file offers — and + avoids the cold-start problem where the importer hasn't been transformed yet. + +### Why self-denial instead of virtual modules for file-based violations + +| Approach | Virtual mock ID | Self-denial | +| ------------------ | -------------------------------------------------------------- | -------------------------------------------------------------- | +| `resolveId` return | `\0mock-edge:BASE64` (virtual) | Physical file path (resolves normally) | +| Cache risk | Virtual ID stored in shared resolver cache → leaks to SSR | Physical path cached → correct in both environments | +| Module identity | New virtual module per violation | Same physical file, different content per environment | +| Export accuracy | From importer's AST (what it imports) | From denied file's AST (what it exports) | +| Cold-start safety | Fails — importer AST unavailable on cold start → empty exports | Safe — denied file's source code always available in transform | + +### When self-denial is NOT used + +Self-denial only applies to **file-based** violations. + +- **Specifier violations** (e.g. `import '@tanstack/react-start/server'`): + These are bare specifiers, not file paths. They use virtual mock-edge module + IDs because the specifier doesn't resolve to a physical file that could be + "self-denied." +- **Marker violations**: Use virtual mock IDs in build mode for + `generateBundle` tracking (see marker section above). In dev mode, markers + resolve to the marker module ID directly. + +## Module Dependency Architecture + +The import-protection plugin is structured to avoid heavy transitive +dependencies in its utility modules: + +```text +constants.ts ← lightweight (no heavy imports) +├── IMPORT_PROTECTION_DEBUG +├── KNOWN_SOURCE_EXTENSIONS +├── SERVER_FN_LOOKUP_QUERY ← imports SERVER_FN_LOOKUP from parent constants.ts +└── VITE_BROWSER_VIRTUAL_PREFIX + +defaults.ts ← lightweight (type-only imports) +├── getDefaultImportProtectionRules +└── getMarkerSpecifiers + +matchers.ts ← picomatch only +├── compileMatcher +├── compileMatchers +└── matchesAny + +utils.ts ← vite + node:path + local constants +├── normalizeFilePath, stripViteQuery, ... +└── extractImportSources, dedupePatterns, ... + +trace.ts ← imports from utils only +├── ImportGraph +├── buildTrace +└── formatViolation + +virtualModules.ts ← imports ../utils (parent), ../constants (parent) +├── generateSelfContainedMockModule, generateDevSelfDenialModule +├── loadMockEdgeModule, loadMockRuntimeModule +└── MOCK_MODULE_ID, MOCK_EDGE_PREFIX, ... + +plugin.ts ← main plugin, imports everything above +``` + +The `SERVER_FN_LOOKUP` constant lives in the shared parent `constants.ts` +(not in `start-compiler-plugin/plugin.ts`) to avoid pulling in +`@tanstack/start-server-core` when unit-testing import-protection modules. diff --git a/packages/start-plugin-core/src/import-protection-plugin/ast.ts b/packages/start-plugin-core/src/import-protection-plugin/ast.ts new file mode 100644 index 00000000000..2c8a4b73ed2 --- /dev/null +++ b/packages/start-plugin-core/src/import-protection-plugin/ast.ts @@ -0,0 +1,7 @@ +import { parseAst } from '@tanstack/router-utils' + +export type ParsedAst = ReturnType + +export function parseImportProtectionAst(code: string): ParsedAst { + return parseAst({ code }) +} diff --git a/packages/start-plugin-core/src/import-protection-plugin/constants.ts b/packages/start-plugin-core/src/import-protection-plugin/constants.ts new file mode 100644 index 00000000000..9c3b596550b --- /dev/null +++ b/packages/start-plugin-core/src/import-protection-plugin/constants.ts @@ -0,0 +1,25 @@ +import { SERVER_FN_LOOKUP } from '../constants' + +export const SERVER_FN_LOOKUP_QUERY = `?${SERVER_FN_LOOKUP}` + +export const IMPORT_PROTECTION_DEBUG = + process.env.TSR_IMPORT_PROTECTION_DEBUG === '1' || + process.env.TSR_IMPORT_PROTECTION_DEBUG === 'true' + +export const IMPORT_PROTECTION_DEBUG_FILTER = + process.env.TSR_IMPORT_PROTECTION_DEBUG_FILTER + +export const KNOWN_SOURCE_EXTENSIONS = new Set([ + '.ts', + '.tsx', + '.mts', + '.cts', + '.js', + '.jsx', + '.mjs', + '.cjs', + '.json', +]) + +/** Vite's browser-visible prefix for virtual modules (replaces `\0`). */ +export const VITE_BROWSER_VIRTUAL_PREFIX = '/@id/__x00__' diff --git a/packages/start-plugin-core/src/import-protection-plugin/extensionlessAbsoluteIdResolver.ts b/packages/start-plugin-core/src/import-protection-plugin/extensionlessAbsoluteIdResolver.ts new file mode 100644 index 00000000000..ec4037f7216 --- /dev/null +++ b/packages/start-plugin-core/src/import-protection-plugin/extensionlessAbsoluteIdResolver.ts @@ -0,0 +1,121 @@ +import { basename, dirname, extname, isAbsolute } from 'node:path' +import { resolveModulePath } from 'exsolve' + +import { KNOWN_SOURCE_EXTENSIONS } from './constants' +import { normalizeFilePath } from './utils' + +const FILE_RESOLUTION_EXTENSIONS = [...KNOWN_SOURCE_EXTENSIONS] + +type DepKey = `file:${string}` | `dir:${string}` + +/** + * Canonicalize extensionless absolute IDs like `/src/foo.server` to the + * physical file when possible. + * + * Keeps a small cache plus a reverse index so we can invalidate on HMR + * updates without clearing the whole map. + */ +export class ExtensionlessAbsoluteIdResolver { + private entries = new Map }>() + private keysByDep = new Map>() + + clear(): void { + this.entries.clear() + this.keysByDep.clear() + } + + /** + * Invalidate any cached entries that might be affected by changes to `id`. + * We invalidate both the file and its containing directory. + */ + invalidateByFile(id: string): void { + const file = normalizeFilePath(id) + this.invalidateDep(`file:${file}`) + if (isAbsolute(file)) { + this.invalidateDep(`dir:${dirname(file)}`) + } + } + + resolve(id: string): string { + const key = normalizeFilePath(id) + const cached = this.entries.get(key) + if (cached) return cached.value + + let result = key + let resolvedPhysical: string | undefined + + if (isAbsolute(key)) { + const ext = extname(key) + if (!FILE_RESOLUTION_EXTENSIONS.includes(ext)) { + const resolved = resolveModulePath(`./${basename(key)}`, { + from: dirname(key), + extensions: FILE_RESOLUTION_EXTENSIONS, + try: true, + }) + if (resolved) { + resolvedPhysical = resolved + result = normalizeFilePath(resolved) + } + } + } + + const resolvedFile = resolvedPhysical + ? normalizeFilePath(resolvedPhysical) + : undefined + + const deps = this.buildDepsForKey(key, resolvedFile) + this.entries.set(key, { value: result, deps }) + this.indexDeps(key, deps) + return result + } + + private invalidateDep(dep: DepKey): void { + const keys = this.keysByDep.get(dep) + if (!keys) return + + // Copy because deleting keys mutates indexes. + for (const key of Array.from(keys)) { + this.deleteKey(key) + } + } + + private buildDepsForKey(key: string, resolvedFile: string | undefined) { + const deps = new Set() + deps.add(`file:${key}`) + + if (isAbsolute(key)) { + deps.add(`dir:${dirname(key)}`) + } + if (resolvedFile) { + deps.add(`file:${resolvedFile}`) + } + + return deps + } + + private indexDeps(key: string, deps: Set): void { + for (const dep of deps) { + let keys = this.keysByDep.get(dep) + if (!keys) { + keys = new Set() + this.keysByDep.set(dep, keys) + } + keys.add(key) + } + } + + private deleteKey(key: string): void { + const entry = this.entries.get(key) + this.entries.delete(key) + if (!entry) return + + for (const dep of entry.deps) { + const keys = this.keysByDep.get(dep) + if (!keys) continue + keys.delete(key) + if (keys.size === 0) { + this.keysByDep.delete(dep) + } + } + } +} diff --git a/packages/start-plugin-core/src/import-protection-plugin/plugin.ts b/packages/start-plugin-core/src/import-protection-plugin/plugin.ts index 9adec4b7430..ed3bad7e73d 100644 --- a/packages/start-plugin-core/src/import-protection-plugin/plugin.ts +++ b/packages/start-plugin-core/src/import-protection-plugin/plugin.ts @@ -2,26 +2,37 @@ import { normalizePath } from 'vite' import { resolveViteId } from '../utils' import { VITE_ENVIRONMENT_NAMES } from '../constants' -import { SERVER_FN_LOOKUP } from '../start-compiler-plugin/plugin' import { ImportGraph, buildTrace, formatViolation } from './trace' import { getDefaultImportProtectionRules, getMarkerSpecifiers, } from './defaults' -import { findPostCompileUsagePos } from './postCompileUsage' import { compileMatchers, matchesAny } from './matchers' import { + buildResolutionCandidates, + buildSourceCandidates, + canonicalizeResolvedId, clearNormalizeFilePathCache, + debugLog, dedupePatterns, escapeRegExp, extractImportSources, getOrCreate, + isInsideDirectory, + matchesDebugFilter, normalizeFilePath, relativizePath, + shouldDeferViolation, } from './utils' -import { collectMockExportNamesBySource } from './rewriteDeniedImports' +import { + collectMockExportNamesBySource, + collectNamedExports, + rewriteDeniedImports, +} from './rewriteDeniedImports' import { MOCK_BUILD_PREFIX, + generateDevSelfDenialModule, + generateSelfContainedMockModule, getResolvedVirtualModuleMatchers, loadResolvedVirtualModule, makeMockEdgeModuleId, @@ -29,12 +40,17 @@ import { resolveInternalVirtualModuleId, resolvedMarkerVirtualModuleId, } from './virtualModules' +import { ExtensionlessAbsoluteIdResolver } from './extensionlessAbsoluteIdResolver' +import { + IMPORT_PROTECTION_DEBUG, + SERVER_FN_LOOKUP_QUERY, + VITE_BROWSER_VIRTUAL_PREFIX, +} from './constants' import { ImportLocCache, addTraceImportLocations, buildCodeSnippet, buildLineIndex, - clearImportPatternCache, findImportStatementLocationFromTransformed, findPostCompileUsageLocation, pickOriginalCodeFromSourcesContent, @@ -47,201 +63,52 @@ import type { TransformResult, TransformResultProvider, } from './sourceLocation' +import type { ImportProtectionOptions } from '../schema' import type { - ImportProtectionBehavior, - ImportProtectionOptions, -} from '../schema' -import type { CompileStartFrameworkOptions, GetConfigFn } from '../types' - -const SERVER_FN_LOOKUP_QUERY = '?' + SERVER_FN_LOOKUP -const IMPORT_PROTECTION_DEBUG = - process.env.TSR_IMPORT_PROTECTION_DEBUG === '1' || - process.env.TSR_IMPORT_PROTECTION_DEBUG === 'true' -const IMPORT_PROTECTION_DEBUG_FILTER = - process.env.TSR_IMPORT_PROTECTION_DEBUG_FILTER - -function debugLog(...args: Array) { - if (!IMPORT_PROTECTION_DEBUG) return - console.warn('[import-protection:debug]', ...args) -} - -/** Check if a value matches the debug filter (when set). */ -function matchesDebugFilter(...values: Array): boolean { - if (!IMPORT_PROTECTION_DEBUG_FILTER) return true - return values.some((v) => v.includes(IMPORT_PROTECTION_DEBUG_FILTER)) -} - -export { rewriteDeniedImports } from './rewriteDeniedImports' -export { dedupePatterns, extractImportSources } from './utils' -export type { Pattern } from './utils' - -/** - * Immutable plugin configuration — set once in `configResolved`, never mutated - * per-env or per-request afterward. - */ -interface PluginConfig { - enabled: boolean - root: string - command: 'build' | 'serve' - srcDirectory: string - framework: CompileStartFrameworkOptions - - /** Absolute, query-free entry file ids used for trace roots. */ - entryFiles: Array - - effectiveBehavior: ImportProtectionBehavior - mockAccess: 'error' | 'warn' | 'off' - logMode: 'once' | 'always' - maxTraceDepth: number - - compiledRules: { - client: { - specifiers: Array - files: Array - excludeFiles: Array - } - server: { - specifiers: Array - files: Array - excludeFiles: Array - } - } - includeMatchers: Array - excludeMatchers: Array - ignoreImporterMatchers: Array - - markerSpecifiers: { serverOnly: Set; clientOnly: Set } - envTypeMap: Map - - onViolation?: ( - info: ViolationInfo, - ) => boolean | void | Promise -} - -/** - * Per-Vite-environment mutable state. One instance per environment name, - * stored in `envStates: Map`. - * - * All caches that previously lived on `PluginState` with `${envName}:` key - * prefixes now live here without any prefix. - */ -interface EnvState { - graph: ImportGraph - /** Specifiers that resolved to the mock module (for transform-time rewriting). */ - deniedSources: Set - /** Per-importer denied edges (for dev ESM mock modules). */ - deniedEdges: Map> - /** - * During `vite dev` in mock mode, we generate a per-importer mock module that - * exports the names the importer expects. - * Populated in the transform hook (no disk reads). - */ - mockExportsByImporter: Map>> - - /** Resolve cache. Key: `${normalizedImporter}:${source}` (no env prefix). */ - resolveCache: Map - /** Reverse index: file path → Set of resolveCache keys involving that file. */ - resolveCacheByFile: Map> - - /** Import location cache. Key: `${importerFile}::${source}`. */ - importLocCache: ImportLocCache - - /** Deduplication of logged violations (no env prefix in key). */ - seenViolations: Set - - /** - * Modules transitively loaded during a `fetchModule(?SERVER_FN_LOOKUP)` call. - * In dev mode the compiler calls `fetchModule(id + '?' + SERVER_FN_LOOKUP)` to - * analyse a module's exports. The direct target carries the query parameter so - * `isPreTransformResolve` is `true`. But Vite also resolves the target's own - * imports (and their imports, etc.) with the plain file path as the importer — - * those would otherwise fire false-positive violations. - * - * We record every module resolved while walking a SERVER_FN_LOOKUP chain so - * that their child imports are also treated as pre-transform resolves. - */ - serverFnLookupModules: Set - - /** Transform result cache (code + composed sourcemap + original source). */ - transformResultCache: Map - /** Reverse index: physical file path → Set of transformResultCache keys. */ - transformResultKeysByFile: Map> - - /** Cached provider that reads from {@link transformResultCache}. */ - transformResultProvider: TransformResultProvider - - /** - * Post-transform resolved imports. Populated by the transform-cache hook - * after resolving every import source found in the transformed code. - * Key: transform cache key (normalised module ID incl. query params). - * Value: set of resolved child file paths. - */ - postTransformImports: Map> - - /** - * Violations deferred in dev mock mode. Keyed by the violating importer's - * normalized file path. Violations are confirmed or discarded by the - * transform-cache hook once enough post-transform data is available to - * determine whether the importer is still reachable from an entry point. - */ - pendingViolations: Map> - - /** - * Violations deferred in build mode (both mock and error). Each gets a - * unique mock module ID so we can check which ones survived tree-shaking - * in `generateBundle`. - */ - deferredBuildViolations: Array -} - -interface PendingViolation { - info: ViolationInfo - /** The mock module ID that resolveId already returned for this violation. */ - mockReturnValue: string -} - -interface DeferredBuildViolation { - info: ViolationInfo - /** Unique mock module ID assigned to this violation. */ - mockModuleId: string - - /** - * Module ID to check for tree-shaking survival in `generateBundle`. - * - * For most violations we check the unique mock module ID. - * For `marker` violations the import is a bare side-effect import that often - * gets optimized away regardless of whether the importer survives, so we - * instead check whether the importer module itself survived. - */ - checkModuleId?: string -} - -/** - * Intentionally cross-env shared mutable state. - * - * A file's `'use server'`/`'use client'` directive is inherent to the file - * content, not the environment that happens to discover it first. - */ -interface SharedState { - fileMarkerKind: Map -} - -export interface ImportProtectionPluginOptions { - getConfig: GetConfigFn - framework: CompileStartFrameworkOptions - environments: Array<{ name: string; type: 'client' | 'server' }> - providerEnvName: string -} + EnvRules, + EnvState, + HandleViolationResult, + ImportProtectionPluginOptions, + ModuleGraphNode, + PendingViolation, + PluginConfig, + SharedState, + ViolationReporter, +} from './types' + +export type { ImportProtectionPluginOptions } from './types' export function importProtectionPlugin( opts: ImportProtectionPluginOptions, ): PluginOption { let devServer: ViteDevServer | null = null + const extensionlessIdResolver = new ExtensionlessAbsoluteIdResolver() + const resolveExtensionlessAbsoluteId = (id: string) => + extensionlessIdResolver.resolve(id) + + const importPatternCache = new Map>() + + function findFirstImportSpecifierIndex(code: string, source: string): number { + let patterns = importPatternCache.get(source) + if (!patterns) { + const escaped = escapeRegExp(source) + patterns = [ + new RegExp(`\\bimport\\s+(['"])${escaped}\\1`), + new RegExp(`\\bfrom\\s+(['"])${escaped}\\1`), + new RegExp(`\\bimport\\s*\\(\\s*(['"])${escaped}\\1\\s*\\)`), + ] + importPatternCache.set(source, patterns) + } - type ModuleGraphNode = { - id?: string | null - url?: string - importers: Set + let best = -1 + for (const re of patterns) { + const m = re.exec(code) + if (!m) continue + const idx = m.index + m[0].indexOf(source) + if (idx === -1) continue + if (best === -1 || idx < best) best = idx + } + return best } /** @@ -344,7 +211,6 @@ export function importProtectionPlugin( command: 'build', srcDirectory: '', framework: opts.framework, - entryFiles: [], effectiveBehavior: 'error', mockAccess: 'error', logMode: 'once', @@ -364,18 +230,6 @@ export function importProtectionPlugin( const envStates = new Map() const shared: SharedState = { fileMarkerKind: new Map() } - function getMarkerKindForFile( - fileId: string, - ): 'server' | 'client' | undefined { - const file = normalizeFilePath(fileId) - return shared.fileMarkerKind.get(file) - } - - type ViolationReporter = { - warn: (msg: string) => void - error: (msg: string) => never - } - /** * Build the best available trace for a module and enrich each step with * line/column locations. Tries the plugin's own ImportGraph first, then @@ -407,7 +261,12 @@ export function importProtectionPlugin( trace = mgTrace } } - await addTraceImportLocations(provider, trace, env.importLocCache) + await addTraceImportLocations( + provider, + trace, + env.importLocCache, + findFirstImportSpecifierIndex, + ) if (trace.length > 0) { const last = trace[trace.length - 1]! @@ -448,19 +307,20 @@ export function importProtectionPlugin( >, traceOverride?: Array, ): Promise { - const loc = - (await findPostCompileUsageLocation( - provider, - importer, - source, - findPostCompileUsagePos, - )) || - (await findImportStatementLocationFromTransformed( - provider, - importer, - source, - env.importLocCache, - )) + const sourceCandidates = buildSourceCandidates( + source, + 'resolved' in overrides && typeof overrides.resolved === 'string' + ? overrides.resolved + : undefined, + config.root, + ) + + const loc = await resolveImporterLocation( + provider, + env, + importer, + sourceCandidates, + ) const trace = await rebuildAndAnnotateTrace( provider, @@ -487,6 +347,27 @@ export function importProtectionPlugin( } } + async function resolveImporterLocation( + provider: TransformResultProvider, + env: EnvState, + importer: string, + sourceCandidates: Iterable, + ): Promise { + for (const candidate of sourceCandidates) { + const loc = + (await findPostCompileUsageLocation(provider, importer, candidate)) || + (await findImportStatementLocationFromTransformed( + provider, + importer, + candidate, + env.importLocCache, + findFirstImportSpecifierIndex, + )) + if (loc) return loc + } + return undefined + } + /** * Check if a resolved import violates marker restrictions (e.g. importing * a server-only module in the client env). If so, build and return the @@ -505,7 +386,8 @@ export function importProtectionPlugin( relativePath: string, traceOverride?: Array, ): Promise { - const markerKind = getMarkerKindForFile(resolvedId) + const normalizedResolvedId = normalizeFilePath(resolvedId) + const markerKind = shared.fileMarkerKind.get(normalizedResolvedId) const violates = (envType === 'client' && markerKind === 'server') || (envType === 'server' && markerKind === 'client') @@ -523,11 +405,49 @@ export function importProtectionPlugin( source, { type: 'marker', - resolved: normalizeFilePath(resolvedId), - message: - markerKind === 'server' - ? `Module "${relativePath}" is marked server-only but is imported in the client environment` - : `Module "${relativePath}" is marked client-only but is imported in the server environment`, + resolved: normalizedResolvedId, + message: buildMarkerViolationMessage(relativePath, markerKind), + }, + traceOverride, + ) + } + + function buildMarkerViolationMessage( + relativePath: string, + markerKind: 'server' | 'client' | undefined, + ): string { + return markerKind === 'server' + ? `Module "${relativePath}" is marked server-only but is imported in the client environment` + : `Module "${relativePath}" is marked client-only but is imported in the server environment` + } + + async function buildFileViolationInfo( + provider: TransformResultProvider, + env: EnvState, + envName: string, + envType: 'client' | 'server', + importer: string, + normalizedImporter: string, + source: string, + resolvedPath: string, + pattern: string | RegExp, + traceOverride?: Array, + ): Promise { + const relativePath = getRelativePath(resolvedPath) + + return buildViolationInfo( + provider, + env, + envName, + envType, + importer, + normalizedImporter, + source, + { + type: 'file', + pattern, + resolved: resolvedPath, + message: `Import "${source}" (resolved to "${relativePath}") is denied in the ${envType} environment`, }, traceOverride, ) @@ -537,17 +457,36 @@ export function importProtectionPlugin( return config.envTypeMap.get(envName) ?? 'server' } - function getRulesForEnvironment(envName: string): { - specifiers: Array - files: Array - excludeFiles: Array - } { + function getRulesForEnvironment(envName: string): EnvRules { const type = getEnvType(envName) return type === 'client' ? config.compiledRules.client : config.compiledRules.server } + /** + * Check if a relative path matches any denied file pattern for the given + * environment, respecting `excludeFiles`. Returns the matching pattern + * or `undefined` if the file is not denied. + */ + function checkFileDenial( + relativePath: string, + matchers: { + files: Array + excludeFiles: Array + }, + ): CompiledMatcher | undefined { + if ( + matchers.excludeFiles.length > 0 && + matchesAny(relativePath, matchers.excludeFiles) + ) { + return undefined + } + return matchers.files.length > 0 + ? matchesAny(relativePath, matchers.files) + : undefined + } + const environmentNames = new Set([ VITE_ENVIRONMENT_NAMES.client, VITE_ENVIRONMENT_NAMES.server, @@ -563,8 +502,6 @@ export function importProtectionPlugin( const transformResultCache = new Map() envState = { graph: new ImportGraph(), - deniedSources: new Set(), - deniedEdges: new Map(), mockExportsByImporter: new Map(), resolveCache: new Map(), resolveCacheByFile: new Map(), @@ -593,6 +530,186 @@ export function importProtectionPlugin( return envState } + /** + * Search a parsed export-names map for an entry matching any of the + * specifier candidates. Returns matching names or empty array. + */ + function findExportsInMap( + exportMap: Map>, + candidates: Array, + ): Array { + for (const candidate of candidates) { + const hit = exportMap.get(candidate) + if (hit && hit.length > 0) return hit + } + return [] + } + + /** + * Build deduped resolution candidates for a module ID, including the + * extensionless absolute path when the ID looks like a file path. + */ + function buildIdCandidates(id: string, extra?: string): Array { + const set = new Set(buildResolutionCandidates(id)) + if (extra) { + for (const c of buildResolutionCandidates(extra)) set.add(c) + set.add(resolveExtensionlessAbsoluteId(extra)) + } + return Array.from(set) + } + + /** + * Resolve which named exports the importer needs from a denied specifier, + * so mock-edge modules can provide explicit ESM named exports. + * + * Tries multiple strategies: cached export maps, AST parsing, and + * resolver-based comparison. + */ + async function resolveExportsForDeniedSpecifier( + env: EnvState, + ctx: ViolationReporter, + info: ViolationInfo, + importerIdHint?: string, + ): Promise> { + const importerFile = normalizeFilePath(info.importer) + const specifierCandidates = buildIdCandidates(info.specifier, info.resolved) + + // Only parse AST when a violation occurs (this function is only called + // while handling a violation). Cache per-importer to avoid repeated parses + // across multiple violations. + let parsedBySource = env.mockExportsByImporter.get(importerFile) + if (!parsedBySource) { + // Try transform-cache result first, then moduleInfo fallback. + const importerCode = + env.transformResultProvider.getTransformResult(importerFile)?.code ?? + (importerIdHint && ctx.getModuleInfo + ? (ctx.getModuleInfo(importerIdHint)?.code ?? undefined) + : undefined) + if (typeof importerCode !== 'string' || importerCode.length === 0) + return [] + + try { + parsedBySource = collectMockExportNamesBySource(importerCode) + + // Also index by resolved physical IDs so later lookups match. + await recordMockExportsForImporter( + env, + importerFile, + parsedBySource, + async (src) => { + const cacheKey = `${importerFile}:${src}` + if (env.resolveCache.has(cacheKey)) { + return env.resolveCache.get(cacheKey) ?? undefined + } + if (!ctx.resolve) return undefined + const resolved = await ctx.resolve(src, info.importer, { + skipSelf: true, + }) + if (!resolved || resolved.external) return undefined + return resolved.id + }, + ) + + // Keep the parsed-by-source map for direct lookups. + parsedBySource = + env.mockExportsByImporter.get(importerFile) ?? parsedBySource + } catch { + return [] + } + } + + // 1. Direct candidate match + const direct = findExportsInMap(parsedBySource, specifierCandidates) + if (direct.length > 0) return direct + + // 2. Resolve each source key and compare candidates. + const candidateSet = new Set(specifierCandidates) + for (const [sourceKey, names] of parsedBySource) { + if (!names.length) continue + + const resolvedId = await resolveSourceKey( + env, + ctx, + importerFile, + sourceKey, + info.importer, + ) + if (!resolvedId) continue + + const resolvedCandidates = buildIdCandidates(resolvedId) + resolvedCandidates.push(resolveExtensionlessAbsoluteId(resolvedId)) + if (resolvedCandidates.some((v) => candidateSet.has(v))) { + return names + } + } + + return [] + } + + /** Best-effort resolve a source key using the cache or ctx.resolve. */ + async function resolveSourceKey( + env: EnvState, + ctx: ViolationReporter, + importerFile: string, + sourceKey: string, + importerId: string, + ): Promise { + const cacheKey = `${importerFile}:${sourceKey}` + if (env.resolveCache.has(cacheKey)) { + return env.resolveCache.get(cacheKey) ?? undefined + } + if (!ctx.resolve) return undefined + try { + const resolved = await ctx.resolve(sourceKey, importerId, { + skipSelf: true, + }) + if (!resolved || resolved.external) return undefined + return resolved.id + } catch { + return undefined + } + } + + async function recordMockExportsForImporter( + env: EnvState, + importerId: string, + namesBySource: Map>, + resolveSource: (source: string) => Promise, + ): Promise { + const importerFile = normalizeFilePath(importerId) + + if (namesBySource.size === 0) return + + for (const [source, names] of namesBySource) { + try { + const resolvedId = await resolveSource(source) + if (!resolvedId) continue + + namesBySource.set(normalizeFilePath(resolvedId), names) + namesBySource.set(resolveExtensionlessAbsoluteId(resolvedId), names) + } catch { + // Best-effort only + } + } + + const existing = env.mockExportsByImporter.get(importerFile) + if (!existing) { + env.mockExportsByImporter.set(importerFile, namesBySource) + return + } + + for (const [source, names] of namesBySource) { + const prev = existing.get(source) + if (!prev) { + existing.set(source, names) + continue + } + + const union = new Set([...prev, ...names]) + existing.set(source, Array.from(union).sort()) + } + } + const shouldCheckImporterCache = new Map() function shouldCheckImporter(importer: string): boolean { let result = shouldCheckImporterCache.get(importer) @@ -600,20 +717,19 @@ export function importProtectionPlugin( const relativePath = relativizePath(importer, config.root) - if ( - config.excludeMatchers.length > 0 && - matchesAny(relativePath, config.excludeMatchers) - ) { - result = false - } else if ( - config.ignoreImporterMatchers.length > 0 && - matchesAny(relativePath, config.ignoreImporterMatchers) - ) { + // Excluded or ignored importers are never checked. + const excluded = + (config.excludeMatchers.length > 0 && + matchesAny(relativePath, config.excludeMatchers)) || + (config.ignoreImporterMatchers.length > 0 && + matchesAny(relativePath, config.ignoreImporterMatchers)) + + if (excluded) { result = false } else if (config.includeMatchers.length > 0) { result = !!matchesAny(relativePath, config.includeMatchers) } else if (config.srcDirectory) { - result = importer.startsWith(config.srcDirectory) + result = isInsideDirectory(importer, config.srcDirectory) } else { result = true } @@ -622,13 +738,8 @@ export function importProtectionPlugin( return result } - function dedupeKey( - type: string, - importer: string, - specifier: string, - resolved?: string, - ): string { - return `${type}:${importer}:${specifier}:${resolved ?? ''}` + function dedupeKey(info: ViolationInfo): string { + return `${info.type}:${info.importer}:${info.specifier}:${info.resolved ?? ''}` } function hasSeen(env: EnvState, key: string): boolean { @@ -642,6 +753,72 @@ export function importProtectionPlugin( return relativizePath(normalizePath(absolutePath), config.root) } + /** Reset all caches on an EnvState (called from buildStart). */ + function clearEnvState(envState: EnvState): void { + envState.resolveCache.clear() + envState.resolveCacheByFile.clear() + envState.importLocCache.clear() + envState.seenViolations.clear() + envState.transformResultCache.clear() + envState.transformResultKeysByFile.clear() + envState.postTransformImports.clear() + envState.serverFnLookupModules.clear() + envState.pendingViolations.clear() + envState.deferredBuildViolations.length = 0 + envState.graph.clear() + envState.mockExportsByImporter.clear() + } + + /** Invalidate all env-level caches that reference a specific file. */ + function invalidateFileFromEnv(envState: EnvState, file: string): void { + envState.importLocCache.deleteByFile(file) + + // Resolve cache (keyed "importer:source") + const resolveKeys = envState.resolveCacheByFile.get(file) + if (resolveKeys) { + for (const key of resolveKeys) envState.resolveCache.delete(key) + envState.resolveCacheByFile.delete(file) + } + + envState.graph.invalidate(file) + envState.mockExportsByImporter.delete(file) + envState.serverFnLookupModules.delete(file) + envState.pendingViolations.delete(file) + + // Transform result cache + post-transform imports + const transformKeys = envState.transformResultKeysByFile.get(file) + if (transformKeys) { + for (const key of transformKeys) { + envState.transformResultCache.delete(key) + envState.postTransformImports.delete(key) + } + envState.transformResultKeysByFile.delete(file) + } else { + envState.transformResultCache.delete(file) + envState.postTransformImports.delete(file) + } + } + + /** Store a transform result under both the cacheKey and physical file path. */ + function cacheTransformResult( + envState: EnvState, + file: string, + cacheKey: string, + result: TransformResult, + ): void { + envState.transformResultCache.set(cacheKey, result) + const keySet = getOrCreate( + envState.transformResultKeysByFile, + file, + () => new Set(), + ) + keySet.add(cacheKey) + if (cacheKey !== file) { + envState.transformResultCache.set(file, result) + keySet.add(file) + } + } + /** Register known Start entrypoints as trace roots for all environments. */ function registerEntries(): void { const { resolvedStartConfig } = opts.getConfig() @@ -660,6 +837,82 @@ export function importProtectionPlugin( } } + /** + * Get the merged set of post-transform imports for a file, checking all + * code-split variants. Returns `null` if no post-transform data exists + * yet (transform hasn't run). + * + * Skips `SERVER_FN_LOOKUP` variants because they contain untransformed + * code — the Start compiler excludes them. + */ + function getPostTransformImports( + env: EnvState, + file: string, + ): Set | null { + const keySet = env.transformResultKeysByFile.get(file) + let merged: Set | null = null + + if (keySet) { + for (const k of keySet) { + if (k.includes(SERVER_FN_LOOKUP_QUERY)) continue + const imports = env.postTransformImports.get(k) + if (imports) { + if (!merged) merged = new Set(imports) + else for (const v of imports) merged.add(v) + } + } + } + + // Fallback: direct file-path key + if (!merged) { + const imports = env.postTransformImports.get(file) + if (imports) merged = new Set(imports) + } + + return merged + } + + /** + * Check whether an import edge from `parent` to `target` survived + * post-transform compilation. + * + * Returns: + * - `'live'` — target appears in a non-lookup variant's post-transform imports + * - `'dead'` — post-transform data exists but target is absent (compiler stripped it) + * - `'pending'` — transform ran but import data not yet posted + * - `'no-data'` — transform never ran (warm-start cached module) + */ + function checkEdgeLiveness( + env: EnvState, + parent: string, + target: string, + ): 'live' | 'dead' | 'pending' | 'no-data' { + const keySet = env.transformResultKeysByFile.get(parent) + let anyVariantCached = false + + if (keySet) { + for (const k of keySet) { + if (k.includes(SERVER_FN_LOOKUP_QUERY)) continue + const imports = env.postTransformImports.get(k) + if (imports) { + anyVariantCached = true + if (imports.has(target)) return 'live' + } + } + } + + if (!anyVariantCached) { + const imports = env.postTransformImports.get(parent) + if (imports) return imports.has(target) ? 'live' : 'dead' + const hasTransformResult = + env.transformResultCache.has(parent) || + (keySet ? keySet.size > 0 : false) + return hasTransformResult ? 'pending' : 'no-data' + } + + return 'dead' + } + function checkPostTransformReachability( env: EnvState, file: string, @@ -678,73 +931,60 @@ export function importProtectionPlugin( return 'reachable' } - // Walk reverse edges const importers = env.graph.reverseEdges.get(current) if (!importers) continue for (const [parent] of importers) { if (visited.has(parent)) continue - - // Check all code-split variants for this parent. The edge is - // live if ANY variant's resolved imports include `current`. - // Skip SERVER_FN_LOOKUP variants — they contain untransformed - // code (the compiler excludes them), so their import lists - // include imports that the compiler would normally strip. - const keySet = env.transformResultKeysByFile.get(parent) - let anyVariantCached = false - let edgeLive = false - - if (keySet) { - for (const k of keySet) { - if (k.includes(SERVER_FN_LOOKUP_QUERY)) continue - const resolvedImports = env.postTransformImports.get(k) - if (resolvedImports) { - anyVariantCached = true - if (resolvedImports.has(current)) { - edgeLive = true - break - } - } - } - } - - // Fallback: direct file-path key - if (!anyVariantCached) { - const resolvedImports = env.postTransformImports.get(parent) - if (resolvedImports) { - anyVariantCached = true - if (resolvedImports.has(current)) { - edgeLive = true - } - } - } - - if (!anyVariantCached) { - const hasTransformResult = - env.transformResultCache.has(parent) || - (keySet ? keySet.size > 0 : false) - - if (hasTransformResult) { - // Transform ran but postTransformImports not yet populated - hasUnknownEdge = true - continue - } - - // Transform never ran — Vite served from cache (warm start). - // Conservatively treat edge as live. - queue.push(parent) - continue - } - - if (edgeLive) { + const liveness = checkEdgeLiveness(env, parent, current) + if (liveness === 'live' || liveness === 'no-data') { + // Live edge or warm-start (no transform data) — follow it queue.push(parent) + } else if (liveness === 'pending') { + hasUnknownEdge = true } + // 'dead' — edge was stripped by compiler, skip } } return hasUnknownEdge ? 'unknown' : 'unreachable' } + /** + * Filter pending violations using edge-survival data. Returns the subset + * of violations whose resolved import survived the Start compiler (or all + * violations when no post-transform data is available yet). + * + * Returns `undefined` when all violations were stripped or when we must wait + * for post-transform data before proceeding. + */ + function filterEdgeSurvival( + env: EnvState, + file: string, + violations: Array, + ): + | { active: Array; edgeSurvivalApplied: boolean } + | 'all-stripped' + | 'await-transform' { + const postTransform = getPostTransformImports(env, file) + + if (postTransform) { + const surviving = violations.filter( + (pv) => !pv.info.resolved || postTransform.has(pv.info.resolved), + ) + if (surviving.length === 0) return 'all-stripped' + env.pendingViolations.set(file, surviving) + return { active: surviving, edgeSurvivalApplied: true } + } + + // Pre-transform violations need edge-survival verification first. + if (violations.some((pv) => pv.fromPreTransformResolve)) { + return 'await-transform' + } + + return { active: violations, edgeSurvivalApplied: false } + } + /** * Process pending violations for the given environment. Called from the * transform-cache hook after each module transform is cached, because new @@ -761,6 +1001,16 @@ export function importProtectionPlugin( const toDelete: Array = [] for (const [file, violations] of env.pendingViolations) { + const filtered = filterEdgeSurvival(env, file, violations) + + if (filtered === 'all-stripped') { + toDelete.push(file) + continue + } + if (filtered === 'await-transform') continue + + const { active, edgeSurvivalApplied } = filtered + // Wait for entries before running reachability. registerEntries() // populates entries at buildStart; resolveId(!importer) may add more. const status = @@ -769,36 +1019,35 @@ export function importProtectionPlugin( : 'unknown' if (status === 'reachable') { - for (const pv of violations) { - const key = dedupeKey( - pv.info.type, - pv.info.importer, - pv.info.specifier, - pv.info.resolved, - ) - if (!hasSeen(env, key)) { - const freshTrace = await rebuildAndAnnotateTrace( - env.transformResultProvider, - env, - pv.info.env, - pv.info.importer, - pv.info.specifier, - pv.info.importerLoc, - ) - if (freshTrace.length > pv.info.trace.length) { - pv.info.trace = freshTrace - } - - if (config.onViolation) { - const result = await config.onViolation(pv.info) - if (result === false) continue - } - warnFn(formatViolation(pv.info, config.root)) - } + for (const pv of active) { + await emitPendingViolation(env, warnFn, pv) } toDelete.push(file) } else if (status === 'unreachable') { toDelete.push(file) + } else if (config.command === 'serve') { + // 'unknown' reachability — some graph edges lack transform data. + // When edge-survival was applied, surviving violations are confirmed + // real. Without it (warm start), emit conservatively. + let emittedAny = false + for (const pv of active) { + if (pv.fromPreTransformResolve) continue + + const shouldEmit = + edgeSurvivalApplied || + (pv.info.type === 'file' && + !!pv.info.resolved && + isInsideDirectory(pv.info.resolved, config.srcDirectory)) + + if (shouldEmit) { + emittedAny = + (await emitPendingViolation(env, warnFn, pv)) || emittedAny + } + } + + if (emittedAny) { + toDelete.push(file) + } } // 'unknown' — keep pending for next transform-cache invocation. } @@ -808,6 +1057,59 @@ export function importProtectionPlugin( } } + async function emitPendingViolation( + env: EnvState, + warnFn: (msg: string) => void, + pv: PendingViolation, + ): Promise { + if (!pv.info.importerLoc) { + const sourceCandidates = buildSourceCandidates( + pv.info.specifier, + pv.info.resolved, + config.root, + ) + const loc = await resolveImporterLocation( + env.transformResultProvider, + env, + pv.info.importer, + sourceCandidates, + ) + + if (loc) { + pv.info.importerLoc = loc + pv.info.snippet = buildCodeSnippet( + env.transformResultProvider, + pv.info.importer, + loc, + ) + } + } + + if (hasSeen(env, dedupeKey(pv.info))) { + return false + } + + const freshTrace = await rebuildAndAnnotateTrace( + env.transformResultProvider, + env, + pv.info.env, + pv.info.importer, + pv.info.specifier, + pv.info.importerLoc, + ) + if (freshTrace.length > pv.info.trace.length) { + pv.info.trace = freshTrace + } + + if (config.onViolation) { + const result = await config.onViolation(pv.info) + if (result === false) return false + } + + warnFn(formatViolation(pv.info, config.root)) + return true + } + /** * Record a violation as pending for later confirmation via graph * reachability. Called from `resolveId` when `shouldDefer` is true. @@ -816,38 +1118,28 @@ export function importProtectionPlugin( env: EnvState, importerFile: string, info: ViolationInfo, - mockReturnValue: string | undefined, + isPreTransformResolve?: boolean, ): void { getOrCreate(env.pendingViolations, importerFile, () => []).push({ info, - mockReturnValue: mockReturnValue ?? '', + fromPreTransformResolve: isPreTransformResolve, }) } /** Counter for generating unique per-violation mock module IDs in build mode. */ let buildViolationCounter = 0 - type HandleViolationResult = string | undefined - async function handleViolation( ctx: ViolationReporter, env: EnvState, info: ViolationInfo, + importerIdHint?: string, violationOpts?: { silent?: boolean }, ): Promise { - const key = dedupeKey( - info.type, - info.importer, - info.specifier, - info.resolved, - ) - if (!violationOpts?.silent) { if (config.onViolation) { const result = await config.onViolation(info) - if (result === false) { - return undefined - } + if (result === false) return undefined } if (config.effectiveBehavior === 'error') { @@ -862,71 +1154,56 @@ export function importProtectionPlugin( // // Build mode never reaches here — all build violations are // deferred via shouldDefer and handled silently. - return ctx.error(formatViolation(info, config.root)) } - const seen = hasSeen(env, key) - - if (!seen) { + if (!hasSeen(env, dedupeKey(info))) { ctx.warn(formatViolation(info, config.root)) } - } else { - if (config.effectiveBehavior === 'error' && config.command !== 'build') { - return undefined - } + } else if ( + config.effectiveBehavior === 'error' && + config.command !== 'build' + ) { + return undefined } - env.deniedSources.add(info.specifier) - getOrCreate(env.deniedEdges, info.importer, () => new Set()).add( - info.specifier, - ) - - if (config.command === 'serve') { - const runtimeId = mockRuntimeModuleIdFromViolation( - info, - config.mockAccess, - config.root, - ) - const importerFile = normalizeFilePath(info.importer) - const exports = - env.mockExportsByImporter.get(importerFile)?.get(info.specifier) ?? [] - return resolveViteId( - makeMockEdgeModuleId(exports, info.specifier, runtimeId), - ) - } + // File violations: return resolved path — the self-denial transform + // will replace the file's content with a mock module. This avoids + // virtual module IDs that could leak across environments via + // third-party resolver caches. + if (info.type === 'file') return info.resolved - // Build: unique per-violation mock IDs so generateBundle can check - // which violations survived tree-shaking (both mock and error mode). - // We wrap the base mock in a mock-edge module that provides explicit - // named exports — Rolldown doesn't support Rollup's - // syntheticNamedExports, so without this named imports like - // `import { Foo } from "denied-pkg"` would fail with MISSING_EXPORT. - // - // Use the unresolved MOCK_BUILD_PREFIX (without \0) as the runtimeId - // so the mock-edge module's `import mock from "..."` goes through - // resolveId, which adds the \0 prefix. Using the resolved ID directly - // would cause Rollup/Vite to skip resolveId and fail to find the module. - const baseMockId = `${MOCK_BUILD_PREFIX}${buildViolationCounter++}` - const importerFile = normalizeFilePath(info.importer) - const exports = - env.mockExportsByImporter.get(importerFile)?.get(info.specifier) ?? [] - return resolveViteId( - makeMockEdgeModuleId(exports, info.specifier, baseMockId), + // Non-file violations (specifier/marker): create mock-edge module. + // Dev mode uses a runtime diagnostics ID; build mode uses a unique + // per-violation ID so generateBundle can check tree-shaking survival. + const exports = await resolveExportsForDeniedSpecifier( + env, + ctx, + info, + importerIdHint, ) + const baseMockId = + config.command === 'serve' + ? mockRuntimeModuleIdFromViolation(info, config.mockAccess, config.root) + : `${MOCK_BUILD_PREFIX}${buildViolationCounter++}` + return resolveViteId(makeMockEdgeModuleId(exports, baseMockId)) } /** * Unified violation dispatch: either defers or reports immediately. * - * When `shouldDefer` is true, calls `handleViolation` silently to obtain - * the mock module ID, then stores the violation for later verification: - * - Dev mock mode: defers to `pendingViolations` for graph-reachability - * checking via `processPendingViolations`. + * When `shouldDefer` is true (dev mock + build modes), calls + * `handleViolation` silently to obtain the mock module ID, then stores + * the violation for later verification: + * - Dev mock mode: all violations are deferred to `pendingViolations` + * for edge-survival and graph-reachability checking via + * `processPendingViolations`. * - Build mode (mock + error): defers to `deferredBuildViolations` for * tree-shaking verification in `generateBundle`. * - * Otherwise reports (or silences for pre-transform resolves) immediately. + * Otherwise reports immediately (dev error mode). Pre-transform + * resolves are silenced in error mode because they fire before the + * compiler runs and there is no deferred verification path. * * Returns the mock module ID / resolve result from `handleViolation`. */ @@ -934,12 +1211,15 @@ export function importProtectionPlugin( ctx: ViolationReporter, env: EnvState, importerFile: string, + importerIdHint: string | undefined, info: ViolationInfo, shouldDefer: boolean, isPreTransformResolve: boolean, ): Promise { if (shouldDefer) { - const result = await handleViolation(ctx, env, info, { silent: true }) + const result = await handleViolation(ctx, env, info, importerIdHint, { + silent: true, + }) if (config.command === 'build') { // Build mode: store for generateBundle tree-shaking check. @@ -953,13 +1233,18 @@ export function importProtectionPlugin( }) } else { // Dev mock: store for graph-reachability check. - deferViolation(env, importerFile, info, result) + deferViolation(env, importerFile, info, isPreTransformResolve) await processPendingViolations(env, ctx.warn.bind(ctx)) } return result } - return await handleViolation(ctx, env, info, { + + // Non-deferred path: dev error mode only. + // Pre-transform resolves are silenced because they fire before the + // compiler runs — imports inside `.server()` callbacks haven't been + // stripped yet and error mode has no deferred verification. + return handleViolation(ctx, env, info, importerIdHint, { silent: isPreTransformResolve, }) } @@ -983,11 +1268,6 @@ export function importProtectionPlugin( const { startConfig, resolvedStartConfig } = opts.getConfig() config.srcDirectory = resolvedStartConfig.srcDirectory - config.entryFiles = [ - resolvedStartConfig.routerFilePath, - resolvedStartConfig.startFilePath, - ].filter((f): f is string => Boolean(f)) - const userOpts: ImportProtectionOptions | undefined = startConfig.importProtection @@ -998,18 +1278,14 @@ export function importProtectionPlugin( config.enabled = true - if (userOpts?.behavior) { - if (typeof userOpts.behavior === 'string') { - config.effectiveBehavior = userOpts.behavior - } else { - config.effectiveBehavior = - viteConfig.command === 'serve' - ? (userOpts.behavior.dev ?? 'mock') - : (userOpts.behavior.build ?? 'error') - } + const behavior = userOpts?.behavior + if (typeof behavior === 'string') { + config.effectiveBehavior = behavior } else { config.effectiveBehavior = - viteConfig.command === 'serve' ? 'mock' : 'error' + viteConfig.command === 'serve' + ? (behavior?.dev ?? 'mock') + : (behavior?.build ?? 'error') } config.logMode = userOpts?.log ?? 'once' @@ -1021,6 +1297,9 @@ export function importProtectionPlugin( } const defaults = getDefaultImportProtectionRules() + // Use user-provided patterns when available, otherwise defaults. + const pick = (user: Array | undefined, fallback: Array) => + user ? [...user] : [...fallback] // Client specifier denies always include framework defaults even // when the user provides a custom list. @@ -1029,45 +1308,34 @@ export function importProtectionPlugin( ...(userOpts?.client?.specifiers ?? []), ]) - const clientFiles = userOpts?.client?.files - ? [...userOpts.client.files] - : [...defaults.client.files] - const clientExcludeFiles = userOpts?.client?.excludeFiles - ? [...userOpts.client.excludeFiles] - : [...defaults.client.excludeFiles] - const serverSpecifiers = userOpts?.server?.specifiers - ? dedupePatterns([...userOpts.server.specifiers]) - : dedupePatterns([...defaults.server.specifiers]) - const serverFiles = userOpts?.server?.files - ? [...userOpts.server.files] - : [...defaults.server.files] - const serverExcludeFiles = userOpts?.server?.excludeFiles - ? [...userOpts.server.excludeFiles] - : [...defaults.server.excludeFiles] - config.compiledRules.client = { specifiers: compileMatchers(clientSpecifiers), - files: compileMatchers(clientFiles), - excludeFiles: compileMatchers(clientExcludeFiles), + files: compileMatchers( + pick(userOpts?.client?.files, defaults.client.files), + ), + excludeFiles: compileMatchers( + pick(userOpts?.client?.excludeFiles, defaults.client.excludeFiles), + ), } config.compiledRules.server = { - specifiers: compileMatchers(serverSpecifiers), - files: compileMatchers(serverFiles), - excludeFiles: compileMatchers(serverExcludeFiles), + specifiers: compileMatchers( + dedupePatterns( + pick(userOpts?.server?.specifiers, defaults.server.specifiers), + ), + ), + files: compileMatchers( + pick(userOpts?.server?.files, defaults.server.files), + ), + excludeFiles: compileMatchers( + pick(userOpts?.server?.excludeFiles, defaults.server.excludeFiles), + ), } - // Include/exclude - if (userOpts?.include) { - config.includeMatchers = compileMatchers(userOpts.include) - } - if (userOpts?.exclude) { - config.excludeMatchers = compileMatchers(userOpts.exclude) - } - if (userOpts?.ignoreImporters) { - config.ignoreImporterMatchers = compileMatchers( - userOpts.ignoreImporters, - ) - } + config.includeMatchers = compileMatchers(userOpts?.include ?? []) + config.excludeMatchers = compileMatchers(userOpts?.exclude ?? []) + config.ignoreImporterMatchers = compileMatchers( + userOpts?.ignoreImporters ?? [], + ) // Marker specifiers const markers = getMarkerSpecifiers() @@ -1085,25 +1353,13 @@ export function importProtectionPlugin( if (!config.enabled) return // Clear memoization caches that grow unboundedly across builds clearNormalizeFilePathCache() - clearImportPatternCache() + extensionlessIdResolver.clear() + importPatternCache.clear() shouldCheckImporterCache.clear() // Clear per-env caches for (const envState of envStates.values()) { - envState.resolveCache.clear() - envState.resolveCacheByFile.clear() - envState.importLocCache.clear() - envState.seenViolations.clear() - envState.transformResultCache.clear() - envState.transformResultKeysByFile.clear() - envState.postTransformImports.clear() - envState.serverFnLookupModules.clear() - envState.pendingViolations.clear() - envState.deferredBuildViolations.length = 0 - envState.graph.clear() - envState.deniedSources.clear() - envState.deniedEdges.clear() - envState.mockExportsByImporter.clear() + clearEnvState(envState) } // Clear shared state @@ -1119,42 +1375,14 @@ export function importProtectionPlugin( if (mod.id) { const id = mod.id const importerFile = normalizeFilePath(id) + + // Invalidate extensionless-resolution cache entries affected by this file. + extensionlessIdResolver.invalidateByFile(importerFile) shared.fileMarkerKind.delete(importerFile) // Invalidate per-env caches for (const envState of envStates.values()) { - envState.importLocCache.deleteByFile(importerFile) - - // Invalidate resolve cache using reverse index - const resolveKeys = envState.resolveCacheByFile.get(importerFile) - if (resolveKeys) { - for (const key of resolveKeys) { - envState.resolveCache.delete(key) - } - envState.resolveCacheByFile.delete(importerFile) - } - - // Invalidate graph edges - envState.graph.invalidate(importerFile) - envState.deniedEdges.delete(importerFile) - envState.mockExportsByImporter.delete(importerFile) - envState.serverFnLookupModules.delete(importerFile) - envState.pendingViolations.delete(importerFile) - - // Invalidate transform result cache for this file. - const transformKeys = - envState.transformResultKeysByFile.get(importerFile) - if (transformKeys) { - for (const key of transformKeys) { - envState.transformResultCache.delete(key) - envState.postTransformImports.delete(key) - } - envState.transformResultKeysByFile.delete(importerFile) - } else { - // Fallback: at least clear the physical-file entry. - envState.transformResultCache.delete(importerFile) - envState.postTransformImports.delete(importerFile) - } + invalidateFileFromEnv(envState, importerFile) } } } @@ -1173,7 +1401,7 @@ export function importProtectionPlugin( : '(entry)' const isEntryResolve = !importer const filtered = - IMPORT_PROTECTION_DEBUG_FILTER === 'entry' + process.env.TSR_IMPORT_PROTECTION_DEBUG_FILTER === 'entry' ? isEntryResolve : matchesDebugFilter(source, importerPath) if (filtered) { @@ -1184,12 +1412,11 @@ export function importProtectionPlugin( importer: importerPath, isEntryResolve, command: config.command, - behavior: config.effectiveBehavior, }) } } - // Internal virtual modules + // Internal virtual modules (mock:build:N, mock-edge, mock-runtime, marker) const internalVirtualId = resolveInternalVirtualModuleId(source) if (internalVirtualId) return internalVirtualId @@ -1217,15 +1444,30 @@ export function importProtectionPlugin( env.serverFnLookupModules.has(normalizedImporter) || isScanResolve - // Dev mock mode: defer violations until post-transform data is - // available, then confirm/discard via graph reachability. + // Dev mock mode: defer all violations (including pre-transform + // resolves) until post-transform data is available, then + // confirm/discard via graph reachability. // Build mode (both mock and error): defer violations until // generateBundle so tree-shaking can eliminate false positives. const isDevMock = config.command === 'serve' && config.effectiveBehavior === 'mock' const isBuild = config.command === 'build' + const shouldDefer = shouldDeferViolation({ isBuild, isDevMock }) + + const resolveAgainstImporter = async (): Promise => { + const primary = await this.resolve(source, importer, { + skipSelf: true, + }) + if (primary) { + return canonicalizeResolvedId( + primary.id, + config.root, + resolveExtensionlessAbsoluteId, + ) + } - const shouldDefer = (isDevMock && !isPreTransformResolve) || isBuild + return null + } // Check if this is a marker import const markerKind = config.markerSpecifiers.serverOnly.has(source) @@ -1258,16 +1500,17 @@ export function importProtectionPlugin( source, { type: 'marker', - message: - markerKind === 'server' - ? `Module "${getRelativePath(normalizedImporter)}" is marked server-only but is imported in the client environment` - : `Module "${getRelativePath(normalizedImporter)}" is marked client-only but is imported in the server environment`, + message: buildMarkerViolationMessage( + getRelativePath(normalizedImporter), + markerKind, + ), }, ) const markerResult = await reportOrDeferViolation( this, env, normalizedImporter, + importer, info, shouldDefer, isPreTransformResolve, @@ -1284,6 +1527,60 @@ export function importProtectionPlugin( } } + // Retroactive marker violation detection: on cold starts, module + // A may import module B before B's marker is set (because B hasn't + // been processed yet). When B's marker is set (here), + // retroactively check all known importers of B in the graph and + // create deferred marker violations for them. Without this, + // cold-start ordering can miss marker violations that warm starts + // detect (warm starts see markers early from cached transforms). + // + // Uses lightweight `deferViolation` to avoid heavy side effects + // (mock module creation, export resolution). Immediately calls + // `processPendingViolations` to flush the deferred violations, + // because the marker resolveId fires during Vite's import + // analysis (after our transform hook) — there may be no + // subsequent transform invocation to flush them. + // + // Guarded by `violatesEnv` (per-environment) plus a per-env + // seen-set. The marker is shared across environments but each + // env's graph has its own edges; this ensures the check runs + // at most once per (env, module) pair. + const envRetroKey = `retro-marker:${normalizedImporter}` + if (violatesEnv && !env.seenViolations.has(envRetroKey)) { + env.seenViolations.add(envRetroKey) + let retroDeferred = false + const importersMap = env.graph.reverseEdges.get(normalizedImporter) + if (importersMap && importersMap.size > 0) { + for (const [importerFile, specifier] of importersMap) { + if (!specifier) continue + if (!shouldCheckImporter(importerFile)) continue + const markerInfo = await buildMarkerViolationFromResolvedImport( + provider, + env, + envName, + envType, + importerFile, + specifier, + normalizedImporter, + getRelativePath(normalizedImporter), + ) + if (markerInfo) { + deferViolation( + env, + importerFile, + markerInfo, + isPreTransformResolve, + ) + retroDeferred = true + } + } + } + if (retroDeferred) { + await processPendingViolations(env, this.warn.bind(this)) + } + } + return markerKind === 'server' ? resolvedMarkerVirtualModuleId('server') : resolvedMarkerVirtualModuleId('client') @@ -1299,7 +1596,9 @@ export function importProtectionPlugin( // 1. Specifier-based denial const specifierMatch = matchesAny(source, matchers.specifiers) if (specifierMatch) { - env.graph.addEdge(source, normalizedImporter, source) + if (!isPreTransformResolve) { + env.graph.addEdge(source, normalizedImporter, source) + } const info = await buildViolationInfo( provider, env, @@ -1314,10 +1613,24 @@ export function importProtectionPlugin( message: `Import "${source}" is denied in the ${envType} environment`, }, ) + + // Resolve the specifier so edge-survival can verify whether + // the import survives the Start compiler transform (e.g. + // factory-safe pattern strips imports inside .server() callbacks). + if (shouldDefer && !info.resolved) { + try { + const resolvedForInfo = await resolveAgainstImporter() + if (resolvedForInfo) info.resolved = resolvedForInfo + } catch { + // Non-fatal: edge-survival will skip unresolved specifiers + } + } + return reportOrDeferViolation( this, env, normalizedImporter, + importer, info, shouldDefer, isPreTransformResolve, @@ -1331,16 +1644,19 @@ export function importProtectionPlugin( if (env.resolveCache.has(cacheKey)) { resolved = env.resolveCache.get(cacheKey) ?? null } else { - const result = await this.resolve(source, importer, { - skipSelf: true, - }) - resolved = result ? normalizeFilePath(result.id) : null - env.resolveCache.set(cacheKey, resolved) - getOrCreate( - env.resolveCacheByFile, - normalizedImporter, - () => new Set(), - ).add(cacheKey) + resolved = await resolveAgainstImporter() + + // Only cache successful resolves. Null resolves can be + // order-dependent across importer variants (e.g. code-split + // `?tsr-split=...` ids) and may poison later lookups. + if (resolved !== null) { + env.resolveCache.set(cacheKey, resolved) + getOrCreate( + env.resolveCacheByFile, + normalizedImporter, + () => new Set(), + ).add(cacheKey) + } } if (resolved) { @@ -1351,7 +1667,9 @@ export function importProtectionPlugin( env.serverFnLookupModules.add(resolved) } - env.graph.addEdge(resolved, normalizedImporter, source) + if (!isPreTransformResolve) { + env.graph.addEdge(resolved, normalizedImporter, source) + } // Skip file-based and marker-based denial for resolved paths that // match the per-environment `excludeFiles` patterns. By default @@ -1371,7 +1689,7 @@ export function importProtectionPlugin( : undefined if (fileMatch) { - const info = await buildViolationInfo( + const info = await buildFileViolationInfo( provider, env, envName, @@ -1379,17 +1697,14 @@ export function importProtectionPlugin( importer, normalizedImporter, source, - { - type: 'file', - pattern: fileMatch.pattern, - resolved, - message: `Import "${source}" (resolved to "${relativePath}") is denied in the ${envType} environment`, - }, + resolved, + fileMatch.pattern, ) return reportOrDeferViolation( this, env, normalizedImporter, + importer, info, shouldDefer, isPreTransformResolve, @@ -1411,6 +1726,7 @@ export function importProtectionPlugin( this, env, normalizedImporter, + importer, markerInfo, shouldDefer, isPreTransformResolve, @@ -1447,16 +1763,51 @@ export function importProtectionPlugin( const env = envStates.get(envName) if (!env || env.deferredBuildViolations.length === 0) return + const candidateCache = new Map>() + const toModuleIdCandidates = (id: string): Array => { + let cached = candidateCache.get(id) + if (cached) return cached + + const out = new Set() + const normalized = normalizeFilePath(id) + out.add(id) + out.add(normalized) + out.add(relativizePath(normalized, config.root)) + + if (normalized.startsWith(VITE_BROWSER_VIRTUAL_PREFIX)) { + const internal = `\0${normalized.slice(VITE_BROWSER_VIRTUAL_PREFIX.length)}` + out.add(internal) + out.add(relativizePath(normalizeFilePath(internal), config.root)) + } + + if (normalized.startsWith('\0')) { + const browser = `${VITE_BROWSER_VIRTUAL_PREFIX}${normalized.slice(1)}` + out.add(browser) + out.add(relativizePath(normalizeFilePath(browser), config.root)) + } + + cached = Array.from(out) + candidateCache.set(id, cached) + return cached + } + // Collect all module IDs that survived tree-shaking in this bundle. const survivingModules = new Set() for (const chunk of Object.values(bundle)) { if (chunk.type === 'chunk') { for (const moduleId of Object.keys(chunk.modules)) { - survivingModules.add(moduleId) + for (const candidate of toModuleIdCandidates(moduleId)) { + survivingModules.add(candidate) + } } } } + const didModuleSurvive = (moduleId: string): boolean => + toModuleIdCandidates(moduleId).some((candidate) => + survivingModules.has(candidate), + ) + // Check each deferred violation: if its check module survived // in the bundle, the import was NOT tree-shaken — real leak. const realViolations: Array = [] @@ -1465,8 +1816,34 @@ export function importProtectionPlugin( mockModuleId, checkModuleId, } of env.deferredBuildViolations) { - const checkId = checkModuleId ?? mockModuleId - if (!survivingModules.has(checkId)) continue + let survived: boolean + if (checkModuleId != null) { + // Marker violation: check if the importer survived + // (marker is about the file's directive, not a binding). + // Include transform-result keys (e.g. code-split variants) + // to cover all bundle representations of the importer. + const importerVariantIds = new Set([info.importer]) + const importerKeys = env.transformResultKeysByFile.get( + normalizeFilePath(info.importer), + ) + if (importerKeys) { + for (const key of importerKeys) { + importerVariantIds.add(key) + } + } + survived = false + for (const importerId of importerVariantIds) { + if (didModuleSurvive(importerId)) { + survived = true + break + } + } + } else { + // File/specifier violation: check if the mock module survived. + survived = didModuleSurvive(mockModuleId) + } + + if (!survived) continue if (config.onViolation) { const result = await config.onViolation(info) @@ -1485,12 +1862,7 @@ export function importProtectionPlugin( // Mock mode: warn for each surviving violation. const seen = new Set() for (const info of realViolations) { - const key = dedupeKey( - info.type, - info.importer, - info.specifier, - info.resolved, - ) + const key = dedupeKey(info) if (!seen.has(key)) { seen.add(key) this.warn(formatViolation(info, config.root)) @@ -1519,6 +1891,9 @@ export function importProtectionPlugin( async handler(code, id) { const envName = this.environment.name const file = normalizeFilePath(id) + const envType = getEnvType(envName) + const matchers = getRulesForEnvironment(envName) + const isBuild = config.command === 'build' if (IMPORT_PROTECTION_DEBUG) { if (matchesDebugFilter(file)) { @@ -1534,6 +1909,58 @@ export function importProtectionPlugin( return undefined } + // Self-denial: if this file is denied in the current environment + // (e.g. a `.server` file transformed in the client environment), + // replace its entire content with a mock module. + // + // This is the core mechanism for preventing cross-environment + // cache contamination: resolveId never returns virtual module + // IDs for file-based violations, so there is nothing for + // third-party resolver caches (e.g. vite-tsconfig-paths) to + // leak across environments. Each environment's transform + // independently decides whether the file is denied. + // + // In dev mode, this also solves the cold-start problem where + // the importer's AST is unavailable for export resolution: + // the denied file's own source code is always available here, + // so we parse its exports directly. + const selfFileMatch = checkFileDenial(getRelativePath(file), matchers) + if (selfFileMatch) { + // Parse exports once — shared by build and dev paths. + // Falls back to empty list on non-standard syntax. + let exportNames: Array = [] + try { + exportNames = collectNamedExports(code) + } catch { + // Parsing may fail on non-standard syntax + } + + if (isBuild) { + return generateSelfContainedMockModule(exportNames) + } + + // Dev mode: generate a mock that imports mock-runtime for + // runtime diagnostics (error/warn on property access). + const runtimeId = mockRuntimeModuleIdFromViolation( + { + type: 'file', + env: envType, + envType, + behavior: + config.effectiveBehavior === 'error' ? 'error' : 'mock', + importer: file, + specifier: relativizePath(file, config.root), + resolved: file, + pattern: selfFileMatch.pattern, + message: `File "${relativizePath(file, config.root)}" is denied in the ${envType} environment`, + trace: [], + }, + config.mockAccess, + config.root, + ) + return generateDevSelfDenialModule(exportNames, runtimeId) + } + // getCombinedSourcemap() returns the composed sourcemap let map: SourceMapLike | undefined try { @@ -1555,110 +1982,166 @@ export function importProtectionPlugin( const cacheKey = normalizePath(id) const envState = getEnv(envName) + const isServerFnLookup = id.includes(SERVER_FN_LOOKUP_QUERY) // Propagate SERVER_FN_LOOKUP status before import-analysis - if (id.includes(SERVER_FN_LOOKUP_QUERY)) { + if (isServerFnLookup) { envState.serverFnLookupModules.add(file) } - envState.transformResultCache.set(cacheKey, { + const result: TransformResult = { code, map, originalCode, lineIndex, - }) - - const keySet = getOrCreate( - envState.transformResultKeysByFile, - file, - () => new Set(), - ) - keySet.add(cacheKey) - - // Also store stripped-path entry for physical-file lookups. - if (cacheKey !== file) { - envState.transformResultCache.set(file, { - code, - map, - originalCode, - lineIndex, - }) - keySet.add(file) } - - // Resolve import sources to canonical paths for reachability checks. + cacheTransformResult(envState, file, cacheKey, result) + + // Build mode: only self-denial (above) and transform caching are + // needed. All violations are detected and deferred in resolveId; + // self-denial replaces denied file content; generateBundle checks + // tree-shaking survival. The import resolution loop below is + // dev-mode only — it resolves imports for graph reachability, + // catches violations missed on warm starts (where Vite caches + // resolveId), and rewrites denied imports to mock modules. + if (isBuild) return undefined + + // Dev mode: resolve imports, populate graph, detect violations, + // and rewrite denied imports. + const isDevMock = config.effectiveBehavior === 'mock' const importSources = extractImportSources(code) const resolvedChildren = new Set() + const deniedSourceReplacements = new Map() for (const src of importSources) { try { const resolved = await this.resolve(src, id, { skipSelf: true }) if (resolved && !resolved.external) { - const resolvedPath = normalizeFilePath(resolved.id) + const resolvedPath = canonicalizeResolvedId( + resolved.id, + config.root, + resolveExtensionlessAbsoluteId, + ) + resolvedChildren.add(resolvedPath) + + // When the resolved ID is a mock-module (from our + // resolveId returning a mock-edge ID), postTransformImports + // would only contain the mock ID. Edge-survival needs the + // real physical path so pending violations can be matched. + // + // For relative specifiers we can compute the physical path + // directly. For bare/alias specifiers, look up the real + // resolved path from the pending violations that were + // already stored by resolveId before this transform ran. + if (resolved.id.includes('tanstack-start-import-protection:')) { + let physicalPath: string | undefined + // Look up real resolved path from pending violations + const pending = envState.pendingViolations.get(file) + if (pending) { + const match = pending.find( + (pv) => pv.info.specifier === src && pv.info.resolved, + ) + if (match) physicalPath = match.info.resolved + } + if (physicalPath && physicalPath !== resolvedPath) { + resolvedChildren.add(physicalPath) + envState.graph.addEdge(physicalPath, file, src) + } + } + // Populate import graph edges for warm-start trace accuracy envState.graph.addEdge(resolvedPath, file, src) + + if (isDevMock) { + const relativePath = getRelativePath(resolvedPath) + const fileMatch = checkFileDenial(relativePath, matchers) + + if (fileMatch) { + const info = await buildFileViolationInfo( + envState.transformResultProvider, + envState, + envName, + envType, + id, + file, + src, + resolvedPath, + fileMatch.pattern, + ) + + const replacement = await reportOrDeferViolation( + this, + envState, + file, + id, + info, + isDevMock, + isServerFnLookup, + ) + + if (replacement) { + deniedSourceReplacements.set( + src, + replacement.startsWith('\0') + ? VITE_BROWSER_VIRTUAL_PREFIX + replacement.slice(1) + : replacement, + ) + } + } + } } } catch { // Non-fatal } } envState.postTransformImports.set(cacheKey, resolvedChildren) - if (cacheKey !== file) { + if (cacheKey !== file && !isServerFnLookup) { envState.postTransformImports.set(file, resolvedChildren) } await processPendingViolations(envState, this.warn.bind(this)) - return undefined - }, - }, - }, - { - // Separate plugin so the transform can be enabled/disabled per-environment. - name: 'tanstack-start-core:import-protection-mock-rewrite', - enforce: 'pre', - - applyToEnvironment(env) { - if (!config.enabled) return false - // We record expected named exports per importer so mock-edge modules - // can provide explicit ESM named exports. This is needed in both dev - // and build: native ESM (dev) requires real named exports, and - // Rolldown (used in Vite 6+) doesn't support Rollup's - // syntheticNamedExports flag which was previously relied upon in build. - // - // In build+error mode we still emit mock modules for deferred - // violations (checked at generateBundle time), so we always need the - // export name data when import protection is active. - return environmentNames.has(env.name) - }, + if (deniedSourceReplacements.size > 0) { + try { + const rewritten = rewriteDeniedImports( + code, + id, + new Set(deniedSourceReplacements.keys()), + (source: string) => + deniedSourceReplacements.get(source) ?? source, + ) - transform: { - filter: { - id: { - include: [/\.[cm]?[tj]sx?($|\?)/], - }, - }, - handler(code, id) { - const envName = this.environment.name - const envState = envStates.get(envName) - if (!envState) return undefined + if (!rewritten) { + return undefined + } - // Record export names per source for this importer so we can generate - // dev mock-edge modules without any disk reads. - try { - const importerFile = normalizeFilePath(id) - envState.mockExportsByImporter.set( - importerFile, - collectMockExportNamesBySource(code), - ) - } catch { - // Best-effort only + const normalizedMap = rewritten.map + ? { + ...rewritten.map, + version: Number(rewritten.map.version), + sourcesContent: + rewritten.map.sourcesContent?.map( + (s: string | null) => s ?? '', + ) ?? [], + } + : { + version: 3, + file: id, + names: [], + sources: [id], + sourcesContent: [code], + mappings: '', + } + + return { + code: rewritten.code, + map: normalizedMap, + } + } catch { + // Non-fatal: keep original code when rewrite fails. + } } - // Note: we no longer rewrite imports here. - // Dev uses per-importer mock-edge modules in resolveId so native ESM - // has explicit named exports, and runtime diagnostics are handled by - // the mock runtime proxy when those mocks are actually invoked. return undefined }, }, diff --git a/packages/start-plugin-core/src/import-protection-plugin/postCompileUsage.ts b/packages/start-plugin-core/src/import-protection-plugin/postCompileUsage.ts index 8a298cfb83f..adc4a98e296 100644 --- a/packages/start-plugin-core/src/import-protection-plugin/postCompileUsage.ts +++ b/packages/start-plugin-core/src/import-protection-plugin/postCompileUsage.ts @@ -1,6 +1,7 @@ import babel from '@babel/core' import * as t from '@babel/types' -import { parseAst } from '@tanstack/router-utils' +import { parseImportProtectionAst } from './ast' +import type { ParsedAst } from './ast' type UsagePos = { line: number; column0: number } @@ -15,8 +16,13 @@ export function findPostCompileUsagePos( code: string, source: string, ): UsagePos | undefined { - const ast = parseAst({ code }) + return findPostCompileUsagePosFromAst(parseImportProtectionAst(code), source) +} +function findPostCompileUsagePosFromAst( + ast: ParsedAst, + source: string, +): UsagePos | undefined { // Collect local names bound from this specifier const imported = new Set() for (const node of ast.program.body) { diff --git a/packages/start-plugin-core/src/import-protection-plugin/rewriteDeniedImports.ts b/packages/start-plugin-core/src/import-protection-plugin/rewriteDeniedImports.ts index cba0f435596..c9739396a3e 100644 --- a/packages/start-plugin-core/src/import-protection-plugin/rewriteDeniedImports.ts +++ b/packages/start-plugin-core/src/import-protection-plugin/rewriteDeniedImports.ts @@ -1,8 +1,11 @@ import * as t from '@babel/types' -import { generateFromAst, parseAst } from '@tanstack/router-utils' +import { generateFromAst } from '@tanstack/router-utils' import { MOCK_MODULE_ID } from './virtualModules' import { getOrCreate } from './utils' +import { parseImportProtectionAst } from './ast' +import type { SourceMapLike } from './sourceLocation' +import type { ParsedAst } from './ast' export function isValidExportName(name: string): boolean { if (name === 'default' || name.length === 0) return false @@ -34,16 +37,17 @@ export function isValidExportName(name: string): boolean { return true } -/** - * Best-effort static analysis of an importer's source to determine which - * named exports are needed per specifier, to keep native ESM valid in dev. - */ export function collectMockExportNamesBySource( code: string, ): Map> { - const ast = parseAst({ code }) + return collectMockExportNamesBySourceFromAst(parseImportProtectionAst(code)) +} +function collectMockExportNamesBySourceFromAst( + ast: ParsedAst, +): Map> { const namesBySource = new Map>() + const memberBindingToSource = new Map() const add = (source: string, name: string) => { if (name === 'default' || name.length === 0) return getOrCreate(namesBySource, source, () => new Set()).add(name) @@ -54,6 +58,14 @@ export function collectMockExportNamesBySource( if (node.importKind === 'type') continue const source = node.source.value for (const s of node.specifiers) { + if (t.isImportNamespaceSpecifier(s)) { + memberBindingToSource.set(s.local.name, source) + continue + } + if (t.isImportDefaultSpecifier(s)) { + memberBindingToSource.set(s.local.name, source) + continue + } if (!t.isImportSpecifier(s)) continue if (s.importKind === 'type') continue const importedName = t.isIdentifier(s.imported) @@ -76,6 +88,45 @@ export function collectMockExportNamesBySource( } } + // For namespace/default imports, collect property names used as + // `binding.foo`/`binding?.foo` so mock-edge modules can expose explicit ESM + // named exports required by Rolldown/native ESM. + if (memberBindingToSource.size > 0) { + const visit = (node: t.Node): void => { + if (t.isMemberExpression(node)) { + const object = node.object + if (t.isIdentifier(object)) { + const source = memberBindingToSource.get(object.name) + if (source) { + const property = node.property + if (!node.computed && t.isIdentifier(property)) { + add(source, property.name) + } else if (node.computed && t.isStringLiteral(property)) { + add(source, property.value) + } + } + } + } + + const keys = t.VISITOR_KEYS[node.type] + if (!keys) return + for (const key of keys) { + const child = (node as unknown as Record)[key] + if (Array.isArray(child)) { + for (const item of child) { + if (item && typeof item === 'object' && 'type' in item) { + visit(item as t.Node) + } + } + } else if (child && typeof child === 'object' && 'type' in child) { + visit(child as t.Node) + } + } + } + + visit(ast.program) + } + const out = new Map>() for (const [source, set] of namesBySource) { out.set(source, Array.from(set).sort()) @@ -83,6 +134,71 @@ export function collectMockExportNamesBySource( return out } +/** Collect all valid named export identifiers from the given code. */ +export function collectNamedExports(code: string): Array { + return collectNamedExportsFromAst(parseImportProtectionAst(code)) +} + +function collectIdentifiersFromPattern( + pattern: t.LVal, + add: (name: string) => void, +): void { + if (t.isIdentifier(pattern)) { + add(pattern.name) + } else if (t.isObjectPattern(pattern)) { + for (const prop of pattern.properties) { + if (t.isRestElement(prop)) { + collectIdentifiersFromPattern(prop.argument as t.LVal, add) + } else { + collectIdentifiersFromPattern(prop.value as t.LVal, add) + } + } + } else if (t.isArrayPattern(pattern)) { + for (const elem of pattern.elements) { + if (elem) collectIdentifiersFromPattern(elem as t.LVal, add) + } + } else if (t.isAssignmentPattern(pattern)) { + collectIdentifiersFromPattern(pattern.left, add) + } else if (t.isRestElement(pattern)) { + collectIdentifiersFromPattern(pattern.argument as t.LVal, add) + } +} + +function collectNamedExportsFromAst(ast: ParsedAst): Array { + const names = new Set() + const add = (name: string) => { + if (isValidExportName(name)) names.add(name) + } + + for (const node of ast.program.body) { + if (t.isExportNamedDeclaration(node)) { + if (node.exportKind === 'type') continue + + if (node.declaration) { + const decl = node.declaration + if (t.isFunctionDeclaration(decl) || t.isClassDeclaration(decl)) { + if (decl.id?.name) add(decl.id.name) + } else if (t.isVariableDeclaration(decl)) { + for (const d of decl.declarations) { + collectIdentifiersFromPattern(d.id as t.LVal, add) + } + } + } + + for (const s of node.specifiers) { + if (!t.isExportSpecifier(s)) continue + if (s.exportKind === 'type') continue + const exportedName = t.isIdentifier(s.exported) + ? s.exported.name + : s.exported.value + add(exportedName) + } + } + } + + return Array.from(names).sort() +} + /** * Rewrite static imports/re-exports from denied sources using Babel AST transforms. * @@ -105,8 +221,21 @@ export function rewriteDeniedImports( id: string, deniedSources: Set, getMockModuleId: (source: string) => string = () => MOCK_MODULE_ID, -): { code: string; map?: object | null } | undefined { - const ast = parseAst({ code }) +): { code: string; map?: SourceMapLike } | undefined { + return rewriteDeniedImportsFromAst( + parseImportProtectionAst(code), + id, + deniedSources, + getMockModuleId, + ) +} + +function rewriteDeniedImportsFromAst( + ast: ParsedAst, + id: string, + deniedSources: Set, + getMockModuleId: (source: string) => string = () => MOCK_MODULE_ID, +): { code: string; map?: SourceMapLike } | undefined { let modified = false let mockCounter = 0 @@ -243,5 +372,8 @@ export function rewriteDeniedImports( filename: id, }) - return { code: result.code, map: result.map } + return { + code: result.code, + ...(result.map ? { map: result.map as SourceMapLike } : {}), + } } diff --git a/packages/start-plugin-core/src/import-protection-plugin/sourceLocation.ts b/packages/start-plugin-core/src/import-protection-plugin/sourceLocation.ts index aef2f3e2e3f..5a0bef5350d 100644 --- a/packages/start-plugin-core/src/import-protection-plugin/sourceLocation.ts +++ b/packages/start-plugin-core/src/import-protection-plugin/sourceLocation.ts @@ -1,7 +1,8 @@ import { SourceMapConsumer } from 'source-map' import * as path from 'pathe' -import { escapeRegExp, getOrCreate, normalizeFilePath } from './utils' +import { findPostCompileUsagePos } from './postCompileUsage' +import { getOrCreate, normalizeFilePath } from './utils' import type { Loc } from './trace' import type { RawSourceMap } from 'source-map' @@ -270,36 +271,7 @@ export class ImportLocCache { } } -// Import specifier search (regex-based) - -const importPatternCache = new Map>() - -export function clearImportPatternCache(): void { - importPatternCache.clear() -} - -function findFirstImportSpecifierIndex(code: string, source: string): number { - let patterns = importPatternCache.get(source) - if (!patterns) { - const escaped = escapeRegExp(source) - patterns = [ - new RegExp(`\\bimport\\s+(['"])${escaped}\\1`), - new RegExp(`\\bfrom\\s+(['"])${escaped}\\1`), - new RegExp(`\\bimport\\s*\\(\\s*(['"])${escaped}\\1\\s*\\)`), - ] - importPatternCache.set(source, patterns) - } - - let best = -1 - for (const re of patterns) { - const m = re.exec(code) - if (!m) continue - const idx = m.index + m[0].indexOf(source) - if (idx === -1) continue - if (best === -1 || idx < best) best = idx - } - return best -} +export type FindImportSpecifierIndex = (code: string, source: string) => number /** * Find the location of an import statement in a transformed module @@ -311,6 +283,7 @@ export async function findImportStatementLocationFromTransformed( importerId: string, source: string, importLocCache: ImportLocCache, + findImportSpecifierIndex: FindImportSpecifierIndex, ): Promise { const importerFile = normalizeFilePath(importerId) const cacheKey = `${importerFile}::${source}` @@ -329,7 +302,7 @@ export async function findImportStatementLocationFromTransformed( const lineIndex = res.lineIndex ?? buildLineIndex(code) - const idx = findFirstImportSpecifierIndex(code, source) + const idx = findImportSpecifierIndex(code, source) if (idx === -1) { importLocCache.set(cacheKey, null) return undefined @@ -354,10 +327,6 @@ export async function findPostCompileUsageLocation( provider: TransformResultProvider, importerId: string, source: string, - findPostCompileUsagePos: ( - code: string, - source: string, - ) => { line: number; column0: number } | undefined, ): Promise { try { const importerFile = normalizeFilePath(importerId) @@ -391,6 +360,7 @@ export async function addTraceImportLocations( column?: number }>, importLocCache: ImportLocCache, + findImportSpecifierIndex: FindImportSpecifierIndex, ): Promise { for (const step of trace) { if (!step.specifier) continue @@ -400,6 +370,7 @@ export async function addTraceImportLocations( step.file, step.specifier, importLocCache, + findImportSpecifierIndex, ) if (!loc) continue step.line = loc.line @@ -435,67 +406,26 @@ export function buildCodeSnippet( const res = provider.getTransformResult(moduleId) if (!res) return undefined - const { code: transformedCode, originalCode } = res - - const sourceCode = originalCode ?? transformedCode + const sourceCode = res.originalCode ?? res.code const targetLine = loc.line // 1-indexed const targetCol = loc.column // 1-indexed if (targetLine < 1) return undefined - const wantStart = Math.max(1, targetLine - contextLines) - const wantEnd = targetLine + contextLines - - // Advance to wantStart - let lineNum = 1 - let pos = 0 - while (lineNum < wantStart && pos < sourceCode.length) { - const ch = sourceCode.charCodeAt(pos) - if (ch === 10) { - lineNum++ - } else if (ch === 13) { - lineNum++ - if ( - pos + 1 < sourceCode.length && - sourceCode.charCodeAt(pos + 1) === 10 - ) - pos++ - } - pos++ - } - if (lineNum < wantStart) return undefined - - const lines: Array = [] - let curLine = wantStart - while (curLine <= wantEnd && pos <= sourceCode.length) { - // Find end of current line - let eol = pos - while (eol < sourceCode.length) { - const ch = sourceCode.charCodeAt(eol) - if (ch === 10 || ch === 13) break - eol++ - } - lines.push(sourceCode.slice(pos, eol)) - curLine++ - if (eol < sourceCode.length) { - if ( - sourceCode.charCodeAt(eol) === 13 && - eol + 1 < sourceCode.length && - sourceCode.charCodeAt(eol + 1) === 10 - ) { - pos = eol + 2 - } else { - pos = eol + 1 - } - } else { - pos = eol + 1 - } + const allLines = sourceCode.split('\n') + // Strip trailing \r from \r\n line endings + for (let i = 0; i < allLines.length; i++) { + const line = allLines[i]! + if (line.endsWith('\r')) allLines[i] = line.slice(0, -1) } - if (targetLine > wantStart + lines.length - 1) return undefined + const wantStart = Math.max(1, targetLine - contextLines) + const wantEnd = Math.min(allLines.length, targetLine + contextLines) + + if (targetLine > allLines.length) return undefined - const actualEnd = wantStart + lines.length - 1 - const gutterWidth = String(actualEnd).length + const lines = allLines.slice(wantStart - 1, wantEnd) + const gutterWidth = String(wantEnd).length const sourceFile = loc.file ?? importerFile const snippetLines: Array = [] diff --git a/packages/start-plugin-core/src/import-protection-plugin/types.ts b/packages/start-plugin-core/src/import-protection-plugin/types.ts new file mode 100644 index 00000000000..e5058c9e00d --- /dev/null +++ b/packages/start-plugin-core/src/import-protection-plugin/types.ts @@ -0,0 +1,103 @@ +import type { CompileStartFrameworkOptions, GetConfigFn } from '../types' +import type { ImportProtectionBehavior } from '../schema' +import type { CompiledMatcher } from './matchers' +import type { ImportGraph, ViolationInfo } from './trace' +import type { + ImportLocCache, + TransformResult, + TransformResultProvider, +} from './sourceLocation' + +/** Compiled deny/exclude patterns for one environment (client or server). */ +export interface EnvRules { + specifiers: Array + files: Array + excludeFiles: Array +} + +export interface PluginConfig { + enabled: boolean + root: string + command: 'build' | 'serve' + srcDirectory: string + framework: CompileStartFrameworkOptions + effectiveBehavior: ImportProtectionBehavior + mockAccess: 'error' | 'warn' | 'off' + logMode: 'once' | 'always' + maxTraceDepth: number + compiledRules: { + client: EnvRules + server: EnvRules + } + includeMatchers: Array + excludeMatchers: Array + ignoreImporterMatchers: Array + markerSpecifiers: { serverOnly: Set; clientOnly: Set } + envTypeMap: Map + onViolation?: ( + info: ViolationInfo, + ) => boolean | void | Promise +} + +export interface EnvState { + graph: ImportGraph + mockExportsByImporter: Map>> + resolveCache: Map + resolveCacheByFile: Map> + importLocCache: ImportLocCache + seenViolations: Set + serverFnLookupModules: Set + transformResultCache: Map + transformResultKeysByFile: Map> + transformResultProvider: TransformResultProvider + postTransformImports: Map> + pendingViolations: Map> + deferredBuildViolations: Array +} + +export interface PendingViolation { + info: ViolationInfo + /** True when the violation originates from a pre-transform resolveId call + * (e.g. server-fn lookup). These need edge-survival verification because + * the Start compiler may strip the import later. */ + fromPreTransformResolve?: boolean +} + +export interface DeferredBuildViolation { + info: ViolationInfo + mockModuleId: string + checkModuleId?: string +} + +export interface SharedState { + fileMarkerKind: Map +} + +export interface ImportProtectionPluginOptions { + getConfig: GetConfigFn + framework: CompileStartFrameworkOptions + environments: Array<{ name: string; type: 'client' | 'server' }> + providerEnvName: string +} + +export type ModuleGraphNode = { + id?: string | null + url?: string + importers: Set +} + +export type ViolationReporter = { + warn: (msg: string) => void + error: (msg: string) => never + resolve?: ( + source: string, + importer?: string, + options?: { + skipSelf?: boolean + custom?: Record + }, + ) => Promise<{ id: string; external?: boolean | 'absolute' } | null> + getModuleInfo?: (id: string) => { code?: string | null } | null +} + +export type HandleViolationResult = string | undefined diff --git a/packages/start-plugin-core/src/import-protection-plugin/utils.ts b/packages/start-plugin-core/src/import-protection-plugin/utils.ts index 7a35ac924d9..ee9f9acdaa5 100644 --- a/packages/start-plugin-core/src/import-protection-plugin/utils.ts +++ b/packages/start-plugin-core/src/import-protection-plugin/utils.ts @@ -1,5 +1,17 @@ +import { + extname, + isAbsolute, + relative, + resolve as resolvePath, +} from 'node:path' import { normalizePath } from 'vite' +import { + IMPORT_PROTECTION_DEBUG, + IMPORT_PROTECTION_DEBUG_FILTER, + KNOWN_SOURCE_EXTENSIONS, +} from './constants' + export type Pattern = string | RegExp export function dedupePatterns(patterns: Array): Array { @@ -14,7 +26,8 @@ export function dedupePatterns(patterns: Array): Array { return out } -export function stripViteQuery(id: string): string { +/** Strip both `?query` and `#hash` from a module ID. */ +export function stripQueryAndHash(id: string): string { const q = id.indexOf('?') const h = id.indexOf('#') if (q === -1 && h === -1) return id @@ -24,8 +37,7 @@ export function stripViteQuery(id: string): string { } /** - * Strip Vite query parameters and normalize the path in one step. - * Replaces the repeated `normalizePath(stripViteQuery(id))` pattern. + * Strip Vite query/hash parameters and normalize the path in one step. * * Results are memoized because the same module IDs are processed many * times across resolveId, transform, and trace-building hooks. @@ -34,7 +46,7 @@ const normalizeFilePathCache = new Map() export function normalizeFilePath(id: string): string { let result = normalizeFilePathCache.get(id) if (result === undefined) { - result = normalizePath(stripViteQuery(id)) + result = normalizePath(stripQueryAndHash(id)) normalizeFilePathCache.set(id, result) } return result @@ -91,3 +103,110 @@ export function extractImportSources(code: string): Array { } return sources } + +/** Log import-protection debug output when debug mode is enabled. */ +export function debugLog(...args: Array): void { + if (!IMPORT_PROTECTION_DEBUG) return + console.warn('[import-protection:debug]', ...args) +} + +/** Check if any value matches the configured debug filter (if present). */ +export function matchesDebugFilter(...values: Array): boolean { + const debugFilter = IMPORT_PROTECTION_DEBUG_FILTER + if (!debugFilter) return true + return values.some((v) => v.includes(debugFilter)) +} + +/** Strip `?query` (but not `#hash`) from a module ID. */ +export function stripQuery(id: string): string { + const queryIndex = id.indexOf('?') + return queryIndex === -1 ? id : id.slice(0, queryIndex) +} + +export function withoutKnownExtension(id: string): string { + const ext = extname(id) + return KNOWN_SOURCE_EXTENSIONS.has(ext) ? id.slice(0, -ext.length) : id +} + +/** + * Check whether `filePath` is contained inside `directory` using a + * boundary-safe comparison. A naïve `filePath.startsWith(directory)` + * would incorrectly treat `/app/src2/foo.ts` as inside `/app/src`. + */ +export function isInsideDirectory( + filePath: string, + directory: string, +): boolean { + const rel = relative(resolvePath(directory), resolvePath(filePath)) + return rel.length > 0 && !rel.startsWith('..') && !isAbsolute(rel) +} + +/** + * Decide whether a violation should be deferred for later verification + * rather than reported immediately. + * + * Build mode: always defer — generateBundle checks tree-shaking. + * Dev mock mode: always defer — edge-survival verifies whether the Start + * compiler strips the import (factory-safe pattern). All violation + * types and specifier formats are handled uniformly by the + * edge-survival mechanism in processPendingViolations. + * Dev error mode: never defer — throw immediately (no mock fallback). + */ +export function shouldDeferViolation(opts: { + isBuild: boolean + isDevMock: boolean +}): boolean { + return opts.isBuild || opts.isDevMock +} + +export function buildSourceCandidates( + source: string, + resolved: string | undefined, + root: string, +): Set { + const candidates = new Set() + const push = (value: string | undefined) => { + if (!value) return + candidates.add(value) + candidates.add(stripQuery(value)) + candidates.add(withoutKnownExtension(stripQuery(value))) + } + + push(source) + if (resolved) { + push(resolved) + const relativeResolved = relativizePath(resolved, root) + push(relativeResolved) + push(`./${relativeResolved}`) + push(`/${relativeResolved}`) + } + + return candidates +} + +export function buildResolutionCandidates(id: string): Array { + const normalized = normalizeFilePath(id) + const stripped = stripQuery(normalized) + + return [...new Set([id, normalized, stripped])] +} + +export function canonicalizeResolvedId( + id: string, + root: string, + resolveExtensionlessAbsoluteId: (value: string) => string, +): string { + const stripped = stripQuery(id) + let normalized = normalizeFilePath(stripped) + + if ( + !isAbsolute(normalized) && + !normalized.startsWith('.') && + !normalized.startsWith('\0') && + !/^[a-zA-Z]+:/.test(normalized) + ) { + normalized = normalizeFilePath(resolvePath(root, normalized)) + } + + return resolveExtensionlessAbsoluteId(normalized) +} diff --git a/packages/start-plugin-core/src/import-protection-plugin/virtualModules.ts b/packages/start-plugin-core/src/import-protection-plugin/virtualModules.ts index c2e163edafe..de2e407a811 100644 --- a/packages/start-plugin-core/src/import-protection-plugin/virtualModules.ts +++ b/packages/start-plugin-core/src/import-protection-plugin/virtualModules.ts @@ -2,6 +2,7 @@ import { resolveViteId } from '../utils' import { VITE_ENVIRONMENT_NAMES } from '../constants' import { isValidExportName } from './rewriteDeniedImports' import { CLIENT_ENV_SUGGESTIONS } from './trace' +import { VITE_BROWSER_VIRTUAL_PREFIX } from './constants' import { relativizePath } from './utils' import type { ViolationInfo } from './trace' @@ -23,7 +24,7 @@ export const MOCK_RUNTIME_PREFIX = 'tanstack-start-import-protection:mock-runtime:' const RESOLVED_MOCK_RUNTIME_PREFIX = resolveViteId(MOCK_RUNTIME_PREFIX) -export const MARKER_PREFIX = 'tanstack-start-import-protection:marker:' +const MARKER_PREFIX = 'tanstack-start-import-protection:marker:' const RESOLVED_MARKER_PREFIX = resolveViteId(MARKER_PREFIX) const RESOLVED_MARKER_SERVER_ONLY = resolveViteId(`${MARKER_PREFIX}server-only`) @@ -55,6 +56,13 @@ const RESOLVED_VIRTUAL_MODULE_MATCHERS = [ RESOLVED_MARKER_PREFIX, ] as const +const RESOLVE_PREFIX_PAIRS = [ + [MOCK_EDGE_PREFIX, RESOLVED_MOCK_EDGE_PREFIX], + [MOCK_RUNTIME_PREFIX, RESOLVED_MOCK_RUNTIME_PREFIX], + [MOCK_BUILD_PREFIX, RESOLVED_MOCK_BUILD_PREFIX], + [MARKER_PREFIX, RESOLVED_MARKER_PREFIX], +] as const + /** * Resolve import-protection's internal virtual module IDs. * @@ -64,11 +72,26 @@ const RESOLVED_VIRTUAL_MODULE_MATCHERS = [ export function resolveInternalVirtualModuleId( source: string, ): string | undefined { - if (source === MOCK_MODULE_ID) return RESOLVED_MOCK_MODULE_ID - if (source.startsWith(MOCK_EDGE_PREFIX)) return resolveViteId(source) - if (source.startsWith(MOCK_RUNTIME_PREFIX)) return resolveViteId(source) - if (source.startsWith(MOCK_BUILD_PREFIX)) return resolveViteId(source) - if (source.startsWith(MARKER_PREFIX)) return resolveViteId(source) + if (source.startsWith(VITE_BROWSER_VIRTUAL_PREFIX)) { + return resolveInternalVirtualModuleId( + `\0${source.slice(VITE_BROWSER_VIRTUAL_PREFIX.length)}`, + ) + } + + if (source === MOCK_MODULE_ID || source === RESOLVED_MOCK_MODULE_ID) { + return RESOLVED_MOCK_MODULE_ID + } + + for (const [unresolvedPrefix, resolvedPrefix] of RESOLVE_PREFIX_PAIRS) { + if (source.startsWith(unresolvedPrefix)) { + return resolveViteId(source) + } + + if (source.startsWith(resolvedPrefix)) { + return source + } + } + return undefined } @@ -118,10 +141,9 @@ export function mockRuntimeModuleIdFromViolation( export function makeMockEdgeModuleId( exports: Array, - source: string, runtimeId: string, ): string { - const payload = { source, exports, runtimeId } + const payload = { exports, runtimeId } return `${MOCK_EDGE_PREFIX}${toBase64Url(JSON.stringify(payload))}` } @@ -254,36 +276,30 @@ export function loadSilentMockModule(): { code: string } { return { code: generateMockCode() } } -export function loadMockEdgeModule(encodedPayload: string): { code: string } { - let payload: { exports?: Array; runtimeId?: string } - try { - payload = JSON.parse(fromBase64Url(encodedPayload)) as typeof payload - } catch { - payload = { exports: [] } - } - const names: Array = Array.isArray(payload.exports) - ? payload.exports.filter( - (n): n is string => - typeof n === 'string' && n.length > 0 && n !== 'default', - ) - : [] - - const runtimeId: string = - typeof payload.runtimeId === 'string' && payload.runtimeId.length > 0 - ? payload.runtimeId - : MOCK_MODULE_ID +/** + * Filter export names to valid, non-default names. + */ +function filterExportNames(exports: ReadonlyArray): Array { + return exports.filter((n) => n.length > 0 && n !== 'default') +} - const exportLines: Array = [] +/** + * Generate ESM export lines that re-export named properties from `mock`. + * + * Produces `export const foo = mock.foo;` for valid identifiers and + * string-keyed re-exports for non-identifier names. + */ +function generateExportLines(names: ReadonlyArray): Array { + const lines: Array = [] const stringExports: Array<{ alias: string; name: string }> = [] for (let i = 0; i < names.length; i++) { const n = names[i]! if (isValidExportName(n)) { - exportLines.push(`export const ${n} = mock.${n};`) + lines.push(`export const ${n} = mock.${n};`) } else { - // ES2022 string-keyed export: use a temp var + re-export with string literal const alias = `__tss_str_${i}` - exportLines.push(`const ${alias} = mock[${JSON.stringify(n)}];`) + lines.push(`const ${alias} = mock[${JSON.stringify(n)}];`) stringExports.push({ alias, name: n }) } } @@ -292,9 +308,79 @@ export function loadMockEdgeModule(encodedPayload: string): { code: string } { const reexports = stringExports .map((s) => `${s.alias} as ${JSON.stringify(s.name)}`) .join(', ') - exportLines.push(`export { ${reexports} };`) + lines.push(`export { ${reexports} };`) } + return lines +} + +/** + * Generate a self-contained mock module with explicit named exports. + * + * Used by the transform hook's "self-denial" check: when a denied file + * (e.g. `.server.ts` in the client environment) is transformed, its entire + * content is replaced with this mock module. This avoids returning virtual + * module IDs from `resolveId`, which prevents cross-environment cache + * contamination from third-party resolver plugins. + * + * The generated code is side-effect-free and tree-shakeable. + */ +export function generateSelfContainedMockModule(exportNames: Array): { + code: string +} { + const mockCode = generateMockCode() + const exportLines = generateExportLines(filterExportNames(exportNames)) + + return { + code: `${mockCode} +${exportLines.join('\n')} +`, + } +} + +/** + * Generate a dev-mode mock module for self-denial transforms. + * + * Similar to `loadMockEdgeModule` but takes export names and a runtime ID + * directly (instead of parsing them from a base64url-encoded payload). + * Used by the transform hook when a denied file (e.g. `.server.ts` in + * the client environment) is replaced in dev mode. + * + * The generated module imports mock-runtime for runtime diagnostics + * (error/warn on property access) and re-exports explicit named exports + * so that `import { foo } from './denied.server'` works. + */ +export function generateDevSelfDenialModule( + exportNames: Array, + runtimeId: string, +): { code: string } { + const names = filterExportNames(exportNames) + const exportLines = generateExportLines(names) + + return { + code: `import mock from ${JSON.stringify(runtimeId)}; +${exportLines.join('\n')} +export default mock; +`, + } +} + +export function loadMockEdgeModule(encodedPayload: string): { code: string } { + let payload: { exports?: Array; runtimeId?: string } + try { + payload = JSON.parse(fromBase64Url(encodedPayload)) as typeof payload + } catch { + payload = { exports: [] } + } + const names = filterExportNames(payload.exports ?? []) + + const runtimeId: string = + typeof payload.runtimeId === 'string' && payload.runtimeId.length > 0 + ? payload.runtimeId + : MOCK_MODULE_ID + + const exportLines = generateExportLines(names) + return { code: `import mock from ${JSON.stringify(runtimeId)}; ${exportLines.join('\n')} diff --git a/packages/start-plugin-core/src/start-compiler-plugin/plugin.ts b/packages/start-plugin-core/src/start-compiler-plugin/plugin.ts index 388750ad923..ef53af07e50 100644 --- a/packages/start-plugin-core/src/start-compiler-plugin/plugin.ts +++ b/packages/start-plugin-core/src/start-compiler-plugin/plugin.ts @@ -1,7 +1,11 @@ import assert from 'node:assert' import { VIRTUAL_MODULES } from '@tanstack/start-server-core' import { resolve as resolvePath } from 'pathe' -import { TRANSFORM_ID_REGEX, VITE_ENVIRONMENT_NAMES } from '../constants' +import { + SERVER_FN_LOOKUP, + TRANSFORM_ID_REGEX, + VITE_ENVIRONMENT_NAMES, +} from '../constants' import { KindDetectionPatterns, LookupKindsPerEnv, @@ -82,7 +86,8 @@ const getLookupConfigurationsForEnv = ( ] } } -export const SERVER_FN_LOOKUP = 'server-fn-module-lookup' +// Re-export from shared constants for backwards compatibility +export { SERVER_FN_LOOKUP } function resolveViteId(id: string) { return `\0${id}` diff --git a/packages/start-plugin-core/tests/importProtection/rewriteDeniedImports.test.ts b/packages/start-plugin-core/tests/importProtection/rewriteDeniedImports.test.ts index 9ea0e7afd5e..49b9e61f7e8 100644 --- a/packages/start-plugin-core/tests/importProtection/rewriteDeniedImports.test.ts +++ b/packages/start-plugin-core/tests/importProtection/rewriteDeniedImports.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'vitest' import { + collectNamedExports, collectMockExportNamesBySource, isValidExportName, } from '../../src/import-protection-plugin/rewriteDeniedImports' @@ -46,8 +47,50 @@ describe('collectMockExportNamesBySource', () => { expect(result.has('mod-a')).toBe(false) }) - test('ignores namespace imports', () => { - const code = `import * as ns from 'mod-a'` + test('collects namespace member usage', () => { + const code = [ + `import * as ns from 'mod-a'`, + `const a = ns.alpha`, + `const b = ns.beta`, + ].join('\n') + const result = collectMockExportNamesBySource(code) + expect(result.get('mod-a')).toEqual(['alpha', 'beta']) + }) + + test('collects optional namespace member usage', () => { + const code = [ + `import * as ns from 'mod-a'`, + `const maybe = ns.gamma?.()`, + ].join('\n') + const result = collectMockExportNamesBySource(code) + expect(result.get('mod-a')).toEqual(['gamma']) + }) + + test('collects string-literal computed namespace member usage', () => { + const code = [ + `import * as ns from 'mod-a'`, + `const val = ns['delta']`, + ].join('\n') + const result = collectMockExportNamesBySource(code) + expect(result.get('mod-a')).toEqual(['delta']) + }) + + test('collects member usage from default imports', () => { + const code = [ + `import mock from 'mod-a'`, + `const a = mock.getSecret`, + `const b = mock['foo-bar']`, + ].join('\n') + const result = collectMockExportNamesBySource(code) + expect(result.get('mod-a')).toEqual(['foo-bar', 'getSecret']) + }) + + test('ignores non-literal computed namespace member usage', () => { + const code = [ + `import * as ns from 'mod-a'`, + `const key = 'epsilon'`, + `const val = ns[key]`, + ].join('\n') const result = collectMockExportNamesBySource(code) expect(result.has('mod-a')).toBe(false) }) @@ -128,3 +171,66 @@ describe('collectMockExportNamesBySource', () => { expect(result.get('mod-a')).toEqual(['foo-bar']) }) }) + +describe('collectNamedExports', () => { + test('collects declaration exports', () => { + const code = [ + `export function alpha() {}`, + `export const beta = 1`, + `export class Gamma {}`, + ].join('\n') + expect(collectNamedExports(code)).toEqual(['Gamma', 'alpha', 'beta']) + }) + + test('collects named export specifiers and aliases', () => { + const code = [ + `const local = 1`, + `const x = 2`, + `export { local as renamed, x }`, + ].join('\n') + expect(collectNamedExports(code)).toEqual(['renamed', 'x']) + }) + + test('ignores default and type-only exports', () => { + const code = [ + `export default function main() {}`, + `export type { Foo } from 'mod-a'`, + ].join('\n') + expect(collectNamedExports(code)).toEqual([]) + }) + + test('collects destructured object pattern exports', () => { + const code = `export const { a, b } = obj` + expect(collectNamedExports(code)).toEqual(['a', 'b']) + }) + + test('collects destructured array pattern exports', () => { + const code = `export const [x, y] = arr` + expect(collectNamedExports(code)).toEqual(['x', 'y']) + }) + + test('collects renamed destructured exports', () => { + const code = `export const { a: renamed } = obj` + expect(collectNamedExports(code)).toEqual(['renamed']) + }) + + test('collects nested destructured exports', () => { + const code = `export const { a: { b, c } } = obj` + expect(collectNamedExports(code)).toEqual(['b', 'c']) + }) + + test('collects destructured exports with defaults', () => { + const code = `export const { a = 1, b = 2 } = obj` + expect(collectNamedExports(code)).toEqual(['a', 'b']) + }) + + test('collects rest element in destructured exports', () => { + const code = `export const { a, ...rest } = obj` + expect(collectNamedExports(code)).toEqual(['a', 'rest']) + }) + + test('collects re-exports with aliases', () => { + const code = `export { default as hello } from './foo'` + expect(collectNamedExports(code)).toEqual(['hello']) + }) +}) diff --git a/packages/start-plugin-core/tests/importProtection/utils.test.ts b/packages/start-plugin-core/tests/importProtection/utils.test.ts index e10f25d3239..19833b26fc1 100644 --- a/packages/start-plugin-core/tests/importProtection/utils.test.ts +++ b/packages/start-plugin-core/tests/importProtection/utils.test.ts @@ -1,13 +1,20 @@ import { describe, expect, test } from 'vitest' import { + buildResolutionCandidates, + buildSourceCandidates, + canonicalizeResolvedId, dedupePatterns, escapeRegExp, extractImportSources, getOrCreate, + isInsideDirectory, normalizeFilePath, relativizePath, - stripViteQuery, + shouldDeferViolation, + stripQuery, + stripQueryAndHash, + withoutKnownExtension, } from '../../src/import-protection-plugin/utils' describe('dedupePatterns', () => { @@ -34,22 +41,119 @@ describe('dedupePatterns', () => { }) }) -describe('stripViteQuery', () => { +describe('stripQueryAndHash', () => { test('strips ?query params', () => { - expect(stripViteQuery('/a/b.ts?x=1')).toBe('/a/b.ts') + expect(stripQueryAndHash('/a/b.ts?x=1')).toBe('/a/b.ts') }) test('strips #fragments', () => { - expect(stripViteQuery('/a/b.ts#hash')).toBe('/a/b.ts') + expect(stripQueryAndHash('/a/b.ts#hash')).toBe('/a/b.ts') }) test('strips whichever comes first of ? and #', () => { - expect(stripViteQuery('/a/b.ts?x=1#hash')).toBe('/a/b.ts') - expect(stripViteQuery('/a/b.ts#hash?x=1')).toBe('/a/b.ts') + expect(stripQueryAndHash('/a/b.ts?x=1#hash')).toBe('/a/b.ts') + expect(stripQueryAndHash('/a/b.ts#hash?x=1')).toBe('/a/b.ts') }) test('returns path unchanged when no query or fragment', () => { - expect(stripViteQuery('/a/b.ts')).toBe('/a/b.ts') + expect(stripQueryAndHash('/a/b.ts')).toBe('/a/b.ts') + }) +}) + +describe('stripQuery', () => { + test('strips query params only', () => { + expect(stripQuery('/a/b.ts?x=1')).toBe('/a/b.ts') + expect(stripQuery('/a/b.ts#hash')).toBe('/a/b.ts#hash') + }) +}) + +describe('withoutKnownExtension', () => { + test('removes known source extension', () => { + expect(withoutKnownExtension('/a/b.ts')).toBe('/a/b') + expect(withoutKnownExtension('/a/b.tsx')).toBe('/a/b') + }) + + test('keeps unknown extension', () => { + expect(withoutKnownExtension('/a/b.css')).toBe('/a/b.css') + }) +}) + +describe('buildSourceCandidates', () => { + test('includes source and resolved variants', () => { + const out = buildSourceCandidates('src/mod.ts', '/app/src/mod.ts', '/app') + expect(out.has('src/mod.ts')).toBe(true) + expect(out.has('src/mod')).toBe(true) + expect(out.has('/app/src/mod.ts')).toBe(true) + expect(out.has('/app/src/mod')).toBe(true) + expect(out.has('./src/mod.ts')).toBe(true) + expect(out.has('/src/mod.ts')).toBe(true) + }) +}) + +describe('buildResolutionCandidates', () => { + test('returns deduped id variants', () => { + expect(buildResolutionCandidates('/a/b.ts?x=1')).toEqual([ + '/a/b.ts?x=1', + '/a/b.ts', + ]) + }) +}) + +describe('canonicalizeResolvedId', () => { + test('normalizes and resolves non-absolute ids against root', () => { + expect(canonicalizeResolvedId('src/a.ts?x=1', '/app', (id) => id)).toBe( + '/app/src/a.ts', + ) + }) + + test('keeps absolute ids as absolute', () => { + expect( + canonicalizeResolvedId('/app/src/a.ts?x=1', '/app', (id) => id), + ).toBe('/app/src/a.ts') + }) +}) + +describe('shouldDeferViolation', () => { + test('defers in build mode', () => { + expect(shouldDeferViolation({ isBuild: true, isDevMock: false })).toBe(true) + }) + + test('defers in dev mock mode', () => { + expect(shouldDeferViolation({ isBuild: false, isDevMock: true })).toBe(true) + }) + + test('defers in build + dev mock', () => { + expect(shouldDeferViolation({ isBuild: true, isDevMock: true })).toBe(true) + }) + + test('does not defer in dev error mode', () => { + expect(shouldDeferViolation({ isBuild: false, isDevMock: false })).toBe( + false, + ) + }) +}) + +describe('isInsideDirectory', () => { + test('returns true for file inside directory', () => { + expect(isInsideDirectory('/app/src/foo.ts', '/app/src')).toBe(true) + }) + + test('returns true for file in nested subdirectory', () => { + expect(isInsideDirectory('/app/src/deep/nested/foo.ts', '/app/src')).toBe( + true, + ) + }) + + test('returns false for sibling directory with same prefix', () => { + expect(isInsideDirectory('/app/src2/foo.ts', '/app/src')).toBe(false) + }) + + test('returns false for parent directory', () => { + expect(isInsideDirectory('/app/foo.ts', '/app/src')).toBe(false) + }) + + test('returns false for exact directory match (no file inside)', () => { + expect(isInsideDirectory('/app/src', '/app/src')).toBe(false) }) }) diff --git a/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts b/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts index c88886de30c..29f925aed58 100644 --- a/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts +++ b/packages/start-plugin-core/tests/importProtection/virtualModules.test.ts @@ -3,6 +3,8 @@ import { MOCK_EDGE_PREFIX, MOCK_MODULE_ID, MOCK_RUNTIME_PREFIX, + generateDevSelfDenialModule, + generateSelfContainedMockModule, loadMarkerModule, loadMockEdgeModule, loadMockRuntimeModule, @@ -33,7 +35,6 @@ describe('loadMarkerModule', () => { describe('loadMockEdgeModule', () => { test('generates named exports from payload', () => { const payload = JSON.stringify({ - source: './secret.server', exports: ['getSecret', 'initDb'], runtimeId: MOCK_MODULE_ID, }) @@ -47,7 +48,6 @@ describe('loadMockEdgeModule', () => { test('handles empty exports array', () => { const payload = JSON.stringify({ - source: './x', exports: [], runtimeId: MOCK_MODULE_ID, }) @@ -60,7 +60,6 @@ describe('loadMockEdgeModule', () => { test('handles string-keyed (non-identifier) export names via re-export', () => { const payload = JSON.stringify({ - source: './x', exports: ['valid', 'default', '123invalid', 'also-invalid'], runtimeId: MOCK_MODULE_ID, }) @@ -84,7 +83,6 @@ describe('loadMockEdgeModule', () => { test('falls back to MOCK_MODULE_ID when runtimeId missing', () => { const payload = JSON.stringify({ - source: './x', exports: ['a'], }) const encoded = Buffer.from(payload, 'utf8').toString('base64url') @@ -213,24 +211,19 @@ describe('mockRuntimeModuleIdFromViolation', () => { }) describe('makeMockEdgeModuleId', () => { - test('encodes exports and source into module ID', () => { - const id = makeMockEdgeModuleId( - ['foo', 'bar'], - './secret.server', - MOCK_MODULE_ID, - ) + test('encodes exports into module ID payload', () => { + const id = makeMockEdgeModuleId(['foo', 'bar'], MOCK_MODULE_ID) expect(id.startsWith(MOCK_EDGE_PREFIX)).toBe(true) const encoded = id.slice(MOCK_EDGE_PREFIX.length) const payload = JSON.parse( Buffer.from(encoded, 'base64url').toString('utf8'), ) - expect(payload.source).toBe('./secret.server') expect(payload.exports).toEqual(['foo', 'bar']) expect(payload.runtimeId).toBe(MOCK_MODULE_ID) }) test('handles empty exports', () => { - const id = makeMockEdgeModuleId([], './x', MOCK_MODULE_ID) + const id = makeMockEdgeModuleId([], MOCK_MODULE_ID) const encoded = id.slice(MOCK_EDGE_PREFIX.length) const payload = JSON.parse( Buffer.from(encoded, 'base64url').toString('utf8'), @@ -238,3 +231,81 @@ describe('makeMockEdgeModuleId', () => { expect(payload.exports).toEqual([]) }) }) + +describe('generateSelfContainedMockModule', () => { + test('generates module with named exports', () => { + const result = generateSelfContainedMockModule(['getSecret', 'initDb']) + expect(result.code).toContain('export const getSecret = mock.getSecret;') + expect(result.code).toContain('export const initDb = mock.initDb;') + expect(result.code).toContain('export default mock') + expect(result.code).toContain('createMock') + expect(result.code).toContain('Proxy') + }) + + test('handles empty exports', () => { + const result = generateSelfContainedMockModule([]) + expect(result.code).toContain('export default mock') + expect(result.code).not.toContain('export const') + }) + + test('filters out default export', () => { + const result = generateSelfContainedMockModule(['default', 'foo']) + expect(result.code).toContain('export const foo = mock.foo;') + expect(result.code).not.toContain('export const default') + }) + + test('handles string-keyed (non-identifier) export names via re-export', () => { + const result = generateSelfContainedMockModule([ + 'valid', + '123invalid', + 'also-invalid', + ]) + expect(result.code).toContain('export const valid = mock.valid;') + expect(result.code).toContain('__tss_str_') + expect(result.code).toContain('"123invalid"') + expect(result.code).toContain('"also-invalid"') + }) + + test('is self-contained (no imports)', () => { + const result = generateSelfContainedMockModule(['foo']) + // Should not import from any other module + expect(result.code).not.toMatch(/\bimport\b/) + }) +}) + +describe('generateDevSelfDenialModule', () => { + test('generates module with named exports and mock-runtime import', () => { + const runtimeId = 'tanstack-start-import-protection:mock-runtime:abc' + const result = generateDevSelfDenialModule( + ['getSecret', 'initDb'], + runtimeId, + ) + expect(result.code).toContain(`import mock from "${runtimeId}"`) + expect(result.code).toContain('export const getSecret = mock.getSecret;') + expect(result.code).toContain('export const initDb = mock.initDb;') + expect(result.code).toContain('export default mock;') + }) + + test('handles empty exports', () => { + const runtimeId = MOCK_MODULE_ID + const result = generateDevSelfDenialModule([], runtimeId) + expect(result.code).toContain(`import mock from "${runtimeId}"`) + expect(result.code).toContain('export default mock;') + expect(result.code).not.toContain('export const') + }) + + test('filters out default export', () => { + const result = generateDevSelfDenialModule(['default', 'foo'], 'mock-rt:x') + expect(result.code).toContain('export const foo = mock.foo;') + expect(result.code).not.toContain('export const default') + expect(result.code).toContain('export default mock;') + }) + + test('imports mock-runtime (not self-contained)', () => { + const result = generateDevSelfDenialModule(['foo'], 'mock-rt:xyz') + expect(result.code).toMatch(/\bimport\b/) + // Should NOT contain inline createMock/Proxy (that's the build-mode mock) + expect(result.code).not.toContain('createMock') + expect(result.code).not.toContain('Proxy') + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90b1eb10370..037ff0c64d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1860,6 +1860,49 @@ importers: vite: specifier: ^7.3.1 version: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + vite-tsconfig-paths: + specifier: ^6.1.0 + version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + devDependencies: + '@playwright/test': + specifier: ^1.57.0 + version: 1.57.0 + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/node': + specifier: 25.0.9 + version: 25.0.9 + '@types/react': + specifier: ^19.2.8 + version: 19.2.8 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.8) + srvx: + specifier: ^0.11.7 + version: 0.11.7 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + + e2e/react-start/import-protection-custom-config: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/react-start': + specifier: workspace:* + version: link:../../../packages/react-start + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + vite: + specifier: ^7.3.1 + version: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) devDependencies: '@playwright/test': specifier: ^1.57.0 @@ -24605,6 +24648,11 @@ packages: vite: optional: true + vite-tsconfig-paths@6.1.1: + resolution: {integrity: sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==} + peerDependencies: + vite: ^7.3.1 + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -38482,6 +38530,16 @@ snapshots: - supports-color - typescript + vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)): + dependencies: + debug: 4.4.3 + globrex: 0.1.2 + tsconfck: 3.1.4(typescript@5.9.3) + vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + transitivePeerDependencies: + - supports-color + - typescript + vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.8.1): dependencies: esbuild: 0.27.2