From cad07538b450f46bcc79c3433189195d2d15610a Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 16 Sep 2025 17:07:56 +0900 Subject: [PATCH 1/7] fix(rsc): show import chain for server-only and client-only import error --- .../plugin-rsc/src/plugins/validate-import.ts | 126 +++++++++++++++--- 1 file changed, 110 insertions(+), 16 deletions(-) diff --git a/packages/plugin-rsc/src/plugins/validate-import.ts b/packages/plugin-rsc/src/plugins/validate-import.ts index 3b40126e4..49b602d71 100644 --- a/packages/plugin-rsc/src/plugins/validate-import.ts +++ b/packages/plugin-rsc/src/plugins/validate-import.ts @@ -1,4 +1,4 @@ -import type { Plugin } from 'vite' +import type { DevEnvironment, Plugin, Rollup } from 'vite' // https://github.com/vercel/next.js/blob/90f564d376153fe0b5808eab7b83665ee5e08aaf/packages/next/src/build/webpack-config.ts#L1249-L1280 // https://github.com/pcattori/vite-env-only/blob/68a0cc8546b9a37c181c0b0a025eb9b62dbedd09/src/deny-imports.ts @@ -8,37 +8,131 @@ export function validateImportPlugin(): Plugin { name: 'rsc:validate-imports', resolveId: { order: 'pre', - async handler(source, importer, options) { + async handler(source, _importer, options) { // optimizer is not aware of server/client boudnary so skip if ('scan' in options && options.scan) { return } // Validate client-only imports in server environments - if (source === 'client-only') { - if (this.environment.name === 'rsc') { - throw new Error( - `'client-only' cannot be imported in server build (importer: '${importer ?? 'unknown'}', environment: ${this.environment.name})`, - ) + if (source === 'client-only' || source === 'server-only') { + if ( + (source === 'client-only' && this.environment.name === 'rsc') || + (source === 'server-only' && this.environment.name !== 'rsc') + ) { + return { + id: `\0virtual:vite-rsc/validate-imports/invalid/${source}`, + moduleSideEffects: true, + } } - return { id: `\0virtual:vite-rsc/empty`, moduleSideEffects: false } - } - if (source === 'server-only') { - if (this.environment.name !== 'rsc') { - throw new Error( - `'server-only' cannot be imported in client build (importer: '${importer ?? 'unknown'}', environment: ${this.environment.name})`, - ) + return { + id: `\0virtual:vite-rsc/validate-imports/valid/${source}`, + moduleSideEffects: false, } - return { id: `\0virtual:vite-rsc/empty`, moduleSideEffects: false } } return }, }, load(id) { - if (id.startsWith('\0virtual:vite-rsc/empty')) { + if (id.startsWith('\0virtual:vite-rsc/validate-imports/invalid/')) { + // it should surface as build error but we make a runtime error just in case. + const source = id.slice(id.lastIndexOf('/') + 1) + return `throw new Error("invalid import of '${source}'")` + } + if (id.startsWith('\0virtual:vite-rsc/validate-imports/')) { return `export {}` } }, + // need a different way to probe module graph for dev and build + transform: { + order: 'post', + async handler(_code, id) { + if (this.environment.mode === 'dev') { + if (id.startsWith(`\0virtual:vite-rsc/validate-imports/invalid/`)) { + const chain = getImportChainDev(this.environment, id) + const error = formatError(chain, this.environment.name) + if (error) { + this.error({ + id: chain[1], + message: error, + }) + } + } + } + }, + }, + generateBundle() { + if (this.environment.mode === 'build') { + const serverOnly = getImportChainBuild( + this, + '\0virtual:vite-rsc/validate-imports/invalid/server-only', + ) + const serverOnlyError = formatError(serverOnly, this.environment.name) + if (serverOnlyError) { + throw new Error(serverOnlyError) + } + const clientOnly = getImportChainBuild( + this, + '\0virtual:vite-rsc/validate-imports/invalid/client-only', + ) + const clientOnlyError = formatError(clientOnly, this.environment.name) + if (clientOnlyError) { + throw new Error(clientOnlyError) + } + } + }, + } +} + +function getImportChainDev(environment: DevEnvironment, id: string) { + const chain: string[] = [] + const recurse = (id: string) => { + if (chain.includes(id)) return + const info = environment.moduleGraph.getModuleById(id) + if (!info) return + chain.push(id) + const next = [...info.importers][0] + if (next && next.id) { + recurse(next.id) + } + } + recurse(id) + return chain +} + +function getImportChainBuild(ctx: Rollup.PluginContext, id: string): string[] { + const chain: string[] = [] + const recurse = (id: string) => { + if (chain.includes(id)) return + const info = ctx.getModuleInfo(id) + if (!info) return + chain.push(id) + const next = info.importers[0] + if (next) { + recurse(next) + } + } + recurse(id) + return chain +} + +function formatError( + chain: string[], + environmentName: string, +): string | undefined { + if (chain.length === 0) return + const id = chain[0]! + const source = id.slice(id.lastIndexOf('/') + 1) + let result = `'${source}' cannot be imported in '${environmentName}' environment:\n` + result += chain + .slice(1, 6) + .map( + (id, i) => ' '.repeat(i + 1) + `imported by ${id.replaceAll('\0', '')}\n`, + ) + .join('') + if (chain.length > 6) { + result += ' '.repeat(7) + '...\n' } + return result } From 2889a4882a1a805be2649d54e8e9d1b3c5e7f645 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 16 Sep 2025 17:11:43 +0900 Subject: [PATCH 2/7] fix: fix format --- packages/plugin-rsc/src/plugins/validate-import.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/plugin-rsc/src/plugins/validate-import.ts b/packages/plugin-rsc/src/plugins/validate-import.ts index 49b602d71..bac8c1fe5 100644 --- a/packages/plugin-rsc/src/plugins/validate-import.ts +++ b/packages/plugin-rsc/src/plugins/validate-import.ts @@ -51,7 +51,7 @@ export function validateImportPlugin(): Plugin { if (this.environment.mode === 'dev') { if (id.startsWith(`\0virtual:vite-rsc/validate-imports/invalid/`)) { const chain = getImportChainDev(this.environment, id) - const error = formatError(chain, this.environment.name) + const error = formatError(chain) if (error) { this.error({ id: chain[1], @@ -68,7 +68,7 @@ export function validateImportPlugin(): Plugin { this, '\0virtual:vite-rsc/validate-imports/invalid/server-only', ) - const serverOnlyError = formatError(serverOnly, this.environment.name) + const serverOnlyError = formatError(serverOnly) if (serverOnlyError) { throw new Error(serverOnlyError) } @@ -76,7 +76,7 @@ export function validateImportPlugin(): Plugin { this, '\0virtual:vite-rsc/validate-imports/invalid/client-only', ) - const clientOnlyError = formatError(clientOnly, this.environment.name) + const clientOnlyError = formatError(clientOnly) if (clientOnlyError) { throw new Error(clientOnlyError) } @@ -117,14 +117,12 @@ function getImportChainBuild(ctx: Rollup.PluginContext, id: string): string[] { return chain } -function formatError( - chain: string[], - environmentName: string, -): string | undefined { +function formatError(chain: string[]): string | undefined { if (chain.length === 0) return const id = chain[0]! const source = id.slice(id.lastIndexOf('/') + 1) - let result = `'${source}' cannot be imported in '${environmentName}' environment:\n` + const buildName = source === 'server-only' ? 'client' : 'server' + let result = `'${source}' cannot be imported in ${buildName} build:\n` result += chain .slice(1, 6) .map( From 7740b6d709d6d1266afb10bf549f4f6d42e48b19 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 16 Sep 2025 17:15:00 +0900 Subject: [PATCH 3/7] tweak --- packages/plugin-rsc/src/plugins/validate-import.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/plugin-rsc/src/plugins/validate-import.ts b/packages/plugin-rsc/src/plugins/validate-import.ts index bac8c1fe5..6d832cdf7 100644 --- a/packages/plugin-rsc/src/plugins/validate-import.ts +++ b/packages/plugin-rsc/src/plugins/validate-import.ts @@ -51,7 +51,7 @@ export function validateImportPlugin(): Plugin { if (this.environment.mode === 'dev') { if (id.startsWith(`\0virtual:vite-rsc/validate-imports/invalid/`)) { const chain = getImportChainDev(this.environment, id) - const error = formatError(chain) + const error = formatError(chain, this.environment.name) if (error) { this.error({ id: chain[1], @@ -68,7 +68,7 @@ export function validateImportPlugin(): Plugin { this, '\0virtual:vite-rsc/validate-imports/invalid/server-only', ) - const serverOnlyError = formatError(serverOnly) + const serverOnlyError = formatError(serverOnly, this.environment.name) if (serverOnlyError) { throw new Error(serverOnlyError) } @@ -76,7 +76,7 @@ export function validateImportPlugin(): Plugin { this, '\0virtual:vite-rsc/validate-imports/invalid/client-only', ) - const clientOnlyError = formatError(clientOnly) + const clientOnlyError = formatError(clientOnly, this.environment.name) if (clientOnlyError) { throw new Error(clientOnlyError) } @@ -117,12 +117,15 @@ function getImportChainBuild(ctx: Rollup.PluginContext, id: string): string[] { return chain } -function formatError(chain: string[]): string | undefined { +function formatError( + chain: string[], + environmentName: string, +): string | undefined { if (chain.length === 0) return const id = chain[0]! const source = id.slice(id.lastIndexOf('/') + 1) const buildName = source === 'server-only' ? 'client' : 'server' - let result = `'${source}' cannot be imported in ${buildName} build:\n` + let result = `'${source}' cannot be imported in ${buildName} build ('${environmentName}' environment):\n` result += chain .slice(1, 6) .map( From a181f869c727402adbb80cf002b8d0976505743c Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 16 Sep 2025 17:17:57 +0900 Subject: [PATCH 4/7] comment --- packages/plugin-rsc/src/plugins/validate-import.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/plugin-rsc/src/plugins/validate-import.ts b/packages/plugin-rsc/src/plugins/validate-import.ts index 6d832cdf7..7f6ad4cd7 100644 --- a/packages/plugin-rsc/src/plugins/validate-import.ts +++ b/packages/plugin-rsc/src/plugins/validate-import.ts @@ -44,7 +44,7 @@ export function validateImportPlugin(): Plugin { return `export {}` } }, - // need a different way to probe module graph for dev and build + // for dev, use DevEnvironment.moduleGraph during post transform transform: { order: 'post', async handler(_code, id) { @@ -62,6 +62,7 @@ export function validateImportPlugin(): Plugin { } }, }, + // for build, use PluginContext.getModuleInfo during generateBundle generateBundle() { if (this.environment.mode === 'build') { const serverOnly = getImportChainBuild( From bb5f826e8da0e555e1186c50090f3f35c1aa0b99 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Wed, 17 Sep 2025 10:45:13 +0900 Subject: [PATCH 5/7] fix: show root relative path --- .../plugin-rsc/src/plugins/validate-import.ts | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/plugin-rsc/src/plugins/validate-import.ts b/packages/plugin-rsc/src/plugins/validate-import.ts index 7f6ad4cd7..9564be4c5 100644 --- a/packages/plugin-rsc/src/plugins/validate-import.ts +++ b/packages/plugin-rsc/src/plugins/validate-import.ts @@ -1,3 +1,4 @@ +import path from 'node:path' import type { DevEnvironment, Plugin, Rollup } from 'vite' // https://github.com/vercel/next.js/blob/90f564d376153fe0b5808eab7b83665ee5e08aaf/packages/next/src/build/webpack-config.ts#L1249-L1280 @@ -51,7 +52,11 @@ export function validateImportPlugin(): Plugin { if (this.environment.mode === 'dev') { if (id.startsWith(`\0virtual:vite-rsc/validate-imports/invalid/`)) { const chain = getImportChainDev(this.environment, id) - const error = formatError(chain, this.environment.name) + const error = formatError( + chain, + this.environment.name, + this.environment.config.root, + ) if (error) { this.error({ id: chain[1], @@ -69,7 +74,11 @@ export function validateImportPlugin(): Plugin { this, '\0virtual:vite-rsc/validate-imports/invalid/server-only', ) - const serverOnlyError = formatError(serverOnly, this.environment.name) + const serverOnlyError = formatError( + serverOnly, + this.environment.name, + this.environment.config.root, + ) if (serverOnlyError) { throw new Error(serverOnlyError) } @@ -77,7 +86,11 @@ export function validateImportPlugin(): Plugin { this, '\0virtual:vite-rsc/validate-imports/invalid/client-only', ) - const clientOnlyError = formatError(clientOnly, this.environment.name) + const clientOnlyError = formatError( + clientOnly, + this.environment.name, + this.environment.config.root, + ) if (clientOnlyError) { throw new Error(clientOnlyError) } @@ -121,6 +134,7 @@ function getImportChainBuild(ctx: Rollup.PluginContext, id: string): string[] { function formatError( chain: string[], environmentName: string, + root: string, ): string | undefined { if (chain.length === 0) return const id = chain[0]! @@ -130,7 +144,9 @@ function formatError( result += chain .slice(1, 6) .map( - (id, i) => ' '.repeat(i + 1) + `imported by ${id.replaceAll('\0', '')}\n`, + (id, i) => + ' '.repeat(i + 1) + + `imported by ${path.relative(root, id).replaceAll('\0', '')}\n`, ) .join('') if (chain.length > 6) { From c7295d8d5b192f08b9d791329ef33304bcaac773 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Wed, 17 Sep 2025 10:49:16 +0900 Subject: [PATCH 6/7] fix: validate during buildEnd --- packages/plugin-rsc/src/plugins/validate-import.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/plugin-rsc/src/plugins/validate-import.ts b/packages/plugin-rsc/src/plugins/validate-import.ts index 9564be4c5..30f3054ae 100644 --- a/packages/plugin-rsc/src/plugins/validate-import.ts +++ b/packages/plugin-rsc/src/plugins/validate-import.ts @@ -67,8 +67,9 @@ export function validateImportPlugin(): Plugin { } }, }, - // for build, use PluginContext.getModuleInfo during generateBundle - generateBundle() { + // for build, use PluginContext.getModuleInfo during buildEnd. + // rollup shows multiple errors if there are other build error from `buildEnd(error)`. + buildEnd() { if (this.environment.mode === 'build') { const serverOnly = getImportChainBuild( this, From c9a8ff6cc103565c21e9ec72a656d43620701b16 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Wed, 17 Sep 2025 11:09:06 +0900 Subject: [PATCH 7/7] fix: add module id to error --- .../plugin-rsc/src/plugins/validate-import.ts | 28 +++++++------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/packages/plugin-rsc/src/plugins/validate-import.ts b/packages/plugin-rsc/src/plugins/validate-import.ts index 30f3054ae..ef466e834 100644 --- a/packages/plugin-rsc/src/plugins/validate-import.ts +++ b/packages/plugin-rsc/src/plugins/validate-import.ts @@ -52,17 +52,11 @@ export function validateImportPlugin(): Plugin { if (this.environment.mode === 'dev') { if (id.startsWith(`\0virtual:vite-rsc/validate-imports/invalid/`)) { const chain = getImportChainDev(this.environment, id) - const error = formatError( + validateImportChain( chain, this.environment.name, this.environment.config.root, ) - if (error) { - this.error({ - id: chain[1], - message: error, - }) - } } } }, @@ -75,26 +69,20 @@ export function validateImportPlugin(): Plugin { this, '\0virtual:vite-rsc/validate-imports/invalid/server-only', ) - const serverOnlyError = formatError( + validateImportChain( serverOnly, this.environment.name, this.environment.config.root, ) - if (serverOnlyError) { - throw new Error(serverOnlyError) - } const clientOnly = getImportChainBuild( this, '\0virtual:vite-rsc/validate-imports/invalid/client-only', ) - const clientOnlyError = formatError( + validateImportChain( clientOnly, this.environment.name, this.environment.config.root, ) - if (clientOnlyError) { - throw new Error(clientOnlyError) - } } }, } @@ -132,11 +120,11 @@ function getImportChainBuild(ctx: Rollup.PluginContext, id: string): string[] { return chain } -function formatError( +function validateImportChain( chain: string[], environmentName: string, root: string, -): string | undefined { +) { if (chain.length === 0) return const id = chain[0]! const source = id.slice(id.lastIndexOf('/') + 1) @@ -153,5 +141,9 @@ function formatError( if (chain.length > 6) { result += ' '.repeat(7) + '...\n' } - return result + const error = new Error(result) + if (chain[1]) { + Object.assign(error, { id: chain[1] }) + } + throw error }