Skip to content
46 changes: 46 additions & 0 deletions packages/plugin-rsc/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ export type RscPluginOptions = {

/** Escape hatch for Waku's `allowServer` */
keepUseCientProxy?: boolean

/**
* Build-time validation for client-only and server-only imports
* @default true
*/
validateImports?: boolean
}

export default function vitePluginRsc(
Expand Down Expand Up @@ -412,6 +418,10 @@ export default function vitePluginRsc(
}
},
},
// conditionally add import validation plugin
...(rscPluginOptions.validateImports !== false
? [validateImportPlugin()]
: []),
{
name: 'rsc:patch-browser-raw-import',
transform: {
Expand Down Expand Up @@ -1947,6 +1957,42 @@ export function __fix_cloudflare(): Plugin {
}
}

// 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
// https://github.com/sveltejs/kit/blob/84298477a014ec471839adf7a4448d91bc7949e4/packages/kit/src/exports/vite/index.js#L513
function validateImportPlugin(): Plugin {
return {
name: 'rsc:validate-imports',
enforce: 'pre',
resolveId(source, importer, options) {
// skip validation during optimizeDeps scan since for now
// we want to allow going through server/client boundary loosely
if (isScanBuild || ('scan' in options && options.scan)) {
return
}

// Validate client-only imports in server environments
if (
source === 'client-only' &&
(this.environment.name === 'rsc' || this.environment.name === 'ssr')
Comment thread
hi-ogawa marked this conversation as resolved.
Outdated
) {
throw new Error(
`'client-only' is included in server build (importer: ${importer ?? 'unknown'})`,
)
}

// Validate server-only imports in client environment
if (source === 'server-only' && this.environment.name === 'client') {
throw new Error(
`'server-only' is included in client build (importer: ${importer ?? 'unknown'})`,
)
}

return
},
}
}

function sortObject<T extends object>(o: T) {
return Object.fromEntries(
Object.entries(o).sort(([a], [b]) => a.localeCompare(b)),
Expand Down
140 changes: 140 additions & 0 deletions packages/plugin-rsc/src/validate-imports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { describe, expect, it } from 'vitest'
Comment thread
hi-ogawa marked this conversation as resolved.
Outdated

// Extract the validateImportPlugin function for testing
function validateImportPlugin() {
let isScanBuild = false

return {
name: 'rsc:validate-imports',
enforce: 'pre' as const,
resolveId(source: string, importer?: string, options?: { scan?: boolean }) {
// skip validation during optimizeDeps scan since for now
// we want to allow going through server/client boundary loosely
if (isScanBuild || (options && 'scan' in options && options.scan)) {
return
}

// Validate client-only imports in server environments
if (
source === 'client-only' &&
(this.environment.name === 'rsc' || this.environment.name === 'ssr')
) {
throw new Error(
`'client-only' is included in server build (importer: ${importer ?? 'unknown'})`,
)
}

// Validate server-only imports in client environment
if (source === 'server-only' && this.environment.name === 'client') {
throw new Error(
`'server-only' is included in client build (importer: ${importer ?? 'unknown'})`,
)
}

return
},
}
}

describe('validateImportPlugin', () => {
it('should allow client-only imports in client environment', () => {
const plugin = validateImportPlugin()
const context = { environment: { name: 'client' } }

expect(() =>
plugin.resolveId.call(context, 'client-only', 'test.tsx', {}),
).not.toThrow()
})

it('should allow server-only imports in rsc environment', () => {
const plugin = validateImportPlugin()
const context = { environment: { name: 'rsc' } }

expect(() =>
plugin.resolveId.call(context, 'server-only', 'test.tsx', {}),
).not.toThrow()
})

it('should allow server-only imports in ssr environment', () => {
const plugin = validateImportPlugin()
const context = { environment: { name: 'ssr' } }

expect(() =>
plugin.resolveId.call(context, 'server-only', 'test.tsx', {}),
).not.toThrow()
})

it('should allow non-restricted imports in any environment', () => {
const plugin = validateImportPlugin()
const clientContext = { environment: { name: 'client' } }
const rscContext = { environment: { name: 'rsc' } }
const ssrContext = { environment: { name: 'ssr' } }

expect(() =>
plugin.resolveId.call(clientContext, 'react', 'test.tsx', {}),
).not.toThrow()

expect(() =>
plugin.resolveId.call(rscContext, 'react', 'test.tsx', {}),
).not.toThrow()

expect(() =>
plugin.resolveId.call(ssrContext, 'react', 'test.tsx', {}),
).not.toThrow()
})

it('should block client-only imports in rsc environment', () => {
const plugin = validateImportPlugin()
const context = { environment: { name: 'rsc' } }

expect(() =>
plugin.resolveId.call(context, 'client-only', 'test.tsx', {}),
).toThrow("'client-only' is included in server build (importer: test.tsx)")
})

it('should block client-only imports in ssr environment', () => {
const plugin = validateImportPlugin()
const context = { environment: { name: 'ssr' } }

expect(() =>
plugin.resolveId.call(context, 'client-only', 'test.tsx', {}),
).toThrow("'client-only' is included in server build (importer: test.tsx)")
})

it('should block server-only imports in client environment', () => {
const plugin = validateImportPlugin()
const context = { environment: { name: 'client' } }

expect(() =>
plugin.resolveId.call(context, 'server-only', 'test.tsx', {}),
).toThrow("'server-only' is included in client build (importer: test.tsx)")
})

it('should skip validation during scan mode', () => {
const plugin = validateImportPlugin()
const clientContext = { environment: { name: 'client' } }
const rscContext = { environment: { name: 'rsc' } }

// These should not throw even though they would normally be invalid
expect(() =>
plugin.resolveId.call(clientContext, 'server-only', 'test.tsx', {
scan: true,
}),
).not.toThrow()

expect(() =>
plugin.resolveId.call(rscContext, 'client-only', 'test.tsx', {
scan: true,
}),
).not.toThrow()
})

it('should handle missing importer gracefully', () => {
const plugin = validateImportPlugin()
const context = { environment: { name: 'client' } }

expect(() =>
plugin.resolveId.call(context, 'server-only', undefined, {}),
).toThrow("'server-only' is included in client build (importer: unknown)")
})
})
Loading