From c0d7689b67aed49c1a7b8519890a59cec2baebe5 Mon Sep 17 00:00:00 2001 From: Varixo Date: Tue, 28 Apr 2026 21:07:27 +0200 Subject: [PATCH 1/4] feat: server-only folder and suffix --- .changeset/bright-servers-protect.md | 5 + .../docs/(qwikrouter)/server$/index.mdx | 28 +++++- packages/qwik-vite/src/plugins/plugin.ts | 62 ++++++++++++- packages/qwik-vite/src/plugins/plugin.unit.ts | 91 +++++++++++++++++++ 4 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 .changeset/bright-servers-protect.md 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/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..25a51d70573 100644 --- a/packages/qwik-vite/src/plugins/plugin.ts +++ b/packages/qwik-vite/src/plugins/plugin.ts @@ -460,6 +460,12 @@ export function createQwikPlugin(optimizerOptions: OptimizerOptions = {}) { : opts.target === 'ssr' || opts.target === 'test'; }; + const assertClientCanImport = (pathId: string, importerId?: string | null, isServer = false) => { + if (!isServer && opts.target === 'client' && isServerOnlyModule(pathId, opts)) { + throw new Error(createServerOnlyImportError(pathId, importerId)); + } + }; + let resolveIdCount = 0; let doNotEdit = false; /** @@ -533,6 +539,7 @@ export function createQwikPlugin(optimizerOptions: OptimizerOptions = {}) { let result: Rollup.ResolveIdResult; /** At this point, the request has been normalized. */ + assertClientCanImport(pathId, importerId, isServer); if ( /** @@ -671,12 +678,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 +720,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 +797,7 @@ export function createQwikPlugin(optimizerOptions: OptimizerOptions = {}) { const path = getPath(); const { pathId } = parseId(id); + assertClientCanImport(normalizePath(pathId), undefined, isServer); const parsedPathId = path.parse(pathId); const dir = parsedPathId.dir; const base = parsedPathId.base; @@ -1312,6 +1326,48 @@ const LIB_OUT_DIR = 'lib'; export const Q_MANIFEST_FILENAME = 'q-manifest.json'; +const SERVER_ONLY_FILE_REGEX = /\.server\.[cm]?[jt]sx?$/; +const SERVER_ONLY_QRL_REGEX = /\.server\.[cm]?[jt]sx?_/; + +const normalizeServerOnlyPath = (pathId: string) => + pathId.replace(/\\/g, '/').replace(/^\/@fs\//, ''); + +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, srcDir?: string): boolean => { + if (!srcDir) { + return false; + } + const normalizedPath = normalizeServerOnlyPath(pathId); + const normalizedSrcDir = normalizeServerOnlyPath(srcDir).replace(/\/+$/, ''); + if (!normalizedPath.startsWith(normalizedSrcDir + '/')) { + return false; + } + return normalizedPath + .slice(normalizedSrcDir.length + 1) + .split('/') + .includes('server'); +}; + +export const isServerOnlyModule = ( + pathId: string, + opts: Pick +): boolean => isServerOnlyFile(pathId) || isInSrcServerDir(pathId, opts.srcDir); + +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.` + ); +}; + /** @public */ export interface QwikPluginDevTools { /** diff --git a/packages/qwik-vite/src/plugins/plugin.unit.ts b/packages/qwik-vite/src/plugins/plugin.unit.ts index 5e4d65bca78..6a9e030ef07 100644 --- a/packages/qwik-vite/src/plugins/plugin.unit.ts +++ b/packages/qwik-vite/src/plugins/plugin.unit.ts @@ -313,6 +313,97 @@ 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, `${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 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.toBeNull(); + await expect( + plugin.transform({} as any, 'export const value = 1;', `${srcDir}/db.server.ts_symbol.js`) + ).resolves.toBeNull(); + }); + 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('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) { From 03a4c203deb37e130443df292a94c7669467c708 Mon Sep 17 00:00:00 2001 From: Varixo Date: Tue, 28 Apr 2026 21:12:41 +0200 Subject: [PATCH 2/4] test: add server-only e2e tests --- .../server-only-modules-rejected/package.json | 7 ++ .../src/client-helper.ts | 3 + .../src/db.server.ts | 1 + .../src/dynamic-import-root.tsx | 12 ++ .../src/entry.ssr.tsx | 9 ++ .../src/folder-client-helper.ts | 3 + .../src/re-export-client-helper.ts | 3 + .../src/re-export-root.tsx | 6 + .../src/re-exported-secret.ts | 1 + .../server-only-modules-rejected/src/root.tsx | 6 + .../src/routes/index.tsx | 1 + .../src/server-folder-root.tsx | 6 + .../src/server/folder-secret.ts | 1 + .../tsconfig.json | 26 +++++ .../apps/server-only-modules/package.json | 7 ++ .../apps/server-only-modules/src/db.server.ts | 1 + .../server-only-modules/src/entry.ssr.tsx | 9 ++ .../apps/server-only-modules/src/root.tsx | 12 ++ .../server-only-modules/src/routes/index.tsx | 13 +++ .../src/server/folder-secret.ts | 1 + .../apps/server-only-modules/tsconfig.json | 26 +++++ e2e/qwik-e2e/tests/server-only-modules.e2e.ts | 108 ++++++++++++++++++ packages/qwik-vite/src/plugins/plugin.ts | 2 +- 23 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 e2e/qwik-e2e/apps/server-only-modules-rejected/package.json create mode 100644 e2e/qwik-e2e/apps/server-only-modules-rejected/src/client-helper.ts create mode 100644 e2e/qwik-e2e/apps/server-only-modules-rejected/src/db.server.ts create mode 100644 e2e/qwik-e2e/apps/server-only-modules-rejected/src/dynamic-import-root.tsx create mode 100644 e2e/qwik-e2e/apps/server-only-modules-rejected/src/entry.ssr.tsx create mode 100644 e2e/qwik-e2e/apps/server-only-modules-rejected/src/folder-client-helper.ts create mode 100644 e2e/qwik-e2e/apps/server-only-modules-rejected/src/re-export-client-helper.ts create mode 100644 e2e/qwik-e2e/apps/server-only-modules-rejected/src/re-export-root.tsx create mode 100644 e2e/qwik-e2e/apps/server-only-modules-rejected/src/re-exported-secret.ts create mode 100644 e2e/qwik-e2e/apps/server-only-modules-rejected/src/root.tsx create mode 100644 e2e/qwik-e2e/apps/server-only-modules-rejected/src/routes/index.tsx create mode 100644 e2e/qwik-e2e/apps/server-only-modules-rejected/src/server-folder-root.tsx create mode 100644 e2e/qwik-e2e/apps/server-only-modules-rejected/src/server/folder-secret.ts create mode 100644 e2e/qwik-e2e/apps/server-only-modules-rejected/tsconfig.json create mode 100644 e2e/qwik-e2e/apps/server-only-modules/package.json create mode 100644 e2e/qwik-e2e/apps/server-only-modules/src/db.server.ts create mode 100644 e2e/qwik-e2e/apps/server-only-modules/src/entry.ssr.tsx create mode 100644 e2e/qwik-e2e/apps/server-only-modules/src/root.tsx create mode 100644 e2e/qwik-e2e/apps/server-only-modules/src/routes/index.tsx create mode 100644 e2e/qwik-e2e/apps/server-only-modules/src/server/folder-secret.ts create mode 100644 e2e/qwik-e2e/apps/server-only-modules/tsconfig.json create mode 100644 e2e/qwik-e2e/tests/server-only-modules.e2e.ts 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..461f67a0a4b --- /dev/null +++ b/e2e/qwik-e2e/apps/server-only-modules-rejected/src/routes/index.tsx @@ -0,0 +1 @@ +export default () => null; 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..f83b99479fc --- /dev/null +++ b/e2e/qwik-e2e/apps/server-only-modules/src/routes/index.tsx @@ -0,0 +1,13 @@ +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(); + return
{secret.value}
; +}); 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..a29602d117a --- /dev/null +++ b/e2e/qwik-e2e/tests/server-only-modules.e2e.ts @@ -0,0 +1,108 @@ +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, type InlineConfig } 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); + } + }); +}); + +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 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/qwik-vite/src/plugins/plugin.ts b/packages/qwik-vite/src/plugins/plugin.ts index 25a51d70573..174d76e2449 100644 --- a/packages/qwik-vite/src/plugins/plugin.ts +++ b/packages/qwik-vite/src/plugins/plugin.ts @@ -1337,7 +1337,7 @@ export const isServerOnlyFile = (pathId: string): boolean => { return SERVER_ONLY_FILE_REGEX.test(normalizedPath) || SERVER_ONLY_QRL_REGEX.test(normalizedPath); }; -export const isInSrcServerDir = (pathId: string, srcDir?: string): boolean => { +export const isInSrcServerDir = (pathId: string, srcDir?: string | null): boolean => { if (!srcDir) { return false; } From 9578c82fc78be4ee0de0be037165f91800656834 Mon Sep 17 00:00:00 2001 From: Varixo Date: Sat, 2 May 2026 11:49:16 +0200 Subject: [PATCH 3/4] feat: server only modules for dev mode --- .../src/routes/index.tsx | 19 +- .../server-only-modules/src/routes/index.tsx | 8 +- e2e/qwik-e2e/tests/server-only-modules.e2e.ts | 154 ++++++++++++++- packages/qwik-vite/src/plugins/plugin.ts | 181 +++++++++++++----- packages/qwik-vite/src/plugins/plugin.unit.ts | 101 +++++++++- .../src/plugins/server-only-modules.ts | 81 ++++++++ 6 files changed, 493 insertions(+), 51 deletions(-) create mode 100644 packages/qwik-vite/src/plugins/server-only-modules.ts 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 index 461f67a0a4b..7e5d6dcf857 100644 --- 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 @@ -1 +1,18 @@ -export default () => null; +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/src/routes/index.tsx b/e2e/qwik-e2e/apps/server-only-modules/src/routes/index.tsx index f83b99479fc..018e61a9c8e 100644 --- a/e2e/qwik-e2e/apps/server-only-modules/src/routes/index.tsx +++ b/e2e/qwik-e2e/apps/server-only-modules/src/routes/index.tsx @@ -9,5 +9,11 @@ export const useSecret = routeLoader$(() => { export default component$(() => { const secret = useSecret(); - return
{secret.value}
; + const exampleText = "import './fake.server'"; + return ( +
+ {secret.value} + {exampleText} +
+ ); }); diff --git a/e2e/qwik-e2e/tests/server-only-modules.e2e.ts b/e2e/qwik-e2e/tests/server-only-modules.e2e.ts index a29602d117a..4e489ea796d 100644 --- a/e2e/qwik-e2e/tests/server-only-modules.e2e.ts +++ b/e2e/qwik-e2e/tests/server-only-modules.e2e.ts @@ -4,7 +4,7 @@ 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, type InlineConfig } from 'vite'; +import { build, createServer, type InlineConfig, type ViteDevServer } from 'vite'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); const repoRoot = resolve(__dirname, '../../..'); @@ -71,6 +71,110 @@ test.describe('server-only modules', () => { 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) => { + await expect(server.ssrLoadModule('/src/routes/index.tsx')).rejects.toThrow( + /Server-only module cannot be imported by client code/ + ); + }); + }); + + 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') { @@ -89,6 +193,54 @@ async function buildFixtureApp(appDir: string, input = './src/root.tsx') { 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 }); } diff --git a/packages/qwik-vite/src/plugins/plugin.ts b/packages/qwik-vite/src/plugins/plugin.ts index 174d76e2449..171e2b8f61d 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,12 +470,108 @@ 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 (!isServer && opts.target === 'client' && isServerOnlyModule(pathId, opts)) { + 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, + importerId: string + ) => { + if (!mightContainServerOnlyImport(code)) { + return; + } + for (const importId of getImportSpecifiers(ctx, code)) { + if (!isServerOnlyImportCandidate(importId)) { + continue; + } + assertClientCanImport(importId, importerId); + const resolved = await ctx.resolve(importId, importerId, { skipSelf: true }); + if (resolved) { + assertClientCanImport(normalizePath(parseId(resolved.id).pathId), importerId); + } + } + }; + + const assertClientTransformOutputCanImport = async ( + ctx: Rollup.PluginContext, + output: TransformOutput, + srcDir: 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); + } + }; + let resolveIdCount = 0; let doNotEdit = false; /** @@ -481,7 +587,7 @@ export function createQwikPlugin(optimizerOptions: OptimizerOptions = {}) { ctx: Rollup.PluginContext, id: string, importerId: string | undefined, - resolveOpts?: Parameters>[2] + resolveOpts?: QwikResolveIdOptions ) => { if (isVirtualId(id)) { return; @@ -539,7 +645,9 @@ export function createQwikPlugin(optimizerOptions: OptimizerOptions = {}) { let result: Rollup.ResolveIdResult; /** At this point, the request has been normalized. */ - assertClientCanImport(pathId, importerId, isServer); + if (!(devServer && resolveOpts?.scan)) { + assertClientCanImport(pathId, importerId, isServer); + } if ( /** @@ -797,7 +905,8 @@ export function createQwikPlugin(optimizerOptions: OptimizerOptions = {}) { const path = getPath(); const { pathId } = parseId(id); - assertClientCanImport(normalizePath(pathId), undefined, isServer); + const normalizedPathId = normalizePath(pathId); + assertClientCanImport(normalizedPathId, undefined, isServer); const parsedPathId = path.parse(pathId); const dir = parsedPathId.dir; const base = parsedPathId.base; @@ -891,6 +1000,28 @@ 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); + } 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, + true + ); + } + // uncomment to show transform results // debug({ isServer, strip }, transformOpts, newOutput); diagnosticsCallback(newOutput.diagnostics, optimizer, srcDir); @@ -1326,48 +1457,6 @@ const LIB_OUT_DIR = 'lib'; export const Q_MANIFEST_FILENAME = 'q-manifest.json'; -const SERVER_ONLY_FILE_REGEX = /\.server\.[cm]?[jt]sx?$/; -const SERVER_ONLY_QRL_REGEX = /\.server\.[cm]?[jt]sx?_/; - -const normalizeServerOnlyPath = (pathId: string) => - pathId.replace(/\\/g, '/').replace(/^\/@fs\//, ''); - -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, srcDir?: string | null): boolean => { - if (!srcDir) { - return false; - } - const normalizedPath = normalizeServerOnlyPath(pathId); - const normalizedSrcDir = normalizeServerOnlyPath(srcDir).replace(/\/+$/, ''); - if (!normalizedPath.startsWith(normalizedSrcDir + '/')) { - return false; - } - return normalizedPath - .slice(normalizedSrcDir.length + 1) - .split('/') - .includes('server'); -}; - -export const isServerOnlyModule = ( - pathId: string, - opts: Pick -): boolean => isServerOnlyFile(pathId) || isInSrcServerDir(pathId, opts.srcDir); - -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.` - ); -}; - /** @public */ export interface QwikPluginDevTools { /** diff --git a/packages/qwik-vite/src/plugins/plugin.unit.ts b/packages/qwik-vite/src/plugins/plugin.unit.ts index 6a9e030ef07..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(); @@ -332,6 +366,9 @@ describe('resolveId', () => { 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/); @@ -352,6 +389,51 @@ describe('resolveId', () => { 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({ @@ -364,10 +446,10 @@ describe('resolveId', () => { 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.toBeNull(); + 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.toBeNull(); + ).resolves.toBeFalsy(); }); test('allows server-only modules from vite server environments', async () => { const plugin = await mockPlugin(); @@ -383,6 +465,21 @@ describe('resolveId', () => { 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({ 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.` + ); +}; From c1f8fd52d36a3752b6c4347f852f7a7e84e4120f Mon Sep 17 00:00:00 2001 From: Varixo Date: Sat, 2 May 2026 14:26:49 +0200 Subject: [PATCH 4/4] feat: show real importer path --- e2e/qwik-e2e/tests/server-only-modules.e2e.ts | 12 +++++++++--- packages/qwik-vite/src/plugins/plugin.ts | 11 +++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/e2e/qwik-e2e/tests/server-only-modules.e2e.ts b/e2e/qwik-e2e/tests/server-only-modules.e2e.ts index 4e489ea796d..a8a67a30a4e 100644 --- a/e2e/qwik-e2e/tests/server-only-modules.e2e.ts +++ b/e2e/qwik-e2e/tests/server-only-modules.e2e.ts @@ -90,9 +90,15 @@ test.describe('server-only modules', () => { test('dev rejects route components that use .server modules during ssr load', async () => { await withDevServer(rejectedAppDir, async (server) => { - await expect(server.ssrLoadModule('/src/routes/index.tsx')).rejects.toThrow( - /Server-only module cannot be imported by client code/ - ); + 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_'); }); }); diff --git a/packages/qwik-vite/src/plugins/plugin.ts b/packages/qwik-vite/src/plugins/plugin.ts index 171e2b8f61d..fd13236ced0 100644 --- a/packages/qwik-vite/src/plugins/plugin.ts +++ b/packages/qwik-vite/src/plugins/plugin.ts @@ -539,7 +539,8 @@ export function createQwikPlugin(optimizerOptions: OptimizerOptions = {}) { const assertClientTransformCanImport = async ( ctx: Rollup.PluginContext, code: string, - importerId: string + resolveImporterId: string, + importerId = resolveImporterId ) => { if (!mightContainServerOnlyImport(code)) { return; @@ -549,7 +550,7 @@ export function createQwikPlugin(optimizerOptions: OptimizerOptions = {}) { continue; } assertClientCanImport(importId, importerId); - const resolved = await ctx.resolve(importId, importerId, { skipSelf: true }); + const resolved = await ctx.resolve(importId, resolveImporterId, { skipSelf: true }); if (resolved) { assertClientCanImport(normalizePath(parseId(resolved.id).pathId), importerId); } @@ -560,6 +561,7 @@ export function createQwikPlugin(optimizerOptions: OptimizerOptions = {}) { ctx: Rollup.PluginContext, output: TransformOutput, srcDir: string, + importerId?: string, additionalOnly = false ) => { const path = getPath(); @@ -568,7 +570,7 @@ export function createQwikPlugin(optimizerOptions: OptimizerOptions = {}) { continue; } const outputPath = normalizePath(path.join(srcDir, mod.path)); - await assertClientTransformCanImport(ctx, mod.code, outputPath); + await assertClientTransformCanImport(ctx, mod.code, outputPath, importerId ?? outputPath); } }; @@ -1001,7 +1003,7 @@ export function createQwikPlugin(optimizerOptions: OptimizerOptions = {}) { const module = newOutput.modules.find((mod) => !isAdditionalFile(mod))!; if (shouldAssertClientImports(isServer)) { - await assertClientTransformOutputCanImport(ctx, newOutput, srcDir); + await assertClientTransformOutputCanImport(ctx, newOutput, srcDir, normalizedPathId); } else if (shouldValidateDevSsrClientOutput(isServer) && mightContainServerOnlyImport(code)) { const clientTransformOpts: TransformModulesOptions = { ...transformOpts, @@ -1018,6 +1020,7 @@ export function createQwikPlugin(optimizerOptions: OptimizerOptions = {}) { ctx, await optimizer.transformModules(clientTransformOpts), srcDir, + normalizedPathId, true ); }