diff --git a/packages/waku/src/client.ts b/packages/waku/src/client.ts index 79e0a6d40..99c576b17 100644 --- a/packages/waku/src/client.ts +++ b/packages/waku/src/client.ts @@ -13,7 +13,7 @@ import { import type { ReactNode } from 'react'; import RSDWClient from 'react-server-dom-webpack/client'; -import { encodeInput } from './lib/renderers/utils.js'; +import { encodeInput, encodeActionId } from './lib/renderers/utils.js'; const { createFromFetch, encodeReply } = RSDWClient; @@ -81,7 +81,7 @@ export const fetchRSC = ( const options = { async callServer(actionId: string, args: unknown[]) { const response = fetch( - BASE_PATH + encodeInput(encodeURIComponent(actionId)), + BASE_PATH + encodeInput(encodeActionId(actionId)), { method: 'POST', body: await encodeReply(args), diff --git a/packages/waku/src/config.ts b/packages/waku/src/config.ts index 6194fa287..bcf3c7211 100644 --- a/packages/waku/src/config.ts +++ b/packages/waku/src/config.ts @@ -27,6 +27,11 @@ export interface Config { * Defaults to "assets". */ assetsDir?: string; + /** + * The SSR directory relative to distDir. + * Defaults to "ssr". + */ + ssrDir?: string; /** * The index.html file for any directories. * Defaults to "index.html". diff --git a/packages/waku/src/lib/builder/build.ts b/packages/waku/src/lib/builder/build.ts index a0b34a3a8..df4b33c30 100644 --- a/packages/waku/src/lib/builder/build.ts +++ b/packages/waku/src/lib/builder/build.ts @@ -22,6 +22,7 @@ import { createReadStream, createWriteStream, existsSync, + copyFile, rename, mkdir, readFile, @@ -135,6 +136,7 @@ const analyzeEntries = async (entriesFile: string) => { }; }; +// For RSC const buildServerBundle = async ( rootDir: string, config: ResolvedConfig, @@ -152,13 +154,11 @@ const buildServerBundle = async ( rscTransformPlugin({ isBuild: true, assetsDir: config.assetsDir, - clientEntryFiles: { - // FIXME this seems very ad-hoc - [WAKU_CLIENT]: decodeFilePathFromAbsolute( - joinPath(fileURLToFilePath(import.meta.url), '../../../client.js'), - ), - ...clientEntryFiles, - }, + wakuClientId: WAKU_CLIENT, + wakuClientPath: decodeFilePathFromAbsolute( + joinPath(fileURLToFilePath(import.meta.url), '../../../client.js'), + ), + clientEntryFiles, serverEntryFiles, }), rscEnvPlugin({ config }), @@ -224,7 +224,8 @@ const buildServerBundle = async ( if (!('output' in serverBuildOutput)) { throw new Error('Unexpected vite server build output'); } - const psDir = joinPath(config.publicDir, config.assetsDir); + // TODO If ssr === false, we don't need to write ssr entries. + const ssrAssetsDir = joinPath(config.ssrDir, config.assetsDir); const code = ` export function loadModule(id) { switch (id) { @@ -234,24 +235,24 @@ ${Object.keys(CLIENT_MODULE_MAP) .map( (key) => ` case '${CLIENT_PREFIX}${key}': - return import('./${psDir}/${key}.js'); + return import('./${ssrAssetsDir}/${key}.js'); `, ) .join('')} - case '${psDir}/${WAKU_CLIENT}.js': - return import('./${psDir}/${WAKU_CLIENT}.js'); -${Object.entries(serverEntryFiles || {}) + case '${ssrAssetsDir}/${WAKU_CLIENT}.js': + return import('./${ssrAssetsDir}/${WAKU_CLIENT}.js'); +${Object.entries(clientEntryFiles || {}) .map( ([k]) => ` - case '${config.assetsDir}/${k}.js': - return import('./${config.assetsDir}/${k}.js');`, + case '${ssrAssetsDir}/${k}.js': + return import('./${ssrAssetsDir}/${k}.js');`, ) .join('')} -${Object.entries(clientEntryFiles || {}) +${Object.entries(serverEntryFiles || {}) .map( ([k]) => ` - case '${psDir}/${k}.js': - return import('./${psDir}/${k}.js');`, + case '${config.assetsDir}/${k}.js': + return import('./${config.assetsDir}/${k}.js');`, ) .join('')} default: @@ -263,6 +264,67 @@ ${Object.entries(clientEntryFiles || {}) return serverBuildOutput; }; +// For SSR (render client components on server to generate HTML) +const buildSsrBundle = async ( + rootDir: string, + config: ResolvedConfig, + commonEntryFiles: Record, + clientEntryFiles: Record, + serverBuildOutput: Awaited>, +) => { + const mainJsFile = joinPath(rootDir, config.srcDir, config.mainJs); + const cssAssets = serverBuildOutput.output.flatMap(({ type, fileName }) => + type === 'asset' && fileName.endsWith('.css') ? [fileName] : [], + ); + await buildVite({ + base: config.basePath, + plugins: [ + rscIndexPlugin({ ...config, cssAssets }), + rscEnvPlugin({ config, hydrate: true }), + ], + ssr: { + noExternal: /^(?!node:)/, + }, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + publicDir: false, + build: { + ssr: true, + outDir: joinPath(rootDir, config.distDir, config.ssrDir), + rollupOptions: { + onwarn, + input: { + main: mainJsFile, + ...CLIENT_MODULE_MAP, + ...commonEntryFiles, + ...clientEntryFiles, + }, + output: { + entryFileNames: (chunkInfo) => { + if ( + CLIENT_MODULE_MAP[ + chunkInfo.name as keyof typeof CLIENT_MODULE_MAP + ] || + commonEntryFiles[chunkInfo.name] || + clientEntryFiles[chunkInfo.name] + ) { + return config.assetsDir + '/[name].js'; + } + return config.assetsDir + '/[name]-[hash].js'; + }, + }, + }, + }, + }); + for (const cssAsset of cssAssets) { + const from = joinPath(rootDir, config.distDir, cssAsset); + const to = joinPath(rootDir, config.distDir, config.ssrDir, cssAsset); + await copyFile(from, to); + } +}; + +// For Browsers const buildClientBundle = async ( rootDir: string, config: ResolvedConfig, @@ -288,7 +350,7 @@ const buildClientBundle = async ( onwarn, input: { main: mainJsFile, - ...CLIENT_MODULE_MAP, + [WAKU_CLIENT]: CLIENT_MODULE_MAP[WAKU_CLIENT], ...commonEntryFiles, ...clientEntryFiles, }, @@ -296,9 +358,7 @@ const buildClientBundle = async ( output: { entryFileNames: (chunkInfo) => { if ( - CLIENT_MODULE_MAP[ - chunkInfo.name as keyof typeof CLIENT_MODULE_MAP - ] || + [WAKU_CLIENT].includes(chunkInfo.name) || commonEntryFiles[chunkInfo.name] || clientEntryFiles[chunkInfo.name] ) { @@ -567,6 +627,15 @@ export async function build(options: { (options.deploy === 'netlify-functions' ? 'netlify' : false) || (options.deploy === 'aws-lambda' ? 'aws-lambda' : false), ); + if (options.ssr) { + await buildSsrBundle( + rootDir, + config, + commonEntryFiles, + clientEntryFiles, + serverBuildOutput, + ); + } await buildClientBundle( rootDir, config, diff --git a/packages/waku/src/lib/config.ts b/packages/waku/src/lib/config.ts index 2b5ec998a..6ff9861b2 100644 --- a/packages/waku/src/lib/config.ts +++ b/packages/waku/src/lib/config.ts @@ -21,6 +21,7 @@ export async function resolveConfig(config: Config) { distDir: 'dist', publicDir: 'public', assetsDir: 'assets', + ssrDir: 'ssr', indexHtml: 'index.html', mainJs: 'main.tsx', entriesJs: 'entries.js', diff --git a/packages/waku/src/lib/plugins/vite-plugin-rsc-transform.ts b/packages/waku/src/lib/plugins/vite-plugin-rsc-transform.ts index f2f351994..d1354fdc6 100644 --- a/packages/waku/src/lib/plugins/vite-plugin-rsc-transform.ts +++ b/packages/waku/src/lib/plugins/vite-plugin-rsc-transform.ts @@ -9,6 +9,8 @@ export function rscTransformPlugin( | { isBuild: true; assetsDir: string; + wakuClientId: string; + wakuClientPath: string; clientEntryFiles: Record; serverEntryFiles: Record; }, @@ -17,6 +19,9 @@ export function rscTransformPlugin( if (!opts.isBuild) { throw new Error('not buiding'); } + if (id === opts.wakuClientPath) { + return `@id/${opts.assetsDir}/${opts.wakuClientId}.js`; + } for (const [k, v] of Object.entries(opts.clientEntryFiles)) { if (v === id) { return `@id/${opts.assetsDir}/${k}.js`; diff --git a/packages/waku/src/lib/renderers/html-renderer.ts b/packages/waku/src/lib/renderers/html-renderer.ts index 66e52947c..c0cafb828 100644 --- a/packages/waku/src/lib/renderers/html-renderer.ts +++ b/packages/waku/src/lib/renderers/html-renderer.ts @@ -322,7 +322,7 @@ export const renderHtml = async ( moduleLoading.set( id, opts - .loadModule(joinPath(config.publicDir, id)) + .loadModule(joinPath(config.ssrDir, id)) .then((m: any) => { moduleCache.set(id, m); }), diff --git a/packages/waku/src/lib/renderers/rsc-renderer.ts b/packages/waku/src/lib/renderers/rsc-renderer.ts index 85a041358..a43b9ce9a 100644 --- a/packages/waku/src/lib/renderers/rsc-renderer.ts +++ b/packages/waku/src/lib/renderers/rsc-renderer.ts @@ -10,6 +10,7 @@ import { } from '../utils/path.js'; import { parseFormData } from '../utils/form.js'; import { streamToString } from '../utils/stream.js'; +import { decodeActionId } from '../renderers/utils.js'; export const RSDW_SERVER_MODULE = 'rsdw-server'; export const RSDW_SERVER_MODULE_VALUE = 'react-server-dom-webpack/server.edge'; @@ -110,7 +111,7 @@ export async function renderRsc( ); if (method === 'POST') { - const rsfId = decodeURIComponent(input); + const rsfId = decodeActionId(input); let args: unknown[] = []; let bodyStr = ''; if (body) { diff --git a/packages/waku/src/lib/renderers/utils.ts b/packages/waku/src/lib/renderers/utils.ts index d8e5c1f60..b23292841 100644 --- a/packages/waku/src/lib/renderers/utils.ts +++ b/packages/waku/src/lib/renderers/utils.ts @@ -28,6 +28,19 @@ export const decodeInput = (encodedInput: string) => { throw err; }; +export const encodeActionId = (actionId: string) => { + const [file, name] = actionId.split('#') as [string, string]; + if (name.includes('/')) { + throw new Error('Unsupported action name'); + } + return '_' + file + '/' + name; +}; + +export const decodeActionId = (encoded: string) => { + const index = encoded.lastIndexOf('/'); + return encoded.slice(1, index) + '#' + encoded.slice(index + 1); +}; + export const hasStatusCode = (x: unknown): x is { statusCode: number } => typeof (x as any)?.statusCode === 'number'; diff --git a/packages/waku/src/lib/utils/node-fs.ts b/packages/waku/src/lib/utils/node-fs.ts index 71801910f..049aa0945 100644 --- a/packages/waku/src/lib/utils/node-fs.ts +++ b/packages/waku/src/lib/utils/node-fs.ts @@ -14,6 +14,9 @@ export const createWriteStream = (filePath: string) => export const existsSync = (filePath: string) => fs.existsSync(filePathToOsPath(filePath)); +export const copyFile = (filePath1: string, filePath2: string) => + fsPromises.copyFile(filePathToOsPath(filePath1), filePathToOsPath(filePath2)); + export const rename = (filePath1: string, filePath2: string) => fsPromises.rename(filePathToOsPath(filePath1), filePathToOsPath(filePath2));