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.`
+ );
+};