diff --git a/e2e/fixtures/rsc-asset/package.json b/e2e/fixtures/rsc-asset/package.json
new file mode 100644
index 000000000..8a0e99097
--- /dev/null
+++ b/e2e/fixtures/rsc-asset/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "rsc-asset",
+ "version": "0.1.0",
+ "type": "module",
+ "private": true,
+ "scripts": {
+ "dev": "waku dev",
+ "build": "waku build",
+ "start": "waku start"
+ },
+ "dependencies": {
+ "react": "19.0.0",
+ "react-dom": "19.0.0",
+ "react-server-dom-webpack": "19.0.0",
+ "waku": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/react": "^19.0.10",
+ "@types/react-dom": "^19.0.4",
+ "typescript": "^5.7.3",
+ "vite": "6.1.0"
+ }
+}
diff --git a/e2e/fixtures/rsc-asset/src/components/App.tsx b/e2e/fixtures/rsc-asset/src/components/App.tsx
new file mode 100644
index 000000000..cd6fcb3fa
--- /dev/null
+++ b/e2e/fixtures/rsc-asset/src/components/App.tsx
@@ -0,0 +1,31 @@
+import fs from 'node:fs';
+import testClientTxtUrl from './test-client.txt?no-inline';
+
+const App = (_: { name: string }) => {
+ // vite doesn't handle `new URL` for ssr,
+ // so this is handled by a custom plugin in waku.config.ts
+ const testServerTxtUrl = new URL('./test-server.txt', import.meta.url);
+
+ return (
+
+
+ e2e-rsc-asset
+
+
+
+
+
+ server asset: {fs.readFileSync(testServerTxtUrl, 'utf-8')}
+
+
+
+
+ );
+};
+
+export default App;
diff --git a/e2e/fixtures/rsc-asset/src/components/test-client.txt b/e2e/fixtures/rsc-asset/src/components/test-client.txt
new file mode 100644
index 000000000..5ef5dc972
--- /dev/null
+++ b/e2e/fixtures/rsc-asset/src/components/test-client.txt
@@ -0,0 +1 @@
+test-client-ok
diff --git a/e2e/fixtures/rsc-asset/src/components/test-server.txt b/e2e/fixtures/rsc-asset/src/components/test-server.txt
new file mode 100644
index 000000000..937bc9b0b
--- /dev/null
+++ b/e2e/fixtures/rsc-asset/src/components/test-server.txt
@@ -0,0 +1 @@
+test-server-ok
diff --git a/e2e/fixtures/rsc-asset/src/entries.tsx b/e2e/fixtures/rsc-asset/src/entries.tsx
new file mode 100644
index 000000000..43b276537
--- /dev/null
+++ b/e2e/fixtures/rsc-asset/src/entries.tsx
@@ -0,0 +1,18 @@
+import { unstable_defineEntries as defineEntries } from 'waku/minimal/server';
+
+import App from './components/App.js';
+
+const entries: ReturnType = defineEntries({
+ handleRequest: async (input, { renderRsc }) => {
+ if (input.type === 'component') {
+ return renderRsc({ App: });
+ }
+ if (input.type === 'function') {
+ const value = await input.fn(...input.args);
+ return renderRsc({ _value: value });
+ }
+ },
+ handleBuild: () => null,
+});
+
+export default entries;
diff --git a/e2e/fixtures/rsc-asset/src/main.tsx b/e2e/fixtures/rsc-asset/src/main.tsx
new file mode 100644
index 000000000..579d2ee03
--- /dev/null
+++ b/e2e/fixtures/rsc-asset/src/main.tsx
@@ -0,0 +1,13 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { Root, Slot } from 'waku/minimal/client';
+
+const rootElement = (
+
+
+
+
+
+);
+
+createRoot(document as any).render(rootElement);
diff --git a/e2e/fixtures/rsc-asset/tsconfig.json b/e2e/fixtures/rsc-asset/tsconfig.json
new file mode 100644
index 000000000..891abdbb8
--- /dev/null
+++ b/e2e/fixtures/rsc-asset/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "strict": true,
+ "target": "esnext",
+ "downlevelIteration": true,
+ "esModuleInterop": true,
+ "module": "nodenext",
+ "skipLibCheck": true,
+ "noUncheckedIndexedAccess": true,
+ "exactOptionalPropertyTypes": true,
+ "types": ["react/experimental", "vite/client"],
+ "jsx": "react-jsx",
+ "outDir": "./dist",
+ "composite": true
+ },
+ "include": ["./src", "./waku.config.ts"]
+}
diff --git a/e2e/fixtures/rsc-asset/waku.config.ts b/e2e/fixtures/rsc-asset/waku.config.ts
new file mode 100644
index 000000000..9589001c8
--- /dev/null
+++ b/e2e/fixtures/rsc-asset/waku.config.ts
@@ -0,0 +1,37 @@
+import { defineConfig } from 'waku/config';
+import type { Plugin } from 'vite';
+import path from 'node:path';
+import fs from 'node:fs';
+
+export default defineConfig({
+ unstable_viteConfigs: {
+ 'build-server': () => ({
+ plugins: [importMetaUrlServerPlugin()],
+ }),
+ },
+});
+
+// emit asset and rewrite `new URL("./xxx", import.meta.url)` syntax for build.
+function importMetaUrlServerPlugin(): Plugin {
+ // https://github.com/vitejs/vite/blob/0f56e1724162df76fffd5508148db118767ebe32/packages/vite/src/node/plugins/assetImportMetaUrl.ts#L51-L52
+ const assetImportMetaUrlRE =
+ /\bnew\s+URL\s*\(\s*('[^']+'|"[^"]+"|`[^`]+`)\s*,\s*import\.meta\.url\s*(?:,\s*)?\)/dg;
+
+ return {
+ name: 'test-server-asset',
+ transform(code, id) {
+ return code.replace(assetImportMetaUrlRE, (s, match) => {
+ const absPath = path.resolve(path.dirname(id), match.slice(1, -1));
+ if (fs.existsSync(absPath)) {
+ const referenceId = this.emitFile({
+ type: 'asset',
+ name: path.basename(absPath),
+ source: new Uint8Array(fs.readFileSync(absPath)),
+ });
+ return `new URL(import.meta.ROLLUP_FILE_URL_${referenceId})`;
+ }
+ return s;
+ });
+ },
+ };
+}
diff --git a/e2e/rsc-asset.spec.ts b/e2e/rsc-asset.spec.ts
new file mode 100644
index 000000000..b25403999
--- /dev/null
+++ b/e2e/rsc-asset.spec.ts
@@ -0,0 +1,31 @@
+import { expect } from '@playwright/test';
+
+import { test, prepareNormalSetup } from './utils.js';
+
+const startApp = prepareNormalSetup('rsc-asset');
+
+for (const mode of ['DEV', 'PRD'] as const) {
+ test.describe(`rsc-asset: ${mode}`, () => {
+ let port: number;
+ let stopApp: () => Promise;
+ test.beforeAll(async () => {
+ ({ port, stopApp } = await startApp(mode));
+ });
+ test.afterAll(async () => {
+ await stopApp();
+ });
+
+ test('basic', async ({ page }) => {
+ await page.goto(`http://localhost:${port}/`);
+
+ // server asset
+ await expect(page.getByTestId('server-file')).toContainText(
+ 'server asset: test-server-ok',
+ );
+
+ // client asset
+ await page.getByTestId('client-link').click();
+ await page.getByText('test-client-ok').click();
+ });
+ });
+}
diff --git a/packages/waku/src/lib/builder/build.ts b/packages/waku/src/lib/builder/build.ts
index dcd2ad034..3d73ef501 100644
--- a/packages/waku/src/lib/builder/build.ts
+++ b/packages/waku/src/lib/builder/build.ts
@@ -23,12 +23,12 @@ import {
import { extendViteConfig } from '../utils/vite-config.js';
import {
appendFile,
+ copyFile,
createWriteStream,
existsSync,
mkdir,
readdir,
readFile,
- rename,
unlink,
writeFile,
} from '../utils/node-fs.js';
@@ -441,7 +441,7 @@ const buildClientBundle = async (
for (const nonJsAsset of nonJsAssets) {
const from = joinPath(rootDir, config.distDir, nonJsAsset);
const to = joinPath(rootDir, config.distDir, DIST_PUBLIC, nonJsAsset);
- await rename(from, to);
+ await copyFile(from, to);
}
return clientBuildOutput;
};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9ee3e71a0..a76f9c83d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -258,6 +258,34 @@ importers:
specifier: ^5.7.3
version: 5.7.3
+ e2e/fixtures/rsc-asset:
+ dependencies:
+ react:
+ specifier: 19.0.0
+ version: 19.0.0
+ react-dom:
+ specifier: 19.0.0
+ version: 19.0.0(react@19.0.0)
+ react-server-dom-webpack:
+ specifier: 19.0.0
+ version: 19.0.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(webpack@5.98.0)
+ waku:
+ specifier: workspace:*
+ version: link:../../../packages/waku
+ devDependencies:
+ '@types/react':
+ specifier: ^19.0.10
+ version: 19.0.10
+ '@types/react-dom':
+ specifier: ^19.0.4
+ version: 19.0.4(@types/react@19.0.10)
+ typescript:
+ specifier: ^5.7.3
+ version: 5.7.3
+ vite:
+ specifier: 6.1.0
+ version: 6.1.0(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)
+
e2e/fixtures/rsc-basic:
dependencies:
ai:
diff --git a/tsconfig.e2e.json b/tsconfig.e2e.json
index df4a1f6e3..473391046 100644
--- a/tsconfig.e2e.json
+++ b/tsconfig.e2e.json
@@ -6,6 +6,9 @@
},
"include": ["playwright.config.base.ts", "playwright.config.ts", "./e2e"],
"references": [
+ {
+ "path": "./e2e/fixtures/rsc-asset/tsconfig.json"
+ },
{
"path": "./e2e/fixtures/rsc-basic/tsconfig.json"
},