diff --git a/.changeset/bright-servers-protect.md b/.changeset/bright-servers-protect.md new file mode 100644 index 00000000000..51d779076be --- /dev/null +++ b/.changeset/bright-servers-protect.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +feat: reject server-only modules from client builds diff --git a/e2e/qwik-e2e/apps/server-only-modules-rejected/package.json b/e2e/qwik-e2e/apps/server-only-modules-rejected/package.json new file mode 100644 index 00000000000..5d1928711e9 --- /dev/null +++ b/e2e/qwik-e2e/apps/server-only-modules-rejected/package.json @@ -0,0 +1,7 @@ +{ + "description": "Server-only modules rejected e2e app", + "type": "module", + "__qwik__": { + "qwikRouter": true + } +} diff --git a/e2e/qwik-e2e/apps/server-only-modules-rejected/src/client-helper.ts b/e2e/qwik-e2e/apps/server-only-modules-rejected/src/client-helper.ts new file mode 100644 index 00000000000..795ddce6833 --- /dev/null +++ b/e2e/qwik-e2e/apps/server-only-modules-rejected/src/client-helper.ts @@ -0,0 +1,3 @@ +import { loadSecret } from './db.server'; + +export const getClientValue = () => loadSecret(); diff --git a/e2e/qwik-e2e/apps/server-only-modules-rejected/src/db.server.ts b/e2e/qwik-e2e/apps/server-only-modules-rejected/src/db.server.ts new file mode 100644 index 00000000000..ace9256c912 --- /dev/null +++ b/e2e/qwik-e2e/apps/server-only-modules-rejected/src/db.server.ts @@ -0,0 +1 @@ +export const loadSecret = () => 'SERVER_ONLY_SECRET'; diff --git a/e2e/qwik-e2e/apps/server-only-modules-rejected/src/dynamic-import-root.tsx b/e2e/qwik-e2e/apps/server-only-modules-rejected/src/dynamic-import-root.tsx new file mode 100644 index 00000000000..1b8bb91473b --- /dev/null +++ b/e2e/qwik-e2e/apps/server-only-modules-rejected/src/dynamic-import-root.tsx @@ -0,0 +1,12 @@ +import { component$, useSignal, useVisibleTask$ } from '@qwik.dev/core'; + +export default component$(() => { + const secret = useSignal(''); + + useVisibleTask$(async () => { + const module = await import('./db.server'); + secret.value = module.loadSecret(); + }); + + return
{secret.value}
; +}); diff --git a/e2e/qwik-e2e/apps/server-only-modules-rejected/src/entry.ssr.tsx b/e2e/qwik-e2e/apps/server-only-modules-rejected/src/entry.ssr.tsx new file mode 100644 index 00000000000..adee61db29c --- /dev/null +++ b/e2e/qwik-e2e/apps/server-only-modules-rejected/src/entry.ssr.tsx @@ -0,0 +1,9 @@ +import { renderToStream, type RenderToStreamOptions } from '@qwik.dev/core/server'; +import Root from './root'; + +export default function (opts: RenderToStreamOptions) { + return renderToStream(, { + base: '/server-only-modules-rejected/build/', + ...opts, + }); +} diff --git a/e2e/qwik-e2e/apps/server-only-modules-rejected/src/folder-client-helper.ts b/e2e/qwik-e2e/apps/server-only-modules-rejected/src/folder-client-helper.ts new file mode 100644 index 00000000000..ba746859bbf --- /dev/null +++ b/e2e/qwik-e2e/apps/server-only-modules-rejected/src/folder-client-helper.ts @@ -0,0 +1,3 @@ +import { loadFolderSecret } from './server/folder-secret'; + +export const getFolderClientValue = () => loadFolderSecret(); diff --git a/e2e/qwik-e2e/apps/server-only-modules-rejected/src/re-export-client-helper.ts b/e2e/qwik-e2e/apps/server-only-modules-rejected/src/re-export-client-helper.ts new file mode 100644 index 00000000000..3164cfeb8e6 --- /dev/null +++ b/e2e/qwik-e2e/apps/server-only-modules-rejected/src/re-export-client-helper.ts @@ -0,0 +1,3 @@ +import { loadReExportedSecret } from './re-exported-secret'; + +export const getReExportedClientValue = () => loadReExportedSecret(); diff --git a/e2e/qwik-e2e/apps/server-only-modules-rejected/src/re-export-root.tsx b/e2e/qwik-e2e/apps/server-only-modules-rejected/src/re-export-root.tsx new file mode 100644 index 00000000000..537a2b9e46f --- /dev/null +++ b/e2e/qwik-e2e/apps/server-only-modules-rejected/src/re-export-root.tsx @@ -0,0 +1,6 @@ +import { component$ } from '@qwik.dev/core'; +import { getReExportedClientValue } from './re-export-client-helper'; + +export default component$(() => { + return
{getReExportedClientValue()}
; +}); diff --git a/e2e/qwik-e2e/apps/server-only-modules-rejected/src/re-exported-secret.ts b/e2e/qwik-e2e/apps/server-only-modules-rejected/src/re-exported-secret.ts new file mode 100644 index 00000000000..eed87b59921 --- /dev/null +++ b/e2e/qwik-e2e/apps/server-only-modules-rejected/src/re-exported-secret.ts @@ -0,0 +1 @@ +export { loadSecret as loadReExportedSecret } from './db.server'; diff --git a/e2e/qwik-e2e/apps/server-only-modules-rejected/src/root.tsx b/e2e/qwik-e2e/apps/server-only-modules-rejected/src/root.tsx new file mode 100644 index 00000000000..2df9aba66e5 --- /dev/null +++ b/e2e/qwik-e2e/apps/server-only-modules-rejected/src/root.tsx @@ -0,0 +1,6 @@ +import { component$ } from '@qwik.dev/core'; +import { getClientValue } from './client-helper'; + +export default component$(() => { + return
{getClientValue()}
; +}); diff --git a/e2e/qwik-e2e/apps/server-only-modules-rejected/src/routes/index.tsx b/e2e/qwik-e2e/apps/server-only-modules-rejected/src/routes/index.tsx new file mode 100644 index 00000000000..7e5d6dcf857 --- /dev/null +++ b/e2e/qwik-e2e/apps/server-only-modules-rejected/src/routes/index.tsx @@ -0,0 +1,18 @@ +import type { DocumentHead } from '@qwik.dev/router'; +import { component$, useSignal } from '@qwik.dev/core'; +import { loadSecret } from '../db.server'; + +export default component$(() => { + const count = useSignal(0); + return ( +
+

Hi

+ + {loadSecret()} +
+ ); +}); + +export const head: DocumentHead = { + title: 'Server-only rejected', +}; diff --git a/e2e/qwik-e2e/apps/server-only-modules-rejected/src/server-folder-root.tsx b/e2e/qwik-e2e/apps/server-only-modules-rejected/src/server-folder-root.tsx new file mode 100644 index 00000000000..89c7a59fc20 --- /dev/null +++ b/e2e/qwik-e2e/apps/server-only-modules-rejected/src/server-folder-root.tsx @@ -0,0 +1,6 @@ +import { component$ } from '@qwik.dev/core'; +import { getFolderClientValue } from './folder-client-helper'; + +export default component$(() => { + return
{getFolderClientValue()}
; +}); diff --git a/e2e/qwik-e2e/apps/server-only-modules-rejected/src/server/folder-secret.ts b/e2e/qwik-e2e/apps/server-only-modules-rejected/src/server/folder-secret.ts new file mode 100644 index 00000000000..f7d6f410abb --- /dev/null +++ b/e2e/qwik-e2e/apps/server-only-modules-rejected/src/server/folder-secret.ts @@ -0,0 +1 @@ +export const loadFolderSecret = () => 'SERVER_FOLDER_SECRET'; diff --git a/e2e/qwik-e2e/apps/server-only-modules-rejected/tsconfig.json b/e2e/qwik-e2e/apps/server-only-modules-rejected/tsconfig.json new file mode 100644 index 00000000000..7f024c4259f --- /dev/null +++ b/e2e/qwik-e2e/apps/server-only-modules-rejected/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "allowJs": true, + "target": "ES2020", + "module": "ES2022", + "lib": ["es2022", "DOM", "WebWorker", "DOM.Iterable"], + "jsx": "react-jsx", + "jsxImportSource": "@qwik.dev/core", + "strict": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "Bundler", + "esModuleInterop": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "incremental": true, + "isolatedModules": true, + "outDir": "tmp", + "noEmit": true, + "types": ["node", "vite/client"], + "rootDir": ".", + "paths": { + "~/*": ["./src/*"] + } + } +} diff --git a/e2e/qwik-e2e/apps/server-only-modules/package.json b/e2e/qwik-e2e/apps/server-only-modules/package.json new file mode 100644 index 00000000000..d0a805ee548 --- /dev/null +++ b/e2e/qwik-e2e/apps/server-only-modules/package.json @@ -0,0 +1,7 @@ +{ + "description": "Server-only modules e2e app", + "type": "module", + "__qwik__": { + "qwikRouter": true + } +} diff --git a/e2e/qwik-e2e/apps/server-only-modules/src/db.server.ts b/e2e/qwik-e2e/apps/server-only-modules/src/db.server.ts new file mode 100644 index 00000000000..ace9256c912 --- /dev/null +++ b/e2e/qwik-e2e/apps/server-only-modules/src/db.server.ts @@ -0,0 +1 @@ +export const loadSecret = () => 'SERVER_ONLY_SECRET'; diff --git a/e2e/qwik-e2e/apps/server-only-modules/src/entry.ssr.tsx b/e2e/qwik-e2e/apps/server-only-modules/src/entry.ssr.tsx new file mode 100644 index 00000000000..7558cb2fa92 --- /dev/null +++ b/e2e/qwik-e2e/apps/server-only-modules/src/entry.ssr.tsx @@ -0,0 +1,9 @@ +import { renderToStream, type RenderToStreamOptions } from '@qwik.dev/core/server'; +import Root from './root'; + +export default function (opts: RenderToStreamOptions) { + return renderToStream(, { + base: '/server-only-modules/build/', + ...opts, + }); +} diff --git a/e2e/qwik-e2e/apps/server-only-modules/src/root.tsx b/e2e/qwik-e2e/apps/server-only-modules/src/root.tsx new file mode 100644 index 00000000000..24aad9cbd22 --- /dev/null +++ b/e2e/qwik-e2e/apps/server-only-modules/src/root.tsx @@ -0,0 +1,12 @@ +import { component$ } from '@qwik.dev/core'; +import { RouterOutlet, useQwikRouter } from '@qwik.dev/router'; + +export default component$(() => { + useQwikRouter(); + + return ( + + + + ); +}); diff --git a/e2e/qwik-e2e/apps/server-only-modules/src/routes/index.tsx b/e2e/qwik-e2e/apps/server-only-modules/src/routes/index.tsx new file mode 100644 index 00000000000..018e61a9c8e --- /dev/null +++ b/e2e/qwik-e2e/apps/server-only-modules/src/routes/index.tsx @@ -0,0 +1,19 @@ +import { component$ } from '@qwik.dev/core'; +import { routeLoader$ } from '@qwik.dev/router'; +import { loadSecret } from '../db.server'; +import { loadFolderSecret } from '../server/folder-secret'; + +export const useSecret = routeLoader$(() => { + return `${loadSecret()} ${loadFolderSecret()}`; +}); + +export default component$(() => { + const secret = useSecret(); + const exampleText = "import './fake.server'"; + return ( +
+ {secret.value} + {exampleText} +
+ ); +}); diff --git a/e2e/qwik-e2e/apps/server-only-modules/src/server/folder-secret.ts b/e2e/qwik-e2e/apps/server-only-modules/src/server/folder-secret.ts new file mode 100644 index 00000000000..f7d6f410abb --- /dev/null +++ b/e2e/qwik-e2e/apps/server-only-modules/src/server/folder-secret.ts @@ -0,0 +1 @@ +export const loadFolderSecret = () => 'SERVER_FOLDER_SECRET'; diff --git a/e2e/qwik-e2e/apps/server-only-modules/tsconfig.json b/e2e/qwik-e2e/apps/server-only-modules/tsconfig.json new file mode 100644 index 00000000000..7f024c4259f --- /dev/null +++ b/e2e/qwik-e2e/apps/server-only-modules/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "allowJs": true, + "target": "ES2020", + "module": "ES2022", + "lib": ["es2022", "DOM", "WebWorker", "DOM.Iterable"], + "jsx": "react-jsx", + "jsxImportSource": "@qwik.dev/core", + "strict": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "Bundler", + "esModuleInterop": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "incremental": true, + "isolatedModules": true, + "outDir": "tmp", + "noEmit": true, + "types": ["node", "vite/client"], + "rootDir": ".", + "paths": { + "~/*": ["./src/*"] + } + } +} diff --git a/e2e/qwik-e2e/tests/server-only-modules.e2e.ts b/e2e/qwik-e2e/tests/server-only-modules.e2e.ts new file mode 100644 index 00000000000..a8a67a30a4e --- /dev/null +++ b/e2e/qwik-e2e/tests/server-only-modules.e2e.ts @@ -0,0 +1,266 @@ +import { expect, test } from '@playwright/test'; +import { qwikVite } from '@qwik.dev/core/optimizer'; +import { qwikRouter } from '@qwik.dev/router/vite'; +import { readFile, readdir, rm } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build, createServer, type InlineConfig, type ViteDevServer } from 'vite'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +const repoRoot = resolve(__dirname, '../../..'); +const allowedAppDir = resolve(repoRoot, 'e2e/qwik-e2e/apps/server-only-modules'); +const rejectedAppDir = resolve(repoRoot, 'e2e/qwik-e2e/apps/server-only-modules-rejected'); + +test.describe('server-only modules', () => { + test.describe.configure({ mode: 'serial' }); + + test.skip(({ browserName }) => browserName !== 'chromium', 'Runs once in Chromium e2e.'); + + test('allows .server imports used only by routeLoader$', async () => { + try { + await cleanBuildOutput(allowedAppDir); + await buildFixtureApp(allowedAppDir); + const output = await readAllJs(join(allowedAppDir, 'dist')); + expect(output).not.toContain('SERVER_ONLY_SECRET'); + expect(output).not.toContain('SERVER_FOLDER_SECRET'); + } finally { + await cleanBuildOutput(allowedAppDir); + } + }); + + test('rejects transitive .server imports used by client code', async () => { + try { + await cleanBuildOutput(rejectedAppDir); + await expect(buildFixtureApp(rejectedAppDir)).rejects.toThrow( + /Server-only module cannot be imported by client code/ + ); + } finally { + await cleanBuildOutput(rejectedAppDir); + } + }); + + test('rejects transitive src/server folder imports used by client code', async () => { + try { + await cleanBuildOutput(rejectedAppDir); + await expect(buildFixtureApp(rejectedAppDir, './src/server-folder-root.tsx')).rejects.toThrow( + /Server-only module cannot be imported by client code/ + ); + } finally { + await cleanBuildOutput(rejectedAppDir); + } + }); + + test('rejects re-exported .server imports used by client code', async () => { + try { + await cleanBuildOutput(rejectedAppDir); + await expect(buildFixtureApp(rejectedAppDir, './src/re-export-root.tsx')).rejects.toThrow( + /Server-only module cannot be imported by client code/ + ); + } finally { + await cleanBuildOutput(rejectedAppDir); + } + }); + + test('rejects dynamic .server imports used by client code', async () => { + try { + await cleanBuildOutput(rejectedAppDir); + await expect( + buildFixtureApp(rejectedAppDir, './src/dynamic-import-root.tsx') + ).rejects.toThrow(/Server-only module cannot be imported by client code/); + } finally { + await cleanBuildOutput(rejectedAppDir); + } + }); + + test('dev allows server-only imports used only by routeLoader$', async () => { + await withDevServer(allowedAppDir, async (server) => { + await expect(server.ssrLoadModule('/src/routes/index.tsx')).resolves.toBeTruthy(); + }); + }); + + test('dev ssr mode allows server-only imports used only by routeLoader$', async () => { + await withDevServer( + allowedAppDir, + async (server) => { + await expect(server.ssrLoadModule('/src/routes/index.tsx')).resolves.toBeTruthy(); + }, + 'ssr' + ); + }); + + test('dev rejects route components that use .server modules during ssr load', async () => { + await withDevServer(rejectedAppDir, async (server) => { + let message = ''; + try { + await server.ssrLoadModule('/src/routes/index.tsx'); + } catch (error) { + message = String((error as Error).message); + } + expect(message).toContain('Server-only module cannot be imported by client code'); + expect(message).toMatch(/Importer: .*src[\\/]routes[\\/]index\.tsx/); + expect(message).not.toContain('_routes_component_'); + }); + }); + + test('dev rejects page requests when route components use .server modules', async () => { + await withServedDevServer(rejectedAppDir, async (server) => { + const baseUrl = server.resolvedUrls?.local[0]; + expect(baseUrl).toBeTruthy(); + const response = await fetch(baseUrl!); + expect(response.status).toBe(500); + expect(await response.text()).toContain( + 'Server-only module cannot be imported by client code' + ); + }); + }); + + test('dev ssr mode rejects page requests when route components use .server modules', async () => { + await withServedDevServer( + rejectedAppDir, + async (server) => { + const baseUrl = server.resolvedUrls?.local[0]; + expect(baseUrl).toBeTruthy(); + const response = await fetch(baseUrl!); + expect(response.status).toBe(500); + expect(await response.text()).toContain( + 'Server-only module cannot be imported by client code' + ); + }, + 'ssr' + ); + }); + + test('dev allows route modules after client transform when server imports stay in loaders', async () => { + await withServedDevServer(allowedAppDir, async (server) => { + const baseUrl = server.resolvedUrls?.local[0]; + expect(baseUrl).toBeTruthy(); + const response = await fetch(new URL('/src/routes/index.tsx', baseUrl)); + expect(response.status).toBe(200); + expect(await response.text()).not.toContain( + 'Server-only module cannot be imported by client code' + ); + }); + }); + + test('dev rejects .server modules requested by client code', async () => { + await withServedDevServer(rejectedAppDir, async (server) => { + const baseUrl = server.resolvedUrls?.local[0]; + expect(baseUrl).toBeTruthy(); + for (const path of ['/src/db.server.ts', '/src/client-helper.ts']) { + const response = await fetch(new URL(path, baseUrl)); + expect(response.status).toBe(500); + expect(await response.text()).toContain( + 'Server-only module cannot be imported by client code' + ); + } + }); + }); + + test('dev rejects route components that use .server modules', async () => { + await withServedDevServer(rejectedAppDir, async (server) => { + const baseUrl = server.resolvedUrls?.local[0]; + expect(baseUrl).toBeTruthy(); + const response = await fetch(new URL('/src/routes/index.tsx', baseUrl)); + expect(response.status).toBe(500); + expect(await response.text()).toContain( + 'Server-only module cannot be imported by client code' + ); + }); + }); + + test('dev rejects src/server modules imported by client code', async () => { + await withServedDevServer(rejectedAppDir, async (server) => { + const baseUrl = server.resolvedUrls?.local[0]; + expect(baseUrl).toBeTruthy(); + for (const path of ['/src/folder-client-helper.ts', '/src/server/folder-secret.ts']) { + const response = await fetch(new URL(path, baseUrl)); + expect(response.status).toBe(500); + expect(await response.text()).toContain( + 'Server-only module cannot be imported by client code' + ); + } + }); + }); +}); + +async function buildFixtureApp(appDir: string, input = './src/root.tsx') { + const config: InlineConfig = { + root: appDir, + mode: 'production', + configFile: false, + clearScreen: false, + plugins: [qwikRouter(), qwikVite()], + build: { + minify: false, + rollupOptions: input ? { input: resolve(appDir, input) } : undefined, + }, + }; + + await build(config); +} + +async function withDevServer( + appDir: string, + callback: (server: ViteDevServer) => T | Promise, + mode = 'development' +): Promise { + const server = await createServer({ + root: appDir, + mode, + configFile: false, + clearScreen: false, + appType: 'custom', + server: { + middlewareMode: true, + }, + plugins: [qwikRouter(), qwikVite()], + }); + + try { + return await callback(server); + } finally { + await server.close(); + } +} + +async function withServedDevServer( + appDir: string, + callback: (server: ViteDevServer) => T | Promise, + mode = 'development' +): Promise { + const server = await createServer({ + root: appDir, + mode, + configFile: false, + clearScreen: false, + plugins: [qwikRouter(), qwikVite()], + server: { + port: 0, + }, + }); + + try { + await server.listen(); + return await callback(server); + } finally { + await server.close(); + } +} + +async function cleanBuildOutput(appDir: string) { + await rm(join(appDir, 'dist'), { recursive: true, force: true }); +} + +async function readAllJs(dir: string): Promise { + let output = ''; + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + output += await readAllJs(path); + } else if (entry.isFile() && entry.name.endsWith('.js')) { + output += await readFile(path, 'utf-8'); + } + } + return output; +} diff --git a/packages/docs/src/routes/docs/(qwikrouter)/server$/index.mdx b/packages/docs/src/routes/docs/(qwikrouter)/server$/index.mdx index 0a967c9617b..cc328d25339 100644 --- a/packages/docs/src/routes/docs/(qwikrouter)/server$/index.mdx +++ b/packages/docs/src/routes/docs/(qwikrouter)/server$/index.mdx @@ -26,6 +26,31 @@ import { Note, LongNote } from '~/components/note/note'; `server$` can accept any number of arguments and return any value that can be serialized by Qwik. This includes primitives, objects, arrays, bigint, JSX nodes, and even Promises, just to name a few. +## Server-only modules + +Qwik treats files named `.server.*`, such as `db.server.ts`, and files inside +`src/**/server/**` folders as server-only modules. These modules can be imported by SSR code, +route loaders, route actions, endpoint handlers, and `server$` implementations, but the client +build will fail if client code imports them. + +```ts title="src/db.server.ts" +export const loadSecret = async () => { + return process.env.API_KEY; +}; +``` + +```tsx title="src/routes/index.tsx" +import { routeLoader$ } from '@qwik.dev/router'; +import { loadSecret } from '../db.server'; + +export const useSecret = routeLoader$(async () => { + return loadSecret(); +}); +``` + +Do not import server-only modules from browser code such as components, event handlers, or visible +tasks. If client code needs to trigger server work, expose that work through `server$`, a route +action, or an endpoint. `AbortSignal` is optional, and allows you to cancel a long running request by terminating the connection. Your new function will have the following signature: @@ -251,6 +276,3 @@ When using `server$`, it's important to understand how [middleware functions](/d To ensure that a middleware function runs for both types of requests, it should be defined in the `plugin.ts` file. This ensures that the middleware is executed consistently for all incoming requests, regardless of whether they are normal page requests or `server$` requests. By [defining middleware in the `plugin.ts`](/docs/advanced/plugins) file, developers can maintain a centralized location for shared middleware logic, ensuring consistency and reducing potential errors or oversights. - - - diff --git a/packages/qwik-vite/src/plugins/plugin.ts b/packages/qwik-vite/src/plugins/plugin.ts index 6846dddb3f1..fd13236ced0 100644 --- a/packages/qwik-vite/src/plugins/plugin.ts +++ b/packages/qwik-vite/src/plugins/plugin.ts @@ -19,6 +19,11 @@ import type { } from '../types'; import { convertManifestToBundleGraph, type BundleGraphAdder } from './bundle-graph'; import { createLinter, type QwikLinter } from './eslint-plugin'; +import { + createServerOnlyImportError, + isServerOnlyModule, + mightContainServerOnlyImport, +} from './server-only-modules'; import { isVirtualId, isWin, parseId } from './vite-utils'; import MagicString from 'magic-string'; @@ -55,6 +60,11 @@ const CLIENT_STRIP_CTX_NAME = [ 'event$', ]; +type ViteResolveIdOptions = NonNullable>[2]>; +type QwikResolveIdOptions = Partial & { + scan?: boolean; +}; + /** * Use `__EXPERIMENTAL__.x` to check if feature `x` is enabled. It will be replaced with `true` or * `false` via an exact string replacement. @@ -460,6 +470,110 @@ export function createQwikPlugin(optimizerOptions: OptimizerOptions = {}) { : opts.target === 'ssr' || opts.target === 'test'; }; + const shouldAssertClientImports = (isServer: boolean) => { + return !isServer && (opts.target === 'client' || (!!devServer && opts.target === 'ssr')); + }; + + const shouldValidateDevSsrClientOutput = (isServer: boolean) => { + return !!devServer && isServer && (opts.target === 'client' || opts.target === 'ssr'); + }; + + const assertClientCanImport = (pathId: string, importerId?: string | null, isServer = false) => { + if (shouldAssertClientImports(isServer) && isServerOnlyModule(pathId, opts)) { + throw new Error(createServerOnlyImportError(pathId, importerId)); + } + }; + + const isServerOnlyImportCandidate = (importId: string) => { + const normalizedImportId = importId.replace(/\\/g, '/'); + return normalizedImportId.includes('.server') || /(^|\/)server(\/|$)/.test(normalizedImportId); + }; + + const getImportSpecifiers = (ctx: Rollup.PluginContext, code: string): string[] => { + const imports = new Set(); + + const addSource = (source: any) => { + if (typeof source?.value === 'string') { + imports.add(source.value); + } + }; + + const stack = [ctx.parse(code) as any]; + while (stack.length > 0) { + const node = stack.pop(); + if (!node || typeof node !== 'object' || typeof node.type !== 'string') { + continue; + } + + if (node.type === 'ImportDeclaration' && node.importKind !== 'type') { + addSource(node.source); + } else if ( + (node.type === 'ExportNamedDeclaration' || node.type === 'ExportAllDeclaration') && + node.exportKind !== 'type' + ) { + addSource(node.source); + } else if (node.type === 'ImportExpression') { + addSource(node.source); + } else if (node.type === 'CallExpression' && node.callee?.type === 'Import') { + addSource(node.arguments?.[0]); + } + + for (const key of Object.keys(node)) { + if (key === 'parent') { + continue; + } + const value = node[key]; + if (Array.isArray(value)) { + for (const child of value) { + stack.push(child); + } + } else { + stack.push(value); + } + } + } + + return Array.from(imports); + }; + + const assertClientTransformCanImport = async ( + ctx: Rollup.PluginContext, + code: string, + resolveImporterId: string, + importerId = resolveImporterId + ) => { + if (!mightContainServerOnlyImport(code)) { + return; + } + for (const importId of getImportSpecifiers(ctx, code)) { + if (!isServerOnlyImportCandidate(importId)) { + continue; + } + assertClientCanImport(importId, importerId); + const resolved = await ctx.resolve(importId, resolveImporterId, { skipSelf: true }); + if (resolved) { + assertClientCanImport(normalizePath(parseId(resolved.id).pathId), importerId); + } + } + }; + + const assertClientTransformOutputCanImport = async ( + ctx: Rollup.PluginContext, + output: TransformOutput, + srcDir: string, + importerId?: string, + additionalOnly = false + ) => { + const path = getPath(); + for (const mod of output.modules) { + if (additionalOnly && !isAdditionalFile(mod)) { + continue; + } + const outputPath = normalizePath(path.join(srcDir, mod.path)); + await assertClientTransformCanImport(ctx, mod.code, outputPath, importerId ?? outputPath); + } + }; + let resolveIdCount = 0; let doNotEdit = false; /** @@ -475,7 +589,7 @@ export function createQwikPlugin(optimizerOptions: OptimizerOptions = {}) { ctx: Rollup.PluginContext, id: string, importerId: string | undefined, - resolveOpts?: Parameters>[2] + resolveOpts?: QwikResolveIdOptions ) => { if (isVirtualId(id)) { return; @@ -533,6 +647,9 @@ export function createQwikPlugin(optimizerOptions: OptimizerOptions = {}) { let result: Rollup.ResolveIdResult; /** At this point, the request has been normalized. */ + if (!(devServer && resolveOpts?.scan)) { + assertClientCanImport(pathId, importerId, isServer); + } if ( /** @@ -671,12 +788,19 @@ export function createQwikPlugin(optimizerOptions: OptimizerOptions = {}) { // This doesn't get used, but we need to return something return '"opening in editor"'; } - if (isVirtualId(id) || id.startsWith('/@fs/')) { + if (isVirtualId(id)) { return; } const count = loadCount++; const isServer = getIsServer(ctx, loadOpts); + const parsedId = parseId(id); + const pathId = normalizePath(parsedId.pathId); + assertClientCanImport(pathId, undefined, isServer); + if (id.startsWith('/@fs/')) { + return; + } + // Virtual modules if (opts.resolveQwikBuild && id === QWIK_BUILD_ID) { debug(`load(${count})`, QWIK_BUILD_ID, opts.buildMode); @@ -706,8 +830,7 @@ export function createQwikPlugin(optimizerOptions: OptimizerOptions = {}) { } // QRL segments - const parsedId = parseId(id); - id = normalizePath(parsedId.pathId); + id = pathId; const outputs = isServer ? serverTransformedOutputs : clientTransformedOutputs; if (devServer && !outputs.has(id)) { // in dev mode, it could be that the id is a QRL segment that wasn't transformed yet @@ -784,6 +907,8 @@ export function createQwikPlugin(optimizerOptions: OptimizerOptions = {}) { const path = getPath(); const { pathId } = parseId(id); + const normalizedPathId = normalizePath(pathId); + assertClientCanImport(normalizedPathId, undefined, isServer); const parsedPathId = path.parse(pathId); const dir = parsedPathId.dir; const base = parsedPathId.base; @@ -877,6 +1002,29 @@ export function createQwikPlugin(optimizerOptions: OptimizerOptions = {}) { debug(`transform(${count})`, `done in ${Date.now() - now}ms`); const module = newOutput.modules.find((mod) => !isAdditionalFile(mod))!; + if (shouldAssertClientImports(isServer)) { + await assertClientTransformOutputCanImport(ctx, newOutput, srcDir, normalizedPathId); + } else if (shouldValidateDevSsrClientOutput(isServer) && mightContainServerOnlyImport(code)) { + const clientTransformOpts: TransformModulesOptions = { + ...transformOpts, + entryStrategy, + isServer: false, + }; + if (strip) { + clientTransformOpts.stripCtxName = SERVER_STRIP_CTX_NAME; + clientTransformOpts.stripExports = SERVER_STRIP_EXPORTS; + clientTransformOpts.stripEventHandlers = undefined; + clientTransformOpts.regCtxName = undefined; + } + await assertClientTransformOutputCanImport( + ctx, + await optimizer.transformModules(clientTransformOpts), + srcDir, + normalizedPathId, + true + ); + } + // uncomment to show transform results // debug({ isServer, strip }, transformOpts, newOutput); diagnosticsCallback(newOutput.diagnostics, optimizer, srcDir); diff --git a/packages/qwik-vite/src/plugins/plugin.unit.ts b/packages/qwik-vite/src/plugins/plugin.unit.ts index 5e4d65bca78..c48bd87f52e 100644 --- a/packages/qwik-vite/src/plugins/plugin.unit.ts +++ b/packages/qwik-vite/src/plugins/plugin.unit.ts @@ -3,6 +3,7 @@ import { assert, describe, expect, test } from 'vitest'; import { normalizePath } from '../../../qwik/src/testing/util'; import type { QwikManifest } from '../types'; import { ExperimentalFeatures, createQwikPlugin } from './plugin'; +import { isServerOnlyModule } from './server-only-modules'; import { qwikVite } from './vite'; import type { ResolvedId } from 'rollup'; @@ -240,6 +241,39 @@ test('experimental[]', async () => { assert.deepEqual(opts.experimental, { [flag]: true } as any); }); +describe('server-only module detection', () => { + test('detects absolute and root-relative src/server module paths', () => { + const opts = { + rootDir: '/repo/app', + srcDir: '/repo/app/src', + }; + + expect(isServerOnlyModule('/repo/app/src/server/db.ts', opts)).toBe(true); + expect(isServerOnlyModule('/src/server/db.ts', opts)).toBe(true); + expect(isServerOnlyModule('/repo/app/src/routes/admin/server/session.ts', opts)).toBe(true); + expect(isServerOnlyModule('/src/server-functions.ts', opts)).toBe(false); + expect(isServerOnlyModule('@qwik.dev/core/server', opts)).toBe(false); + }); + + test('does not rewrite unrelated posix absolute paths as root-relative dev urls', () => { + expect( + isServerOnlyModule('/other/app/src/server/db.ts', { + rootDir: '/repo/app', + srcDir: '/repo/app/src', + }) + ).toBe(false); + }); + + test('detects windows src/server module paths', () => { + expect( + isServerOnlyModule('C:\\repo\\app\\src\\server\\db.ts', { + rootDir: 'C:\\repo\\app', + srcDir: 'C:\\repo\\app\\src', + }) + ).toBe(true); + }); +}); + describe('resolveId', () => { test('qrls', async () => { const plugin = await mockPlugin(); @@ -313,6 +347,160 @@ describe('resolveId', () => { await plugin.resolveId({} as any, '@qwik-client-manifest', '/foo/bar/core') ).toHaveProperty('id', '@qwik-client-manifest'); }); + test('rejects server-only modules from the client graph', async () => { + const plugin = await mockPlugin(); + await plugin.normalizeOptions({ + target: 'client', + rootDir: cwd, + srcDir: resolve(cwd, 'src'), + }); + const srcDir = normalizePath(resolve(cwd, 'src')); + const importer = `${srcDir}/entry.client.tsx`; + + await expect(plugin.resolveId({} as any, `${srcDir}/db.server.ts`, importer)).rejects.toThrow( + /Server-only module cannot be imported by client code/ + ); + await expect( + plugin.resolveId({} as any, `${srcDir}/db.server.ts?raw`, importer) + ).rejects.toThrow(/Server-only module cannot be imported by client code/); + await expect(plugin.resolveId({} as any, `${srcDir}/server/db.ts`, importer)).rejects.toThrow( + /Server-only module cannot be imported by client code/ + ); + await expect(plugin.resolveId({} as any, `/src/server/db.ts`, importer)).rejects.toThrow( + /Server-only module cannot be imported by client code/ + ); + await expect( + plugin.resolveId({} as any, `${srcDir}/routes/admin/server/session.ts`, importer) + ).rejects.toThrow(/Server-only module cannot be imported by client code/); + }); + test('allows non-server-only paths that contain server in the filename', async () => { + const plugin = await mockPlugin(); + await plugin.normalizeOptions({ + target: 'client', + rootDir: cwd, + srcDir: resolve(cwd, 'src'), + }); + const srcDir = normalizePath(resolve(cwd, 'src')); + + await expect( + plugin.resolveId({} as any, `${srcDir}/server-functions.ts`, `${srcDir}/entry.client.tsx`) + ).resolves.toBeFalsy(); + await expect( + plugin.resolveId({} as any, '@qwik.dev/core/server', `${srcDir}/entry.client.tsx`) + ).resolves.toBeFalsy(); + }); + test('allows server-only modules during vite dev dependency scanning', async () => { + const plugin = await mockPlugin(); + await plugin.normalizeOptions({ + target: 'client', + rootDir: cwd, + srcDir: resolve(cwd, 'src'), + }); + plugin.configureServer({ moduleGraph: { getModuleById: () => undefined } } as any); + const srcDir = normalizePath(resolve(cwd, 'src')); + + await expect( + plugin.resolveId({} as any, `${srcDir}/db.server.ts`, `${srcDir}/routes/index.tsx`, { + scan: true, + }) + ).resolves.toBeFalsy(); + }); + test('rejects server-only modules during vite dev resolution outside dependency scanning', async () => { + const plugin = await mockPlugin(); + await plugin.normalizeOptions({ + target: 'client', + rootDir: cwd, + srcDir: resolve(cwd, 'src'), + }); + plugin.configureServer({} as any); + const srcDir = normalizePath(resolve(cwd, 'src')); + + await expect( + plugin.resolveId({} as any, `${srcDir}/db.server.ts`, `${srcDir}/routes/index.tsx`) + ).rejects.toThrow(/Server-only module cannot be imported by client code/); + }); + test('rejects server-only modules during non-dev dependency scanning', async () => { + const plugin = await mockPlugin(); + await plugin.normalizeOptions({ + target: 'client', + rootDir: cwd, + srcDir: resolve(cwd, 'src'), + }); + const srcDir = normalizePath(resolve(cwd, 'src')); + + await expect( + plugin.resolveId({} as any, `${srcDir}/db.server.ts`, `${srcDir}/routes/index.tsx`, { + scan: true, + }) + ).rejects.toThrow(/Server-only module cannot be imported by client code/); + }); + test('allows server-only modules from the ssr graph', async () => { + const plugin = await mockPlugin(); + await plugin.normalizeOptions({ + target: 'ssr', + rootDir: cwd, + srcDir: resolve(cwd, 'src'), + }); + const srcDir = normalizePath(resolve(cwd, 'src')); + + await expect( + plugin.resolveId({} as any, `${srcDir}/db.server.ts`, `${srcDir}/entry.ssr.tsx`) + ).resolves.toBeFalsy(); + await expect(plugin.load({} as any, `${srcDir}/server/db.ts`)).resolves.toBeFalsy(); + await expect( + plugin.transform({} as any, 'export const value = 1;', `${srcDir}/db.server.ts_symbol.js`) + ).resolves.toBeFalsy(); + }); + test('allows server-only modules from vite server environments', async () => { + const plugin = await mockPlugin(); + await plugin.normalizeOptions({ + target: 'client', + rootDir: cwd, + srcDir: resolve(cwd, 'src'), + }); + const srcDir = normalizePath(resolve(cwd, 'src')); + const serverCtx = { environment: { config: { consumer: 'server' } } } as any; + + await expect( + plugin.resolveId(serverCtx, `${srcDir}/db.server.ts`, `${srcDir}/entry.ssr.tsx`) + ).resolves.toBeFalsy(); + }); + test('rejects server-only modules from vite client environments during ssr dev mode', async () => { + const plugin = await mockPlugin(); + await plugin.normalizeOptions({ + target: 'ssr', + rootDir: cwd, + srcDir: resolve(cwd, 'src'), + }); + plugin.configureServer({ moduleGraph: { getModuleById: () => undefined } } as any); + const srcDir = normalizePath(resolve(cwd, 'src')); + const clientCtx = { environment: { config: { consumer: 'client' } } } as any; + + await expect( + plugin.resolveId(clientCtx, `${srcDir}/db.server.ts`, `${srcDir}/routes/index.tsx`) + ).rejects.toThrow(/Server-only module cannot be imported by client code/); + }); + test('allows server-only modules during lib builds', async () => { + const plugin = await mockPlugin(); + await plugin.normalizeOptions({ + target: 'lib', + rootDir: cwd, + srcDir: resolve(cwd, 'src'), + }); + const srcDir = normalizePath(resolve(cwd, 'src')); + + await expect( + plugin.resolveId({} as any, `${srcDir}/db.server.ts`, `${srcDir}/entry.tsx`) + ).resolves.toBeFalsy(); + }); + test('rejects windows server-only paths from the client graph', async () => { + const plugin = await mockPlugin('win32'); + await plugin.normalizeOptions({ target: 'client' }); + + await expect( + plugin.resolveId({} as any, 'C:\\project\\src\\db.server.ts', 'C:\\project\\src\\app.tsx') + ).rejects.toThrow('C:/project/src/db.server.ts'); + }); }); async function mockPlugin(os = process.platform) { diff --git a/packages/qwik-vite/src/plugins/server-only-modules.ts b/packages/qwik-vite/src/plugins/server-only-modules.ts new file mode 100644 index 00000000000..11b06867541 --- /dev/null +++ b/packages/qwik-vite/src/plugins/server-only-modules.ts @@ -0,0 +1,81 @@ +export interface ServerOnlyModuleOptions { + rootDir?: string | null; + srcDir?: string | null; +} + +const SERVER_ONLY_FILE_REGEX = /\.server\.[cm]?[jt]sx?$/; +const SERVER_ONLY_QRL_REGEX = /\.server\.[cm]?[jt]sx?_/; +const SERVER_DIR_SEGMENT_REGEX = /(^|\/)server(\/|$)/; + +export const mightContainServerOnlyImport = (code: string): boolean => { + return ( + code.includes('.server') || + code.includes('/server') || + code.includes('\\server') || + code.includes('server/') || + code.includes('server\\') + ); +}; + +const normalizeServerOnlyPath = (pathId: string) => + pathId.replace(/\\/g, '/').replace(/^\/@fs\//, ''); + +const toComparablePath = (pathId: string, opts: ServerOnlyModuleOptions): string => { + const normalizedPath = normalizeServerOnlyPath(pathId); + const normalizedRootDir = opts.rootDir + ? normalizeServerOnlyPath(opts.rootDir).replace(/\/+$/, '') + : ''; + if ( + normalizedRootDir && + (normalizedPath === normalizedRootDir || normalizedPath.startsWith(normalizedRootDir + '/')) + ) { + return normalizedPath; + } + if ( + normalizedRootDir && + normalizedPath.startsWith('/') && + !normalizedPath.startsWith('//') && + !/^[a-zA-Z]:\//.test(normalizedPath) + ) { + return `${normalizedRootDir}${normalizedPath}`; + } + return normalizedPath; +}; + +export const isServerOnlyFile = (pathId: string): boolean => { + const normalizedPath = normalizeServerOnlyPath(pathId); + return SERVER_ONLY_FILE_REGEX.test(normalizedPath) || SERVER_ONLY_QRL_REGEX.test(normalizedPath); +}; + +export const isInSrcServerDir = ( + pathId: string, + srcDirOrOpts?: string | null | ServerOnlyModuleOptions +): boolean => { + const opts = + typeof srcDirOrOpts === 'string' || srcDirOrOpts == null + ? { srcDir: srcDirOrOpts } + : srcDirOrOpts; + if (!opts.srcDir) { + return false; + } + const normalizedPath = toComparablePath(pathId, opts); + const normalizedSrcDir = normalizeServerOnlyPath(opts.srcDir).replace(/\/+$/, ''); + if (!normalizedPath.startsWith(normalizedSrcDir + '/')) { + return false; + } + return SERVER_DIR_SEGMENT_REGEX.test(normalizedPath.slice(normalizedSrcDir.length + 1)); +}; + +export const isServerOnlyModule = (pathId: string, opts: ServerOnlyModuleOptions): boolean => + isServerOnlyFile(pathId) || isInSrcServerDir(pathId, opts); + +export const createServerOnlyImportError = (pathId: string, importerId?: string | null): string => { + const importer = importerId ? `\nImporter: ${importerId}` : ''; + return ( + `Server-only module cannot be imported by client code.\n\n` + + `Server-only module: ${pathId}${importer}\n\n` + + `Files named \`.server.*\` or placed under \`src/**/server/**\` are excluded from ` + + `client bundles. Move this import behind SSR-only route loaders, actions, endpoint handlers, ` + + `or expose the operation through an intentional \`server$\` API.` + ); +};