diff --git a/e2e/fixtures/partial-build/README.md b/e2e/fixtures/partial-build/README.md new file mode 100644 index 000000000..e5e443e85 --- /dev/null +++ b/e2e/fixtures/partial-build/README.md @@ -0,0 +1 @@ +# Partial builds for static websites diff --git a/e2e/fixtures/partial-build/package.json b/e2e/fixtures/partial-build/package.json new file mode 100644 index 000000000..745d5cc38 --- /dev/null +++ b/e2e/fixtures/partial-build/package.json @@ -0,0 +1,24 @@ +{ + "name": "partial-build", + "version": "0.1.0", + "type": "module", + "private": true, + "scripts": { + "dev": "waku dev", + "build": "waku build", + "start": "waku start", + "partial": "waku build --experimental-partial" + }, + "dependencies": { + "react": "19.0.0-beta-4508873393-20240430", + "react-dom": "19.0.0-beta-4508873393-20240430", + "react-server-dom-webpack": "19.0.0-beta-4508873393-20240430", + "waku": "workspace:*", + "serve": "^14.2.3" + }, + "devDependencies": { + "@types/react": "18.3.1", + "@types/react-dom": "18.3.0", + "typescript": "5.4.4" + } +} diff --git a/e2e/fixtures/partial-build/src/pages/index.tsx b/e2e/fixtures/partial-build/src/pages/index.tsx new file mode 100644 index 000000000..f8327f85d --- /dev/null +++ b/e2e/fixtures/partial-build/src/pages/index.tsx @@ -0,0 +1,9 @@ +export default function HomePage() { + return
Home
; +} + +export async function getConfig() { + return { + render: 'static', + }; +} diff --git a/e2e/fixtures/partial-build/src/pages/page/[title].tsx b/e2e/fixtures/partial-build/src/pages/page/[title].tsx new file mode 100644 index 000000000..54fea2b39 --- /dev/null +++ b/e2e/fixtures/partial-build/src/pages/page/[title].tsx @@ -0,0 +1,12 @@ +import { getEnv } from 'waku/server'; + +export default function Test({ title }: { title: string }) { + return
{title}
; +} + +export async function getConfig() { + return { + render: 'static', + staticPaths: getEnv('PAGES')?.split(',') || [], + }; +} diff --git a/e2e/fixtures/partial-build/tsconfig.json b/e2e/fixtures/partial-build/tsconfig.json new file mode 100644 index 000000000..2590e13de --- /dev/null +++ b/e2e/fixtures/partial-build/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "composite": true, + "strict": true, + "target": "esnext", + "downlevelIteration": true, + "esModuleInterop": true, + "module": "nodenext", + "skipLibCheck": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "types": ["react/experimental"], + "jsx": "react-jsx", + "rootDir": "./src", + "outDir": "./dist" + } +} diff --git a/e2e/partial-builds.spec.ts b/e2e/partial-builds.spec.ts new file mode 100644 index 000000000..082bf286a --- /dev/null +++ b/e2e/partial-builds.spec.ts @@ -0,0 +1,76 @@ +import { execSync, exec, ChildProcess } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import waitPort from 'wait-port'; +import { getFreePort, terminate, test } from './utils.js'; +import { rm } from 'node:fs/promises'; +import { expect } from '@playwright/test'; +import { statSync } from 'fs'; + +const cwd = fileURLToPath(new URL('./fixtures/partial-build', import.meta.url)); + +const waku = fileURLToPath( + new URL('../packages/waku/dist/cli.js', import.meta.url), +); + +test.describe(`partial builds`, () => { + test.skip( + ({ browserName }) => browserName !== 'chromium', + 'Browsers are not relevant for this test. One is enough.', + ); + + let cp: ChildProcess; + let port: number; + + test.beforeEach(async ({ page }) => { + await rm(`${cwd}/dist`, { + recursive: true, + force: true, + }); + execSync(`node ${waku} build`, { + cwd, + env: { ...process.env, PAGES: 'a' }, + }); + port = await getFreePort(); + cp = exec(`node ${waku} start --port ${port}`, { cwd }); + await waitPort({ port }); + await page.goto(`http://localhost:${port}/page/a`); + expect(await page.getByTestId('title').textContent()).toBe('a'); + }); + + test('does not change pages that already exist', async () => { + const htmlBefore = statSync(`${cwd}/dist/public/page/a/index.html`); + const rscBefore = statSync(`${cwd}/dist/public/RSC/page/a.txt`); + execSync(`node ${waku} build --experimental-partial`, { + cwd, + env: { ...process.env, PAGES: 'a,b' }, + }); + const htmlAfter = statSync(`${cwd}/dist/public/page/a/index.html`); + const rscAfter = statSync(`${cwd}/dist/public/RSC/page/a.txt`); + expect(htmlBefore.mtimeMs).toBe(htmlAfter.mtimeMs); + expect(rscBefore.mtimeMs).toBe(rscAfter.mtimeMs); + }); + + test('adds new pages', async ({ page }) => { + execSync(`node ${waku} build --experimental-partial`, { + cwd, + env: { ...process.env, PAGES: 'a,b' }, + }); + await page.goto(`http://localhost:${port}/page/b`); + expect(await page.getByTestId('title').textContent()).toBe('b'); + }); + + test('does not delete old pages', async ({ page }) => { + execSync(`node ${waku} build --experimental-partial`, { + cwd, + env: { ...process.env, PAGES: 'c' }, + }); + await page.goto(`http://localhost:${port}/page/a`); + expect(await page.getByTestId('title').textContent()).toBe('a'); + await page.goto(`http://localhost:${port}/page/c`); + expect(await page.getByTestId('title').textContent()).toBe('c'); + }); + + test.afterEach(async () => { + await terminate(cp.pid!); + }); +}); diff --git a/packages/waku/src/cli.ts b/packages/waku/src/cli.ts index bed77bd55..d9b974de1 100644 --- a/packages/waku/src/cli.ts +++ b/packages/waku/src/cli.ts @@ -47,6 +47,9 @@ const { values, positionals } = parseArgs({ 'with-aws-lambda': { type: 'boolean', }, + 'experimental-partial': { + type: 'boolean', + }, port: { type: 'string', short: 'p', @@ -103,6 +106,7 @@ async function runBuild() { await build({ config, env: process.env as any, + partial: !!values['experimental-partial'], deploy: (values['with-vercel'] ?? !!process.env.VERCEL ? values['with-vercel-static'] diff --git a/packages/waku/src/lib/builder/build.ts b/packages/waku/src/lib/builder/build.ts index 0e5585958..e085586cb 100644 --- a/packages/waku/src/lib/builder/build.ts +++ b/packages/waku/src/lib/builder/build.ts @@ -165,6 +165,7 @@ const buildServerBundle = async ( | 'aws-lambda' | false, isNodeCompatible: boolean, + partial: boolean, ) => { const serverBuildOutput = await buildVite({ plugins: [ @@ -247,6 +248,7 @@ const buildServerBundle = async ( }, publicDir: false, build: { + emptyOutDir: !partial, ssr: true, ssrEmitAssets: true, target: 'node18', @@ -275,6 +277,7 @@ const buildSsrBundle = async ( clientEntryFiles: Record, serverBuildOutput: Awaited>, isNodeCompatible: boolean, + partial: boolean, ) => { const cssAssets = serverBuildOutput.output.flatMap(({ type, fileName }) => type === 'asset' && fileName.endsWith('.css') ? [fileName] : [], @@ -310,6 +313,7 @@ const buildSsrBundle = async ( }, publicDir: false, build: { + emptyOutDir: !partial, ssr: true, target: 'node18', outDir: joinPath(rootDir, config.distDir, DIST_SSR), @@ -343,6 +347,7 @@ const buildClientBundle = async ( config: ResolvedConfig, clientEntryFiles: Record, serverBuildOutput: Awaited>, + partial: boolean, ) => { const nonJsAssets = serverBuildOutput.output.flatMap(({ type, fileName }) => type === 'asset' && !fileName.endsWith('.js') ? [fileName] : [], @@ -361,6 +366,7 @@ const buildClientBundle = async ( rscManagedPlugin({ ...config, addMainToInput: true }), ], build: { + emptyOutDir: !partial, outDir: joinPath(rootDir, config.distDir, DIST_PUBLIC), rollupOptions: { onwarn, @@ -426,6 +432,10 @@ const emitRscFiles = async ( config.rscPath, encodeInput(input), ); + // Skip if the file already exists. + if (existsSync(destRscFile)) { + continue; + } await mkdir(joinPath(destRscFile, '..'), { recursive: true }); const readable = await renderRsc( { @@ -549,6 +559,10 @@ const emitHtmlFiles = async ( ? '404.html' // HACK special treatment for 404, better way? : pathname + '/index.html', ); + // In partial mode, skip if the file already exists. + if (existsSync(destHtmlFile)) { + return; + } const htmlReadable = await renderHtml({ config, pathname, @@ -606,6 +620,7 @@ export const publicIndexHtml = ${JSON.stringify(publicIndexHtml)}; export async function build(options: { config: Config; env?: Record; + partial?: boolean; deploy?: | 'vercel-static' | 'vercel-serverless' @@ -643,6 +658,7 @@ export async function build(options: { (options.deploy === 'deno' ? 'deno' : false) || (options.deploy === 'aws-lambda' ? 'aws-lambda' : false), isNodeCompatible, + !!options.partial, ); await buildSsrBundle( rootDir, @@ -650,12 +666,14 @@ export async function build(options: { clientEntryFiles, serverBuildOutput, isNodeCompatible, + !!options.partial, ); const clientBuildOutput = await buildClientBundle( rootDir, config, clientEntryFiles, serverBuildOutput, + !!options.partial, ); const distEntries = await import(filePathToFileURL(distEntriesFile)); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 823374f12..dc917646e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,34 @@ importers: specifier: workspace:* version: link:packages/waku + e2e/fixtures/partial-build: + dependencies: + react: + specifier: 19.0.0-beta-4508873393-20240430 + version: 19.0.0-beta-4508873393-20240430 + react-dom: + specifier: 19.0.0-beta-4508873393-20240430 + version: 19.0.0-beta-4508873393-20240430(react@19.0.0-beta-4508873393-20240430) + react-server-dom-webpack: + specifier: 19.0.0-beta-4508873393-20240430 + version: 19.0.0-beta-4508873393-20240430(react-dom@19.0.0-beta-4508873393-20240430)(react@19.0.0-beta-4508873393-20240430)(webpack@5.91.0) + serve: + specifier: ^14.2.3 + version: 14.2.3 + waku: + specifier: workspace:* + version: link:../../../packages/waku + devDependencies: + '@types/react': + specifier: 18.3.1 + version: 18.3.1 + '@types/react-dom': + specifier: 18.3.0 + version: 18.3.0 + typescript: + specifier: 5.4.4 + version: 5.4.4 + e2e/fixtures/render-type: dependencies: react: diff --git a/tsconfig.e2e.json b/tsconfig.e2e.json index 9a4b6f30c..e374ed536 100644 --- a/tsconfig.e2e.json +++ b/tsconfig.e2e.json @@ -32,6 +32,9 @@ }, { "path": "./e2e/fixtures/ssg-performance/tsconfig.json" + }, + { + "path": "./e2e/fixtures/partial-build/tsconfig.json" } ] }