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 + + +
+
+ client asset:{' '} + + {testClientTxtUrl} + +
+
+ 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" },