diff --git a/e2e/fixtures/ssg-performance/src/Path.tsx b/e2e/fixtures/ssg-performance/src/Path.tsx index 20a5119f7..0591daef4 100644 --- a/e2e/fixtures/ssg-performance/src/Path.tsx +++ b/e2e/fixtures/ssg-performance/src/Path.tsx @@ -1,5 +1,3 @@ -import { getPath } from './context.js'; - -export function Path() { - return

{getPath()}

; +export function Path({ path }: { path: string }) { + return

{path}

; } diff --git a/e2e/fixtures/ssg-performance/src/context.ts b/e2e/fixtures/ssg-performance/src/context.ts deleted file mode 100644 index e7954cef8..000000000 --- a/e2e/fixtures/ssg-performance/src/context.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { cache } from 'react'; - -// Straight copy of https://github.com/manvalls/server-only-context/tree/main -// Inlined for demonstration purposes. -function createContext(defaultValue: T): [() => T, (v: T) => void] { - const getRef = cache(() => ({ current: defaultValue })); - - const getValue = (): T => getRef().current; - - const setValue = (value: T) => { - getRef().current = value; - }; - - return [getValue, setValue]; -} - -export const [getPath, setPath] = createContext('/'); diff --git a/e2e/fixtures/ssg-performance/src/pages/[path].tsx b/e2e/fixtures/ssg-performance/src/pages/[slug].tsx similarity index 62% rename from e2e/fixtures/ssg-performance/src/pages/[path].tsx rename to e2e/fixtures/ssg-performance/src/pages/[slug].tsx index 7f4dc3ec8..ae2d9b6a0 100644 --- a/e2e/fixtures/ssg-performance/src/pages/[path].tsx +++ b/e2e/fixtures/ssg-performance/src/pages/[slug].tsx @@ -1,8 +1,9 @@ +import type { PageProps } from 'waku/router'; import { Path } from '../Path.js'; -export default async function Test() { +export default async function Test({ path }: PageProps<'/[slug]'>) { await new Promise((resolve) => setTimeout(resolve, 1000)); - return ; + return ; } export async function getConfig() { diff --git a/e2e/fixtures/ssg-performance/src/pages/_layout.tsx b/e2e/fixtures/ssg-performance/src/pages/_layout.tsx index 712e6f75b..8f20781fd 100644 --- a/e2e/fixtures/ssg-performance/src/pages/_layout.tsx +++ b/e2e/fixtures/ssg-performance/src/pages/_layout.tsx @@ -1,10 +1,5 @@ import type { PropsWithChildren } from 'react'; -import { setPath } from '../context.js'; -export default function Layout({ - children, - path, -}: PropsWithChildren<{ path: string }>) { - setPath(path); - return children; +export default function Layout({ children }: PropsWithChildren) { + return
{children}
; } diff --git a/e2e/fixtures/ssg-wildcard/package.json b/e2e/fixtures/ssg-wildcard/package.json index ec779274e..91e4e58b7 100644 --- a/e2e/fixtures/ssg-wildcard/package.json +++ b/e2e/fixtures/ssg-wildcard/package.json @@ -1,5 +1,5 @@ { - "name": "waku-example", + "name": "ssg-wildcard", "version": "0.1.0", "type": "module", "private": true, diff --git a/examples/11_fs-router/src/main.tsx b/examples/11_fs-router/src/main.tsx index bddb502d5..354c5d2f7 100644 --- a/examples/11_fs-router/src/main.tsx +++ b/examples/11_fs-router/src/main.tsx @@ -1,6 +1,6 @@ import { StrictMode } from 'react'; import { createRoot, hydrateRoot } from 'react-dom/client'; -import { Router } from 'waku/router/client'; +import { NewRouter as Router } from 'waku/router/client'; const rootElement = ( diff --git a/examples/44_cloudflare/waku.cloudflare-dev-server.ts b/examples/44_cloudflare/waku.cloudflare-dev-server.ts index a50d6d895..b79168281 100644 --- a/examples/44_cloudflare/waku.cloudflare-dev-server.ts +++ b/examples/44_cloudflare/waku.cloudflare-dev-server.ts @@ -9,10 +9,7 @@ export const cloudflareDevServer = (cfOptions: any) => { Object.assign(globalThis, { WebSocketPair }); }); return async (req: Request, app: Hono) => { - const [proxy, _] = await Promise.all([ - await wranglerPromise, - await miniflarePromise, - ]); + const [proxy, _] = await Promise.all([wranglerPromise, miniflarePromise]); Object.assign(req, { cf: proxy.cf }); Object.assign(globalThis, { caches: proxy.caches, diff --git a/packages/waku/src/lib/builder/build.ts b/packages/waku/src/lib/builder/build.ts index b2b76d9a1..d5eaafe99 100644 --- a/packages/waku/src/lib/builder/build.ts +++ b/packages/waku/src/lib/builder/build.ts @@ -528,6 +528,9 @@ const willEmitPublicIndexHtml = async ( } }; +// we write a max of 2500 pages at a time to avoid OOM +const PATH_SLICE_SIZE = 2500; + const emitHtmlFiles = async ( rootDir: string, env: Record, @@ -559,104 +562,112 @@ const emitHtmlFiles = async ( /.*?(.*?)<\/head>.*/s, '$1', ); - const dynamicHtmlPathMap = new Map(); - await Promise.all( - Array.from(buildConfig).map( - async ({ pathname, isStatic, entries, customCode, context }) => { - const pathSpec = - typeof pathname === 'string' ? pathname2pathSpec(pathname) : pathname; - let htmlStr = publicIndexHtml; - let htmlHead = publicIndexHtmlHead; - if (cssAssets.length) { - const cssStr = cssAssets - .map( - (asset) => - ``, - ) - .join('\n'); - // HACK is this too naive to inject style code? - htmlStr = htmlStr.replace(/<\/head>/, cssStr); - htmlHead += cssStr; - } - const rscPathsForPrefetch = new Set(); - const moduleIdsForPrefetch = new Set(); - for (const { rscPath, skipPrefetch } of entries || []) { - if (!skipPrefetch) { - rscPathsForPrefetch.add(rscPath); - for (const id of getClientModules(rscPath)) { - moduleIdsForPrefetch.add(id); - } - } - } - const code = - generatePrefetchCode( - basePrefix, - rscPathsForPrefetch, - moduleIdsForPrefetch, - ) + (customCode || ''); - if (code) { - // HACK is this too naive to inject script code? - htmlStr = htmlStr.replace( - /<\/head>/, - ``, - ); - htmlHead += ``; - } - if (!isStatic) { - dynamicHtmlPathMap.set(pathSpec, htmlHead); - return; - } - pathname = pathSpec2pathname(pathSpec); - const destHtmlFile = joinPath( - rootDir, - config.distDir, - DIST_PUBLIC, - extname(pathname) - ? pathname - : pathname === '/404' - ? '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, - searchParams: new URLSearchParams(), - htmlHead, - renderRscForHtml: (rscPath, rscParams) => - renderRsc( - { env, config, rscPath, context, decodedBody: rscParams }, - { isDev: false, entries: distEntries }, - ), - getSsrConfigForHtml: (pathname, searchParams) => - getSsrConfig( - { env, config, pathname, searchParams }, - { isDev: false, entries: distEntries }, - ), - isDev: false, - loadModule: distEntries.loadModule, - }); - await mkdir(joinPath(destHtmlFile, '..'), { recursive: true }); - if (htmlReadable) { - await pipeline( - Readable.fromWeb(htmlReadable as any), - createWriteStream(destHtmlFile), - ); - } else { - await writeFile(destHtmlFile, htmlStr); + const handlePath = async ({ + pathname, + isStatic, + entries, + customCode, + context, + }: BuildConfig[number]) => { + const pathSpec = + typeof pathname === 'string' ? pathname2pathSpec(pathname) : pathname; + let htmlStr = publicIndexHtml; + let htmlHead = publicIndexHtmlHead; + if (cssAssets.length) { + const cssStr = cssAssets + .map( + (asset) => + ``, + ) + .join('\n'); + // HACK is this too naive to inject style code? + htmlStr = htmlStr.replace(/<\/head>/, cssStr); + htmlHead += cssStr; + } + const rscPathsForPrefetch = new Set(); + const moduleIdsForPrefetch = new Set(); + for (const { rscPath, skipPrefetch } of entries || []) { + if (!skipPrefetch) { + rscPathsForPrefetch.add(rscPath); + for (const id of getClientModules(rscPath)) { + moduleIdsForPrefetch.add(id); } - }, - ), - ); + } + } + const code = + generatePrefetchCode( + basePrefix, + rscPathsForPrefetch, + moduleIdsForPrefetch, + ) + (customCode || ''); + if (code) { + // HACK is this too naive to inject script code? + htmlStr = htmlStr.replace( + /<\/head>/, + ``, + ); + htmlHead += ``; + } + if (!isStatic) { + dynamicHtmlPathMap.set(pathSpec, htmlHead); + return; + } + const pathFromSpec = pathSpec2pathname(pathSpec); + const destHtmlFile = joinPath( + rootDir, + config.distDir, + DIST_PUBLIC, + extname(pathFromSpec) + ? pathFromSpec + : pathFromSpec === '/404' + ? '404.html' // HACK special treatment for 404, better way? + : pathFromSpec + '/index.html', + ); + // In partial mode, skip if the file already exists. + if (existsSync(destHtmlFile)) { + return; + } + const htmlReadable = await renderHtml({ + config, + pathname: pathFromSpec, + searchParams: new URLSearchParams(), + htmlHead, + renderRscForHtml: (rscPath, rscParams) => + renderRsc( + { env, config, rscPath, context, decodedBody: rscParams }, + { isDev: false, entries: distEntries }, + ), + getSsrConfigForHtml: (pathname, searchParams) => + getSsrConfig( + { env, config, pathname, searchParams }, + { isDev: false, entries: distEntries }, + ), + isDev: false, + loadModule: distEntries.loadModule, + }); + await mkdir(joinPath(destHtmlFile, '..'), { recursive: true }); + if (htmlReadable) { + await pipeline( + Readable.fromWeb(htmlReadable as any), + createWriteStream(destHtmlFile), + ); + } else { + await writeFile(destHtmlFile, htmlStr); + } + }; + + const dynamicHtmlPathMap = new Map(); + for (let start = 0; start * PATH_SLICE_SIZE < buildConfig.length; start++) { + const end = start * PATH_SLICE_SIZE + PATH_SLICE_SIZE; + await Promise.all(buildConfig.slice(start, end).map(handlePath)); + } + const dynamicHtmlPaths = Array.from(dynamicHtmlPathMap); - const code = ` + const endCode = ` export const dynamicHtmlPaths = ${JSON.stringify(dynamicHtmlPaths)}; export const publicIndexHtml = ${JSON.stringify(publicIndexHtml)}; `; - await appendFile(distEntriesFile, code); + await appendFile(distEntriesFile, endCode); }; // FIXME this is too hacky @@ -735,166 +746,167 @@ const emitStaticFiles = async ( '$1', ); const dynamicHtmlPathMap = new Map(); - await Promise.all( - Array.from(buildConfig).map( - async ({ pathSpec, isStatic, entries, customCode }) => { - const moduleIdsForPrefetch = new Set(); - for (const { rscPath, isStatic } of entries || []) { - if (!isStatic) { - continue; - } - const destRscFile = joinPath( - rootDir, - config.distDir, - DIST_PUBLIC, - config.rscBase, - encodeRscPath(rscPath), - ); - // Skip if the file already exists. - if (existsSync(destRscFile)) { - continue; - } - await mkdir(joinPath(destRscFile, '..'), { recursive: true }); - const utils = { - renderRsc: (elements: Record) => - renderRscNew(config, { unstable_modules }, elements, (id) => - moduleIdsForPrefetch.add(id), - ), - renderHtml: () => { - throw new Error('Cannot render HTML in RSC build'); - }, - }; - const input = { - type: 'component', - rscPath, - rscParams: undefined, - req: { - body: null, - url: new URL( - 'http://localhost/' + - config.rscBase + - '/' + - encodeRscPath(rscPath), - ), - method: 'GET', - headers: {}, - }, - } as const; - const res = await distEntries.default.unstable_handleRequest( - input, - utils, - ); - const rscReadable = res instanceof ReadableStream ? res : res?.body; - await pipeline( - Readable.fromWeb(rscReadable as never), - createWriteStream(destRscFile), - ); - } - let htmlStr = publicIndexHtml; - let htmlHead = publicIndexHtmlHead; - if (cssAssets.length) { - const cssStr = cssAssets - .map( - (asset) => - ``, - ) - .join('\n'); - // HACK is this too naive to inject style code? - htmlStr = htmlStr.replace(/<\/head>/, cssStr); - htmlHead += cssStr; - } - const rscPathsForPrefetch = new Set(); - for (const { rscPath, skipPrefetch } of entries || []) { - if (!skipPrefetch) { - rscPathsForPrefetch.add(rscPath); - } - } - const code = - generatePrefetchCode( - basePrefix, - rscPathsForPrefetch, - moduleIdsForPrefetch, - ) + (customCode || ''); - if (code) { - // HACK is this too naive to inject script code? - htmlStr = htmlStr.replace( - /<\/head>/, - ``, - ); - htmlHead += ``; - } - const pathname = pathSpec2pathname(pathSpec); - const destFile = joinPath( - rootDir, - config.distDir, - DIST_PUBLIC, - extname(pathname) - ? pathname - : pathname === '/404' - ? '404.html' // HACK special treatment for 404, better way? - : pathname + '/index.html', + const handlePath = async ({ + pathSpec, + isStatic, + entries, + customCode, + }: new_BuildConfig[number]) => { + const moduleIdsForPrefetch = new Set(); + for (const { rscPath, isStatic } of entries || []) { + if (!isStatic) { + continue; + } + const destRscFile = joinPath( + rootDir, + config.distDir, + DIST_PUBLIC, + config.rscBase, + encodeRscPath(rscPath), + ); + // Skip if the file already exists. + if (existsSync(destRscFile)) { + continue; + } + await mkdir(joinPath(destRscFile, '..'), { recursive: true }); + const utils = { + renderRsc: (elements: Record) => + renderRscNew(config, { unstable_modules }, elements, (id) => + moduleIdsForPrefetch.add(id), + ), + renderHtml: () => { + throw new Error('Cannot render HTML in RSC build'); + }, + }; + const input = { + type: 'component', + rscPath, + rscParams: undefined, + req: { + body: null, + url: new URL( + 'http://localhost/' + config.rscBase + '/' + encodeRscPath(rscPath), + ), + method: 'GET', + headers: {}, + }, + } as const; + const res = await distEntries.default.unstable_handleRequest( + input, + utils, + ); + const rscReadable = res instanceof ReadableStream ? res : res?.body; + await pipeline( + Readable.fromWeb(rscReadable as never), + createWriteStream(destRscFile), + ); + } + let htmlStr = publicIndexHtml; + let htmlHead = publicIndexHtmlHead; + if (cssAssets.length) { + const cssStr = cssAssets + .map( + (asset) => + ``, + ) + .join('\n'); + // HACK is this too naive to inject style code? + htmlStr = htmlStr.replace(/<\/head>/, cssStr); + htmlHead += cssStr; + } + const rscPathsForPrefetch = new Set(); + for (const { rscPath, skipPrefetch } of entries || []) { + if (!skipPrefetch) { + rscPathsForPrefetch.add(rscPath); + } + } + const code = + generatePrefetchCode( + basePrefix, + rscPathsForPrefetch, + moduleIdsForPrefetch, + ) + (customCode || ''); + if (code) { + // HACK is this too naive to inject script code? + htmlStr = htmlStr.replace( + /<\/head>/, + ``, + ); + htmlHead += ``; + } + const pathname = pathSpec2pathname(pathSpec); + const destFile = joinPath( + rootDir, + config.distDir, + DIST_PUBLIC, + extname(pathname) + ? pathname + : pathname === '/404' + ? '404.html' // HACK special treatment for 404, better way? + : pathname + '/index.html', + ); + if (!isStatic) { + if (destFile.endsWith('.html')) { + // HACK doesn't feel ideal + dynamicHtmlPathMap.set(pathSpec, htmlHead); + } + return; + } + // In partial mode, skip if the file already exists. + if (existsSync(destFile)) { + return; + } + const utils = { + renderRsc: (elements: Record) => + renderRscNew(config, { unstable_modules }, elements), + renderHtml: ( + elements: Record, + html: ReactNode, + rscPath: string, + ) => { + const readable = renderHtmlNew( + config, + { unstable_modules }, + htmlHead, + elements, + html, + rscPath, ); - if (!isStatic) { - if (destFile.endsWith('.html')) { - // HACK doesn't feel ideal - dynamicHtmlPathMap.set(pathSpec, htmlHead); - } - return; - } - // In partial mode, skip if the file already exists. - if (existsSync(destFile)) { - return; - } - const utils = { - renderRsc: (elements: Record) => - renderRscNew(config, { unstable_modules }, elements), - renderHtml: ( - elements: Record, - html: ReactNode, - rscPath: string, - ) => { - const readable = renderHtmlNew( - config, - { unstable_modules }, - htmlHead, - elements, - html, - rscPath, - ); - const headers = { 'content-type': 'text/html; charset=utf-8' }; - return { - body: readable, - headers, - }; - }, + const headers = { 'content-type': 'text/html; charset=utf-8' }; + return { + body: readable, + headers, }; - const input = { - type: 'custom', - pathname, - req: { - body: null, - url: new URL('http://localhost' + pathname), - method: 'GET', - headers: {}, - }, - } as const; - const res = await distEntries.default.unstable_handleRequest( - input, - utils, - ); - const readable = res instanceof ReadableStream ? res : res?.body; - await mkdir(joinPath(destFile, '..'), { recursive: true }); - if (readable) { - await pipeline( - Readable.fromWeb(readable as never), - createWriteStream(destFile), - ); - } else if (destFile.endsWith('.html')) { - await writeFile(destFile, htmlStr); - } }, - ), - ); + }; + const input = { + type: 'custom', + pathname, + req: { + body: null, + url: new URL('http://localhost' + pathname), + method: 'GET', + headers: {}, + }, + } as const; + const res = await distEntries.default.unstable_handleRequest(input, utils); + const readable = res instanceof ReadableStream ? res : res?.body; + await mkdir(joinPath(destFile, '..'), { recursive: true }); + if (readable) { + await pipeline( + Readable.fromWeb(readable as never), + createWriteStream(destFile), + ); + } else if (destFile.endsWith('.html')) { + await writeFile(destFile, htmlStr); + } + }; + + for (let start = 0; start * PATH_SLICE_SIZE < buildConfig.length; start++) { + const end = start * PATH_SLICE_SIZE + PATH_SLICE_SIZE; + await Promise.all(buildConfig.slice(start, end).map(handlePath)); + } + const dynamicHtmlPaths = Array.from(dynamicHtmlPathMap); const code = ` export const dynamicHtmlPaths = ${JSON.stringify(dynamicHtmlPaths)}; diff --git a/packages/waku/src/lib/plugins/vite-plugin-rsc-managed.ts b/packages/waku/src/lib/plugins/vite-plugin-rsc-managed.ts index 9c03e28b2..b99c60592 100644 --- a/packages/waku/src/lib/plugins/vite-plugin-rsc-managed.ts +++ b/packages/waku/src/lib/plugins/vite-plugin-rsc-managed.ts @@ -22,7 +22,7 @@ export default fsRouter( const getManagedMain = () => ` import { StrictMode, createElement } from 'react'; import { createRoot, hydrateRoot } from 'react-dom/client'; -import { Router } from 'waku/router/client'; +import { NewRouter as Router } from 'waku/router/client'; const rootElement = createElement(StrictMode, null, createElement(Router)); diff --git a/packages/waku/src/router/fs-router.ts b/packages/waku/src/router/fs-router.ts index 946e3ffa1..46172123c 100644 --- a/packages/waku/src/router/fs-router.ts +++ b/packages/waku/src/router/fs-router.ts @@ -1,5 +1,5 @@ import { unstable_getPlatformObject } from '../server.js'; -import { createPages } from './create-pages.js'; +import { new_createPages as createPages } from './create-pages.js'; import { EXTENSIONS } from '../lib/constants.js'; @@ -64,7 +64,11 @@ export function fsRouter( ? pathItems.slice(0, -1) : pathItems ).join('/'); - if (pathItems.at(-1) === '_layout') { + if (pathItems.at(-1) === '[path]') { + throw new Error( + 'Page file cannot be named [path]. This will conflict with the path prop of the page component.', + ); + } else if (pathItems.at(-1) === '_layout') { createLayout({ path, component: mod.default,