Skip to content
160 changes: 160 additions & 0 deletions packages/plugin-rsc/e2e/validate-imports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { test, expect } from '@playwright/test'
import { setupInlineFixture, useFixture, type Fixture } from './fixture'
import { x } from 'tinyexec'
import { expectNoPageError, waitForHydration } from './helper'

test.describe('validate imports', () => {
test.describe('valid imports', () => {
const root = 'examples/e2e/temp/validate-imports'
test.beforeAll(async () => {
await setupInlineFixture({
src: 'examples/starter',
dest: root,
files: {
'src/client.tsx': /* tsx */ `
"use client";
import 'client-only';

export function TestClient() {
return <div>[test-client]</div>
}
`,
'src/root.tsx': /* tsx */ `
import { TestClient } from './client.tsx'
import 'server-only';

export function Root() {
return (
<html lang="en">
<head>
<meta charSet="UTF-8" />
</head>
<body>
<div>[test-server]</div>
<TestClient />
</body>
</html>
)
}
`,
},
})
})

test.describe('dev', () => {
const f = useFixture({ root, mode: 'dev' })
defineTest(f)
})

test.describe('build', () => {
const f = useFixture({ root, mode: 'build' })
defineTest(f)
})

function defineTest(f: Fixture) {
test('basic', async ({ page }) => {
using _ = expectNoPageError(page)
await page.goto(f.url())
await waitForHydration(page)
})
}
})

test.describe('server-only on client', () => {
const root = 'examples/e2e/temp/validate-server-only'
test.beforeAll(async () => {
await setupInlineFixture({
src: 'examples/starter',
dest: root,
files: {
'src/client.tsx': /* tsx */ `
"use client";
import 'server-only';

export function TestClient() {
return <div>[test-client]</div>
}
`,
'src/root.tsx': /* tsx */ `
import { TestClient } from './client.tsx'
import 'server-only';

export function Root() {
return (
<html lang="en">
<head>
<meta charSet="UTF-8" />
</head>
<body>
<div>[test-server]</div>
<TestClient />
</body>
</html>
)
}
`,
},
})
})

test('build', async () => {
const result = await x('pnpm', ['build'], {
throwOnError: false,
nodeOptions: { cwd: root },
})
expect(result.stderr).toContain(
`'server-only' cannot be imported in client build`,
)
expect(result.exitCode).not.toBe(0)
})
})

test.describe('client-only on server', () => {
const root = 'examples/e2e/temp/validate-client-only'
test.beforeAll(async () => {
await setupInlineFixture({
src: 'examples/starter',
dest: root,
files: {
'src/client.tsx': /* tsx */ `
"use client";
import 'client-only';

export function TestClient() {
return <div>[test-client]</div>
}
`,
'src/root.tsx': /* tsx */ `
import { TestClient } from './client.tsx'
import 'client-only';

export function Root() {
return (
<html lang="en">
<head>
<meta charSet="UTF-8" />
</head>
<body>
<div>[test-server]</div>
<TestClient />
</body>
</html>
)
}
`,
},
})
})

test('build', async () => {
const result = await x('pnpm', ['build'], {
throwOnError: false,
nodeOptions: { cwd: root },
})
expect(result.stderr).toContain(
`'client-only' cannot be imported in server build`,
)
expect(result.exitCode).not.toBe(0)
})
})
})
52 changes: 52 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

/**
* Enable build-time validation of 'client-only' and 'server-only' imports
* @default true
*/
validateImports?: boolean
}

export default function vitePluginRsc(
Expand Down Expand Up @@ -828,6 +834,9 @@ globalThis.AsyncLocalStorage = __viteRscAyncHooks.AsyncLocalStorage;
...vitePluginDefineEncryptionKey(rscPluginOptions),
...vitePluginFindSourceMapURL(),
...vitePluginRscCss({ rscCssTransform: rscPluginOptions.rscCssTransform }),
...(rscPluginOptions.validateImports !== false
? [validateImportPlugin()]
: []),
scanBuildStripPlugin(),
]
}
Expand Down Expand Up @@ -1968,6 +1977,49 @@ 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',
resolveId: {
order: 'pre',
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})`,
)
}
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/empty`, moduleSideEffects: false }
}

return
},
},
load(id) {
if (id.startsWith('\0virtual:vite-rsc/empty')) {
return `export {}`
}
},
}
}

function sortObject<T extends object>(o: T) {
return Object.fromEntries(
Object.entries(o).sort(([a], [b]) => a.localeCompare(b)),
Expand Down
Loading