diff --git a/docs/redirects.mdx b/docs/redirects.mdx index 89302acc8..b239a12ba 100644 --- a/docs/redirects.mdx +++ b/docs/redirects.mdx @@ -52,9 +52,9 @@ import type { Config } from 'waku/config'; export default { middleware: () => [ + import('waku/middleware/context'), import('./src/middleware/redirects.js'), import('waku/middleware/dev-server'), - import('waku/middleware/headers'), import('waku/middleware/rsc'), import('waku/middleware/ssr'), ], diff --git a/e2e/fixtures/broken-links/waku.config.ts b/e2e/fixtures/broken-links/waku.config.ts index 6babe53f4..d05df8033 100644 --- a/e2e/fixtures/broken-links/waku.config.ts +++ b/e2e/fixtures/broken-links/waku.config.ts @@ -1,9 +1,9 @@ /** @type {import('waku/config').Config} */ export default { middleware: () => [ + import('waku/middleware/context'), import('./src/redirects.js'), import('waku/middleware/dev-server'), - import('waku/middleware/headers'), import('waku/middleware/rsc'), import('waku/middleware/ssr'), ], diff --git a/examples/12_nossr/waku.config.ts b/examples/12_nossr/waku.config.ts index 8b8ba4250..770a600a2 100644 --- a/examples/12_nossr/waku.config.ts +++ b/examples/12_nossr/waku.config.ts @@ -1,6 +1,7 @@ /** @type {import('waku/config').Config} */ export default { middleware: () => [ + import('waku/middleware/context'), import('waku/middleware/dev-server'), import('waku/middleware/rsc'), import('waku/middleware/fallback'), diff --git a/examples/31_minimal/src/entries.tsx b/examples/31_minimal/src/entries.tsx index 894d8e6d6..3c3b34835 100644 --- a/examples/31_minimal/src/entries.tsx +++ b/examples/31_minimal/src/entries.tsx @@ -1,27 +1,18 @@ -import { defineEntries } from 'waku/server'; -import { Slot } from 'waku/client'; +import { new_defineEntries } from 'waku/minimal/server'; +import { Slot } from 'waku/minimal/client'; import App from './components/App'; -export default defineEntries( - // renderEntries - async (rscPath) => { - return { - App: , - }; - }, - // getBuildConfig - async () => [{ pathname: '/', entries: [{ rscPath: '' }] }], - // getSsrConfig - async (pathname) => { - switch (pathname) { - case '/': - return { - rscPath: '', - html: , - }; - default: - return null; +export default new_defineEntries({ + unstable_handleRequest: async (input, { renderRsc, renderHtml }) => { + if (input.type === 'component') { + return renderRsc({ App: }); + } + if (input.type === 'custom' && input.pathname === '/') { + return renderHtml({ App: }, , ''); } }, -); + unstable_getBuildConfig: async () => [ + { pathname: '/', entries: [{ rscPath: '' }] }, + ], +}); diff --git a/examples/31_minimal/waku.config.ts b/examples/31_minimal/waku.config.ts new file mode 100644 index 000000000..d2d8ee9d7 --- /dev/null +++ b/examples/31_minimal/waku.config.ts @@ -0,0 +1,12 @@ +// This is a temporary file while experimenting new_defineEntries + +import { defineConfig } from 'waku/config'; + +export default defineConfig({ + middleware: () => [ + import('waku/middleware/context'), + import('waku/middleware/dev-server'), + import('waku/middleware/handler'), + import('waku/middleware/fallback'), + ], +}); diff --git a/examples/34_functions/src/als.ts b/examples/34_functions/src/als.ts new file mode 100644 index 000000000..92f2240f8 --- /dev/null +++ b/examples/34_functions/src/als.ts @@ -0,0 +1,16 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +type Rerender = (rscPath: string) => void; + +const store = new AsyncLocalStorage(); + +export const runWithRerender = (rerender: Rerender, fn: () => T): T => { + return store.run(rerender, fn); +}; + +export const rerender = (rscPath: string) => { + const fn = store.getStore(); + if (fn) { + fn(rscPath); + } +}; diff --git a/examples/34_functions/src/components2/funcs.ts b/examples/34_functions/src/components2/funcs.ts index c8172f348..a9e6141ae 100644 --- a/examples/34_functions/src/components2/funcs.ts +++ b/examples/34_functions/src/components2/funcs.ts @@ -1,13 +1,9 @@ 'use server'; -import { - rerender, - unstable_getCustomContext as getCustomContext, -} from 'waku/server'; +import { rerender } from '../als'; export const greet = async (name: string) => { await Promise.resolve(); - console.log('Custom Context:', getCustomContext()); // ---> {} return `Hello ${name} from server!`; }; diff --git a/examples/34_functions/src/components2/funcs2.ts b/examples/34_functions/src/components2/funcs2.ts index 1514c5c66..e9056791d 100644 --- a/examples/34_functions/src/components2/funcs2.ts +++ b/examples/34_functions/src/components2/funcs2.ts @@ -1,6 +1,6 @@ 'use server'; -import { unstable_getCustomContext as getCustomContext } from 'waku/server'; +import { getContext } from 'waku/middleware/context'; export const greet = async (name: string) => { await Promise.resolve(); @@ -9,6 +9,6 @@ export const greet = async (name: string) => { export const hello = async (name: string) => { await Promise.resolve(); - console.log('Custom Context:', getCustomContext()); // ---> {} + console.log('Context:', getContext()); console.log('Hello', name, '!'); }; diff --git a/examples/34_functions/src/entries.tsx b/examples/34_functions/src/entries.tsx index 852bca1ca..b037ebb03 100644 --- a/examples/34_functions/src/entries.tsx +++ b/examples/34_functions/src/entries.tsx @@ -1,27 +1,29 @@ -import { defineEntries } from 'waku/server'; -import { Slot } from 'waku/client'; +import { new_defineEntries } from 'waku/minimal/server'; +import { Slot } from 'waku/minimal/client'; import App from './components2/App'; +import { runWithRerender } from './als'; -export default defineEntries( - // renderEntries - async (rscPath) => { - return { - App: , - }; - }, - // getBuildConfig - async () => [{ pathname: '/', entries: [{ rscPath: '' }] }], - // getSsrConfig - async (pathname) => { - switch (pathname) { - case '/': - return { - rscPath: '', - html: , - }; - default: - return null; +export default new_defineEntries({ + unstable_handleRequest: async (input, { renderRsc, renderHtml }) => { + if (input.type === 'component') { + return renderRsc({ App: }); + } + if (input.type === 'function') { + const elements: Record = {}; + const rerender = (rscPath: string) => { + elements.App = ; + }; + const value = await runWithRerender(rerender, () => + input.fn(...input.args), + ); + return renderRsc({ ...elements, _value: value }); + } + if (input.type === 'custom' && input.pathname === '/') { + return renderHtml({ App: }, , ''); } }, -); + unstable_getBuildConfig: async () => [ + { pathname: '/', entries: [{ rscPath: '' }] }, + ], +}); diff --git a/examples/34_functions/tsconfig.json b/examples/34_functions/tsconfig.json index 84d0d542f..33d25f480 100644 --- a/examples/34_functions/tsconfig.json +++ b/examples/34_functions/tsconfig.json @@ -9,7 +9,7 @@ "skipLibCheck": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, - "types": ["react/experimental"], + "types": ["node", "react/experimental"], "jsx": "react-jsx" } } diff --git a/examples/34_functions/waku.config.ts b/examples/34_functions/waku.config.ts new file mode 100644 index 000000000..d2d8ee9d7 --- /dev/null +++ b/examples/34_functions/waku.config.ts @@ -0,0 +1,12 @@ +// This is a temporary file while experimenting new_defineEntries + +import { defineConfig } from 'waku/config'; + +export default defineConfig({ + middleware: () => [ + import('waku/middleware/context'), + import('waku/middleware/dev-server'), + import('waku/middleware/handler'), + import('waku/middleware/fallback'), + ], +}); diff --git a/examples/35_nesting/src/components/Counter.tsx b/examples/35_nesting/src/components/Counter.tsx index b1397860c..6de58400a 100644 --- a/examples/35_nesting/src/components/Counter.tsx +++ b/examples/35_nesting/src/components/Counter.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useTransition } from 'react'; -import { Slot, useRefetch } from 'waku/client'; +import { Slot, useRefetch } from 'waku/minimal/client'; export const Counter = ({ enableInnerApp }: { enableInnerApp?: boolean }) => { const [count, setCount] = useState(0); diff --git a/examples/38_cookies/waku.config.ts b/examples/38_cookies/waku.config.ts index e8db632d1..395ecb424 100644 --- a/examples/38_cookies/waku.config.ts +++ b/examples/38_cookies/waku.config.ts @@ -1,9 +1,9 @@ /** @type {import('waku/config').Config} */ export default { middleware: () => [ + import('waku/middleware/context'), import('./src/middleware/cookie.js'), import('waku/middleware/dev-server'), - import('waku/middleware/headers'), import('waku/middleware/rsc'), import('waku/middleware/ssr'), ], diff --git a/examples/39_api/waku.config.ts b/examples/39_api/waku.config.ts index fd6aa031f..ef7abba9f 100644 --- a/examples/39_api/waku.config.ts +++ b/examples/39_api/waku.config.ts @@ -1,9 +1,9 @@ /** @type {import('waku/config').Config} */ export default { middleware: () => [ + import('waku/middleware/context'), import('./src/middleware/api.js'), import('waku/middleware/dev-server'), - import('waku/middleware/headers'), import('waku/middleware/rsc'), import('waku/middleware/ssr'), ], diff --git a/packages/waku/package.json b/packages/waku/package.json index 93046aa93..22866ef6a 100644 --- a/packages/waku/package.json +++ b/packages/waku/package.json @@ -42,6 +42,14 @@ "types": "./dist/client.d.ts", "default": "./dist/client.js" }, + "./minimal/server": { + "types": "./dist/minimal/server.d.ts", + "default": "./dist/minimal/server.js" + }, + "./minimal/client": { + "types": "./dist/minimal/client.d.ts", + "default": "./dist/minimal/client.js" + }, "./server": { "types": "./dist/server.d.ts", "default": "./dist/server.js" diff --git a/packages/waku/src/client.ts b/packages/waku/src/client.ts index 49c7f0c37..ba760b34a 100644 --- a/packages/waku/src/client.ts +++ b/packages/waku/src/client.ts @@ -1,321 +1,14 @@ -/// -'use client'; - import { - Component, - createContext, - createElement, - memo, - use, - useCallback, - useEffect, - useState, -} from 'react'; -import type { ReactNode } from 'react'; -import RSDWClient from 'react-server-dom-webpack/client'; - -import { encodeRscPath, encodeFuncId } from './lib/renderers/utils.js'; - -const { createFromFetch, encodeReply } = RSDWClient; - -declare global { - interface ImportMeta { - readonly env: Record; - } -} - -const BASE_PATH = `${import.meta.env?.WAKU_CONFIG_BASE_PATH}${ - import.meta.env?.WAKU_CONFIG_RSC_BASE -}/`; - -const checkStatus = async ( - responsePromise: Promise, -): Promise => { - const response = await responsePromise; - if (!response.ok) { - const err = new Error(response.statusText); - (err as any).statusCode = response.status; - throw err; - } - return response; -}; - -type Elements = Promise> & { - prev?: Record | undefined; -}; - -const getCached = (c: () => T, m: WeakMap, k: object): T => - (m.has(k) ? m : m.set(k, c())).get(k) as T; -const cache1 = new WeakMap(); -const mergeElements = (a: Elements, b: Elements): Elements => { - const getResult = () => { - const promise: Elements = new Promise((resolve, reject) => { - Promise.all([a, b]) - .then(([a, b]) => { - const nextElements = { ...a, ...b }; - delete nextElements._value; - promise.prev = a; - resolve(nextElements); - }) - .catch((e) => { - a.then( - (a) => { - promise.prev = a; - reject(e); - }, - () => { - promise.prev = a.prev; - reject(e); - }, - ); - }); - }); - return promise; - }; - const cache2 = getCached(() => new WeakMap(), cache1, a); - return getCached(getResult, cache2, b); -}; - -type SetElements = (updater: (prev: Elements) => Elements) => void; -type EnhanceCreateData = ( - createData: ( - responsePromise: Promise, - ) => Promise>, -) => (responsePromise: Promise) => Promise>; - -const ENTRY = 'e'; -const SET_ELEMENTS = 's'; -const ENHANCE_CREATE_DATA = 'd'; - -type FetchCache = { - [ENTRY]?: [rscPath: string, rscParams: unknown, elements: Elements]; - [SET_ELEMENTS]?: SetElements; - [ENHANCE_CREATE_DATA]?: EnhanceCreateData | undefined; -}; - -const defaultFetchCache: FetchCache = {}; - -/** - * callServer callback - * This is not a public API. - */ -export const callServerRsc = async ( - funcId: string, - args: unknown[], - fetchCache = defaultFetchCache, -) => { - const enhanceCreateData = fetchCache[ENHANCE_CREATE_DATA] || ((d) => d); - const createData = (responsePromise: Promise) => - createFromFetch>(checkStatus(responsePromise), { - callServer: (funcId: string, args: unknown[]) => - callServerRsc(funcId, args, fetchCache), - }); - const url = BASE_PATH + encodeRscPath(encodeFuncId(funcId)); - const responsePromise = - args.length === 1 && args[0] instanceof URLSearchParams - ? fetch(url + '?' + args[0]) - : encodeReply(args).then((body) => fetch(url, { method: 'POST', body })); - const data = enhanceCreateData(createData)(responsePromise); - // FIXME this causes rerenders even if data is empty - fetchCache[SET_ELEMENTS]?.((prev) => mergeElements(prev, data)); - return (await data)._value; -}; - -const prefetchedParams = new WeakMap, unknown>(); - -const fetchRscInternal = (url: string, rscParams: unknown) => - rscParams === undefined - ? fetch(url) - : rscParams instanceof URLSearchParams - ? fetch(url + '?' + rscParams) - : encodeReply(rscParams).then((body) => - fetch(url, { method: 'POST', body }), - ); - -export const fetchRsc = ( - rscPath: string, - rscParams?: unknown, - fetchCache = defaultFetchCache, -): Elements => { - const entry = fetchCache[ENTRY]; - if (entry && entry[0] === rscPath && entry[1] === rscParams) { - return entry[2]; - } - const enhanceCreateData = fetchCache[ENHANCE_CREATE_DATA] || ((d) => d); - const createData = (responsePromise: Promise) => - createFromFetch>(checkStatus(responsePromise), { - callServer: (funcId: string, args: unknown[]) => - callServerRsc(funcId, args, fetchCache), - }); - const prefetched = ((globalThis as any).__WAKU_PREFETCHED__ ||= {}); - const url = BASE_PATH + encodeRscPath(rscPath); - const hasValidPrefetchedResponse = - !!prefetched[url] && - // HACK .has() is for the initial hydration - // It's limited and may result in a wrong result. FIXME - (!prefetchedParams.has(prefetched[url]) || - prefetchedParams.get(prefetched[url]) === rscParams); - const responsePromise = hasValidPrefetchedResponse - ? prefetched[url] - : fetchRscInternal(url, rscParams); - delete prefetched[url]; - const data = enhanceCreateData(createData)(responsePromise); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - fetchCache[ENTRY] = [rscPath, rscParams, data]; - return data; -}; - -export const prefetchRsc = (rscPath: string, rscParams?: unknown): void => { - const prefetched = ((globalThis as any).__WAKU_PREFETCHED__ ||= {}); - const url = BASE_PATH + encodeRscPath(rscPath); - if (!(url in prefetched)) { - prefetched[url] = fetchRscInternal(url, rscParams); - prefetchedParams.set(prefetched[url], rscParams); - } -}; - -const RefetchContext = createContext< - (rscPath: string, rscParams?: unknown) => void ->(() => { - throw new Error('Missing Root component'); -}); -const ElementsContext = createContext(null); - -export const Root = ({ - initialRscPath, - initialRscParams, - fetchCache = defaultFetchCache, - unstable_enhanceCreateData, - children, -}: { - initialRscPath?: string; - initialRscParams?: unknown; - fetchCache?: FetchCache; - unstable_enhanceCreateData?: EnhanceCreateData; - children: ReactNode; -}) => { - fetchCache[ENHANCE_CREATE_DATA] = unstable_enhanceCreateData; - const [elements, setElements] = useState(() => - fetchRsc(initialRscPath || '', initialRscParams, fetchCache), - ); - useEffect(() => { - fetchCache[SET_ELEMENTS] = setElements; - }, [fetchCache, setElements]); - const refetch = useCallback( - (rscPath: string, rscParams?: unknown) => { - // clear cache entry before fetching - delete fetchCache[ENTRY]; - const data = fetchRsc(rscPath, rscParams, fetchCache); - setElements((prev) => mergeElements(prev, data)); - }, - [fetchCache], - ); - return createElement( - RefetchContext.Provider, - { value: refetch }, - createElement(ElementsContext.Provider, { value: elements }, children), - ); -}; - -export const useRefetch = () => use(RefetchContext); - -const ChildrenContext = createContext(undefined); -const ChildrenContextProvider = memo(ChildrenContext.Provider); - -type OuterSlotProps = { - elementsPromise: Elements; - shouldRenderPrev: ((err: unknown) => boolean) | undefined; - renderSlot: (elements: Record) => ReactNode; - children?: ReactNode; -}; - -class OuterSlot extends Component { - constructor(props: OuterSlotProps) { - super(props); - this.state = {}; - } - static getDerivedStateFromError(error: unknown) { - return { error }; - } - render() { - if ('error' in this.state) { - const e = this.state.error; - if (e instanceof Error && !('statusCode' in e)) { - // HACK we assume any error as Not Found, - // probably caused by history api fallback - (e as any).statusCode = 404; - } - if (this.props.shouldRenderPrev?.(e) && this.props.elementsPromise.prev) { - const elements = this.props.elementsPromise.prev; - return this.props.renderSlot(elements); - } else { - throw e; - } - } - return this.props.children; - } -} - -const InnerSlot = ({ - elementsPromise, - renderSlot, -}: { - elementsPromise: Elements; - renderSlot: (elements: Record) => ReactNode; -}) => { - const elements = use(elementsPromise); - return renderSlot(elements); -}; - -export const Slot = ({ - id, - children, - fallback, - unstable_shouldRenderPrev, -}: { - id: string; - children?: ReactNode; - fallback?: ReactNode; - unstable_shouldRenderPrev?: (err: unknown) => boolean; -}) => { - const elementsPromise = use(ElementsContext); - if (!elementsPromise) { - throw new Error('Missing Root component'); - } - const renderSlot = (elements: Record) => { - if (!(id in elements)) { - if (fallback) { - return fallback; - } - throw new Error('Not found: ' + id); - } - return createElement( - ChildrenContextProvider, - { value: children }, - elements[id], - ); - }; - return createElement( - OuterSlot, - { - elementsPromise, - shouldRenderPrev: unstable_shouldRenderPrev, - renderSlot, - }, - createElement(InnerSlot, { elementsPromise, renderSlot }), - ); -}; - -export const Children = () => use(ChildrenContext); - -/** - * ServerRoot for SSR - * This is not a public API. - */ -export const ServerRoot = ({ - elements, - children, -}: { - elements: Elements; - children: ReactNode; -}) => createElement(ElementsContext.Provider, { value: elements }, children); + Children as ChildrenOrig, + Slot as SlotOrig, + Root as RootOrig, + ServerRootInternal as ServerRootOrig, +} from './minimal/client.js'; +/** @deprecated */ +export const Children = ChildrenOrig; +/** @deprecated */ +export const Slot = SlotOrig; +/** @deprecated */ +export const Root = RootOrig; +/** @deprecated */ +export const ServerRoot = ServerRootOrig; diff --git a/packages/waku/src/config.ts b/packages/waku/src/config.ts index b52820ffe..c84efad27 100644 --- a/packages/waku/src/config.ts +++ b/packages/waku/src/config.ts @@ -41,8 +41,8 @@ export interface Config { * Middleware to use * Defaults to: * () => [ + * import('waku/middleware/context'), * import('waku/middleware/dev-server'), - * import('waku/middleware/headers'), * import('waku/middleware/rsc'), * import('waku/middleware/ssr'), * ] diff --git a/packages/waku/src/lib/builder/build.ts b/packages/waku/src/lib/builder/build.ts index 361b8a3ca..f362b9705 100644 --- a/packages/waku/src/lib/builder/build.ts +++ b/packages/waku/src/lib/builder/build.ts @@ -4,13 +4,20 @@ import { pipeline } from 'node:stream/promises'; import { build as buildVite, resolveConfig as resolveViteConfig } from 'vite'; import viteReact from '@vitejs/plugin-react'; import type { LoggingFunction, RollupLog } from 'rollup'; +import type { ReactNode } from 'react'; import type { Config } from '../../config.js'; import { unstable_getPlatformObject } from '../../server.js'; -import type { BuildConfig, EntriesPrd } from '../../server.js'; +import type { + BuildConfig, + EntriesPrd, + EntriesDev, + new_defineEntries, +} from '../../minimal/server.js'; import type { ResolvedConfig } from '../config.js'; import { resolveConfig } from '../config.js'; import { EXTENSIONS } from '../constants.js'; +import { stringToStream } from '../utils/stream.js'; import type { PathSpec } from '../utils/path.js'; import { decodeFilePathFromAbsolute, @@ -32,6 +39,11 @@ import { writeFile, } from '../utils/node-fs.js'; import { encodeRscPath, generatePrefetchCode } from '../renderers/utils.js'; +import { + collectClientModules, + renderRsc as renderRscNew, +} from '../renderers/rsc.js'; +import { renderHtml as renderHtmlNew } from '../renderers/html.js'; import { getBuildConfig, getSsrConfig, @@ -97,7 +109,13 @@ const analyzeEntries = async (rootDir: string, config: ResolvedConfig) => { const wakuClientDist = decodeFilePathFromAbsolute( joinPath(fileURLToFilePath(import.meta.url), '../../../client.js'), ); - const clientFileSet = new Set([wakuClientDist]); + const wakuMinimalClientDist = decodeFilePathFromAbsolute( + joinPath(fileURLToFilePath(import.meta.url), '../../../minimal/client.js'), + ); + const clientFileSet = new Set([ + wakuClientDist, + wakuMinimalClientDist, + ]); const serverFileSet = new Set(); const fileHashMap = new Map(); const moduleFileMap = new Map(); // module id -> full path @@ -148,7 +166,7 @@ const analyzeEntries = async (rootDir: string, config: ResolvedConfig) => { }); const clientEntryFiles = Object.fromEntries( Array.from(clientFileSet).map((fname, i) => [ - `${DIST_ASSETS}/rsc${i}-${fileHashMap.get(fname)}`, + `${DIST_ASSETS}/rsc${i}-${fileHashMap.get(fname) || 'lib'}`, // FIXME 'lib' is a workaround to avoid `undefined` fname, ]), ); @@ -640,6 +658,251 @@ export const publicIndexHtml = ${JSON.stringify(publicIndexHtml)}; await appendFile(distEntriesFile, code); }; +// FIXME this is too hacky +const willEmitPublicIndexHtmlNew = async ( + distEntries: Omit & { + default: ReturnType; + }, + buildConfig: BuildConfig, +) => { + const hasConfig = buildConfig.some(({ pathname }) => { + const pathSpec = + typeof pathname === 'string' ? pathname2pathSpec(pathname) : pathname; + return !!getPathMapping(pathSpec, '/'); + }); + if (!hasConfig) { + return false; + } + const utils = { + renderRsc: () => { + throw new Error('Cannot render RSC in HTML build'); + }, + renderHtml: () => { + const headers = { 'content-type': 'text/html; charset=utf-8' }; + return { + body: stringToStream('DUMMY'), // HACK this might not work in an edge case + headers, + }; + }, + }; + const input = { + type: 'custom', + pathname: '/', + req: { + body: null, + url: new URL('http://localhost/'), + method: 'GET', + headers: {}, + }, + } as const; + const res = await distEntries.default.unstable_handleRequest(input, utils); + return !!res; +}; + +// TODO too long... we need to refactor and organize this function +const emitStaticFiles = async ( + rootDir: string, + config: ResolvedConfig, + distEntriesFile: string, + distEntries: Omit & { + default: ReturnType; + }, + buildConfig: BuildConfig, + cssAssets: string[], +) => { + const unstable_modules = { + rsdwServer: await distEntries.loadModule('rsdw-server'), + rdServer: await distEntries.loadModule(CLIENT_PREFIX + 'rd-server'), + rsdwClient: await distEntries.loadModule(CLIENT_PREFIX + 'rsdw-client'), + wakuMinimalClient: await distEntries.loadModule( + CLIENT_PREFIX + 'waku-minimal-client', + ), + }; + const basePrefix = config.basePath + config.rscBase + '/'; + const publicIndexHtmlFile = joinPath( + rootDir, + config.distDir, + DIST_PUBLIC, + 'index.html', + ); + const publicIndexHtml = await readFile(publicIndexHtmlFile, { + encoding: 'utf8', + }); + if (await willEmitPublicIndexHtmlNew(distEntries, buildConfig)) { + await unlink(publicIndexHtmlFile); + } + const publicIndexHtmlHead = publicIndexHtml.replace( + /.*?(.*?)<\/head>.*/s, + '$1', + ); + const dynamicHtmlPathMap = new Map(); + await Promise.all( + Array.from(buildConfig).map( + async ({ pathname, 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), + ); + } + 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(); + 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 += ``; + } + 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 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 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 htmlReadable = res instanceof ReadableStream ? res : res?.body; + await mkdir(joinPath(destHtmlFile, '..'), { recursive: true }); + if (htmlReadable) { + await pipeline( + Readable.fromWeb(htmlReadable as never), + createWriteStream(destHtmlFile), + ); + } else { + await writeFile(destHtmlFile, htmlStr); + } + }, + ), + ); + const dynamicHtmlPaths = Array.from(dynamicHtmlPathMap); + const code = ` +export const dynamicHtmlPaths = ${JSON.stringify(dynamicHtmlPaths)}; +export const publicIndexHtml = ${JSON.stringify(publicIndexHtml)}; +`; + await appendFile(distEntriesFile, code); +}; + // For Deploy // FIXME Is this a good approach? I wonder if there's something missing. const buildDeploy = async (rootDir: string, config: ResolvedConfig) => { @@ -750,27 +1013,49 @@ export async function build(options: { const distEntries = await import(filePathToFileURL(distEntriesFile)); // TODO: Add progress indication for static builds. - const buildConfig = await getBuildConfig( - { env, config }, - { entries: distEntries }, - ); - const { getClientModules } = await emitRscFiles( - rootDir, - env, - config, - distEntries, - buildConfig, - ); - await emitHtmlFiles( - rootDir, - env, - config, - distEntriesFile, - distEntries, - buildConfig, - getClientModules, - clientBuildOutput, - ); + if ('unstable_handleRequest' in distEntries.default) { + const buildConfig = await distEntries.default.unstable_getBuildConfig({ + unstable_collectClientModules: (elements: never) => + collectClientModules( + config, + distEntries.loadModule('rsdw-server'), // FIXME hard-coded id + elements, + ), + }); + const cssAssets = clientBuildOutput.output.flatMap(({ type, fileName }) => + type === 'asset' && fileName.endsWith('.css') ? [fileName] : [], + ); + await emitStaticFiles( + rootDir, + config, + distEntriesFile, + distEntries, + buildConfig, + cssAssets, + ); + } else { + const buildConfig = await getBuildConfig( + { env, config }, + { entries: distEntries }, + ); + const { getClientModules } = await emitRscFiles( + rootDir, + env, + config, + distEntries, + buildConfig, + ); + await emitHtmlFiles( + rootDir, + env, + config, + distEntriesFile, + distEntries, + buildConfig, + getClientModules, + clientBuildOutput, + ); + } platformObject.buildOptions.unstable_phase = 'buildDeploy'; await buildDeploy(rootDir, config); diff --git a/packages/waku/src/lib/config.ts b/packages/waku/src/lib/config.ts index 401173d8f..1dfc11ad2 100644 --- a/packages/waku/src/lib/config.ts +++ b/packages/waku/src/lib/config.ts @@ -8,9 +8,14 @@ type DeepRequired = T extends (...args: any[]) => any export type ResolvedConfig = DeepRequired; +export type PureConfig = Omit< + DeepRequired, + 'middleware' | 'unstable_honoEnhancer' +>; + const DEFAULT_MIDDLEWARE = () => [ + import('waku/middleware/context'), import('waku/middleware/dev-server'), - import('waku/middleware/headers'), import('waku/middleware/rsc'), import('waku/middleware/ssr'), ]; diff --git a/packages/waku/src/lib/hono/ctx.ts b/packages/waku/src/lib/hono/ctx.ts index a71fa1733..5148ec8b6 100644 --- a/packages/waku/src/lib/hono/ctx.ts +++ b/packages/waku/src/lib/hono/ctx.ts @@ -1,12 +1,13 @@ import type { Context, Env } from 'hono'; -import { unstable_getCustomContext } from '../../server.js'; +// This can't be relative import +import { getContext } from 'waku/middleware/context'; // Internal context key const HONO_CONTEXT = '__hono_context'; export const getHonoContext = () => { - const c = unstable_getCustomContext()[HONO_CONTEXT]; + const c = getContext().data[HONO_CONTEXT]; if (!c) { throw new Error('Hono context is not available'); } diff --git a/packages/waku/src/lib/hono/engine.ts b/packages/waku/src/lib/hono/engine.ts index 71e0fa6a5..cdb3740b6 100644 --- a/packages/waku/src/lib/hono/engine.ts +++ b/packages/waku/src/lib/hono/engine.ts @@ -6,13 +6,6 @@ import type { HandlerContext, MiddlewareOptions } from '../middleware/types.js'; // Internal context key const HONO_CONTEXT = '__hono_context'; -const createEmptyReadableStream = () => - new ReadableStream({ - start(controller) { - controller.close(); - }, - }); - // serverEngine returns hono middleware that runs Waku middleware. export const serverEngine = (options: MiddlewareOptions): MiddlewareHandler => { const entriesPromise = @@ -35,7 +28,7 @@ export const serverEngine = (options: MiddlewareOptions): MiddlewareHandler => { return async (c, next) => { const ctx: HandlerContext = { req: { - body: c.req.raw.body || createEmptyReadableStream(), + body: c.req.raw.body, url: new URL(c.req.url), method: c.req.method, headers: c.req.header(), @@ -44,6 +37,9 @@ export const serverEngine = (options: MiddlewareOptions): MiddlewareHandler => { context: { [HONO_CONTEXT]: c, }, + data: { + [HONO_CONTEXT]: c, + }, }; const handlers = await handlersPromise; const run = async (index: number) => { diff --git a/packages/waku/src/lib/middleware/context.ts b/packages/waku/src/lib/middleware/context.ts new file mode 100644 index 000000000..a155ed55f --- /dev/null +++ b/packages/waku/src/lib/middleware/context.ts @@ -0,0 +1,53 @@ +import type { AsyncLocalStorage as AsyncLocalStorageType } from 'node:async_hooks'; + +import type { HandlerReq, Middleware } from './types.js'; + +type Context = { + readonly req: HandlerReq; + readonly data: Record; +}; + +let contextStorage: AsyncLocalStorageType | undefined; + +try { + const { AsyncLocalStorage } = await import('node:async_hooks'); + contextStorage = new AsyncLocalStorage(); +} catch { + console.warn('AsyncLocalStorage is not available'); +} + +let previousContext: Context | undefined; +let currentContext: Context | undefined; + +const runWithContext = (context: Context, fn: () => T): T => { + if (contextStorage) { + return contextStorage.run(context, fn); + } + previousContext = currentContext; + currentContext = context; + try { + return fn(); + } finally { + currentContext = previousContext; + } +}; + +export const context: Middleware = () => { + return async (ctx, next) => { + const context: Context = { + req: ctx.req, + data: ctx.data, + }; + return runWithContext(context, next); + }; +}; + +export function getContext() { + const context = contextStorage?.getStore() ?? currentContext; + if (!context) { + throw new Error( + 'Context is not available. Make sure to use the context middleware.', + ); + } + return context; +} diff --git a/packages/waku/src/lib/middleware/dev-server-impl.ts b/packages/waku/src/lib/middleware/dev-server-impl.ts index b2863882f..7ce7d1393 100644 --- a/packages/waku/src/lib/middleware/dev-server-impl.ts +++ b/packages/waku/src/lib/middleware/dev-server-impl.ts @@ -4,7 +4,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { createServer as createViteServer } from 'vite'; import viteReact from '@vitejs/plugin-react'; -import type { EntriesDev } from '../../server.js'; +import type { EntriesDev } from '../../minimal/server.js'; import { resolveConfig } from '../config.js'; import { SRC_MAIN, SRC_ENTRIES } from '../constants.js'; import { @@ -247,6 +247,7 @@ const createRscViteServer = ( conditions: ['react-server'], externalConditions: ['react-server'], }, + external: ['waku/middleware/context'], noExternal: /^(?!node:)/, optimizeDeps: { include: [ @@ -336,10 +337,10 @@ export const devServer: Middleware = (options) => { let initialModules: ClonableModuleNode[]; return async (ctx, next) => { - const [{ middleware: _removed, ...config }, vite] = await Promise.all([ - configPromise, - vitePromise, - ]); + const [ + { middleware: _removed1, unstable_honoEnhancer: _removed2, ...config }, + vite, + ] = await Promise.all([configPromise, vitePromise]); if (!initialModules) { const processedModules = new Set(); @@ -411,7 +412,9 @@ export const devServer: Middleware = (options) => { } const viteUrl = ctx.req.url.toString().slice(ctx.req.url.origin.length); - const viteReq: any = Readable.fromWeb(ctx.req.body as any); + const viteReq: any = ctx.req.body + ? Readable.fromWeb(ctx.req.body as never) + : Readable.from([]); viteReq.method = ctx.req.method; viteReq.url = viteUrl; viteReq.headers = ctx.req.headers; diff --git a/packages/waku/src/lib/middleware/fallback.ts b/packages/waku/src/lib/middleware/fallback.ts index 797a4b0e6..ee7e86c3c 100644 --- a/packages/waku/src/lib/middleware/fallback.ts +++ b/packages/waku/src/lib/middleware/fallback.ts @@ -8,7 +8,8 @@ export const fallback: Middleware = (options) => { return async (ctx, next) => { if (!ctx.res.body) { const config = await configPromise; - ctx.req.url = new URL(config.basePath, ctx.req.url); + const newUrl = new URL(config.basePath, ctx.req.url); + ctx.req.url.pathname = newUrl.pathname; } return next(); }; diff --git a/packages/waku/src/lib/middleware/handler.ts b/packages/waku/src/lib/middleware/handler.ts new file mode 100644 index 000000000..44f57d603 --- /dev/null +++ b/packages/waku/src/lib/middleware/handler.ts @@ -0,0 +1,170 @@ +import type { ReactNode } from 'react'; + +import { resolveConfig } from '../config.js'; +import type { PureConfig } from '../config.js'; +import { setAllEnvInternal } from '../../server.js'; +import type { Middleware, HandlerContext } from './types.js'; +import type { new_defineEntries } from '../../minimal/server.js'; +import { renderRsc, decodeBody } from '../renderers/rsc.js'; +import { renderHtml } from '../renderers/html.js'; +import { decodeRscPath, decodeFuncId } from '../renderers/utils.js'; +import { filePathToFileURL, getPathMapping } from '../utils/path.js'; + +type HandleRequest = Parameters< + typeof new_defineEntries +>[0]['unstable_handleRequest']; + +// TODO avoid copy-pasting +const SERVER_MODULE_MAP = { + 'rsdw-server': 'react-server-dom-webpack/server.edge', +} as const; +const CLIENT_MODULE_MAP = { + 'rd-server': 'react-dom/server.edge', + 'rsdw-client': 'react-server-dom-webpack/client.edge', + 'waku-minimal-client': 'waku/minimal/client', +} as const; +const CLIENT_PREFIX = 'client/'; + +const getInput = async ( + config: PureConfig, + ctx: HandlerContext, + loadServerModule: (fileId: string) => Promise, +): Promise[0] | null> => { + if (!ctx.req.url.pathname.startsWith(config.basePath)) { + return null; + } + const basePrefix = config.basePath + config.rscBase + '/'; + if (ctx.req.url.pathname.startsWith(basePrefix)) { + const rscPath = decodeRscPath( + decodeURI(ctx.req.url.pathname.slice(basePrefix.length)), + ); + const decodedBody = await decodeBody(ctx); + const funcId = decodeFuncId(rscPath); + if (funcId) { + const args = Array.isArray(decodedBody) + ? decodedBody + : decodedBody instanceof URLSearchParams + ? [decodedBody] + : []; + const [fileId, name] = funcId.split('#') as [string, string]; + const mod: any = await loadServerModule(fileId); + return { type: 'function', fn: mod[name], args, req: ctx.req }; + } + return { type: 'component', rscPath, rscParams: decodedBody, req: ctx.req }; + } + return { + type: 'custom', + pathname: '/' + ctx.req.url.pathname.slice(config.basePath.length), + req: ctx.req, + }; +}; + +export const handler: Middleware = (options) => { + const env = options.env || {}; + setAllEnvInternal(env); + const entriesPromise = + options.cmd === 'start' + ? options.loadEntries() + : ('Error: loadEntries are not available' as never); + const configPromise = + options.cmd === 'start' + ? entriesPromise.then((entries) => + entries.loadConfig().then((config) => resolveConfig(config)), + ) + : resolveConfig(options.config); + + return async (ctx, next) => { + const { unstable_devServer: devServer } = ctx; + const [ + { middleware: _removed1, unstable_honoEnhancer: _removed2, ...config }, + entriesPrd, + ] = await Promise.all([configPromise, entriesPromise]); + const entriesDev = devServer && (await devServer.loadEntriesDev(config)); + const entries: { default: object } = devServer ? entriesDev! : entriesPrd; + const rsdwServer = devServer + ? await devServer.loadServerModuleRsc(SERVER_MODULE_MAP['rsdw-server']) + : await entriesPrd.loadModule('rsdw-server'); + const rdServer = devServer + ? await devServer.loadServerModuleMain(CLIENT_MODULE_MAP['rd-server']) + : await entriesPrd.loadModule(CLIENT_PREFIX + 'rd-server'); + const rsdwClient = devServer + ? await devServer.loadServerModuleMain(CLIENT_MODULE_MAP['rsdw-client']) + : await entriesPrd.loadModule(CLIENT_PREFIX + 'rsdw-client'); + const wakuMinimalClient = devServer + ? await devServer.loadServerModuleMain( + CLIENT_MODULE_MAP['waku-minimal-client'], + ) + : await entriesPrd.loadModule(CLIENT_PREFIX + 'waku-minimal-client'); + ctx.unstable_modules = { + rsdwServer, + rdServer, + rsdwClient, + wakuMinimalClient, + }; + const loadServerModule = (fileId: string) => { + if (devServer) { + return devServer.loadServerModuleRsc(filePathToFileURL(fileId)); + } else { + return entriesPrd.loadModule(fileId + '.js'); + } + }; + const htmlHead = + (!devServer && + entriesPrd.dynamicHtmlPaths.find(([pathSpec]) => + getPathMapping(pathSpec, ctx.req.url.pathname), + )?.[1]) || + ''; + const transformIndexHtml = + devServer && (await devServer.transformIndexHtml(ctx.req.url.pathname)); + const utils = { + renderRsc: (elements: Record) => + renderRsc(config, ctx, elements), + renderHtml: ( + elements: Record, + html: ReactNode, + rscPath: string, + ) => { + const readable = renderHtml( + config, + ctx, + htmlHead, + elements, + html, + rscPath, + ); + const headers = { 'content-type': 'text/html; charset=utf-8' }; + return { + body: transformIndexHtml + ? readable.pipeThrough(transformIndexHtml) + : readable, + headers, + }; + }, + }; + if ('unstable_handleRequest' in entries.default) { + const input = await getInput(config, ctx, loadServerModule); + if (input) { + const res = await ( + entries.default.unstable_handleRequest as HandleRequest + )(input, utils); + if (res instanceof ReadableStream) { + ctx.res.body = res; + } else if (res) { + if (res.body) { + ctx.res.body = res.body; + } + if (res.status) { + ctx.res.status = res.status; + } + if (res.headers) { + Object.assign((ctx.res.headers ||= {}), res.headers); + } + } + if (ctx.res.body || ctx.res.status) { + return; + } + } + } + await next(); + }; +}; diff --git a/packages/waku/src/lib/middleware/headers.ts b/packages/waku/src/lib/middleware/headers.ts deleted file mode 100644 index 72230c568..000000000 --- a/packages/waku/src/lib/middleware/headers.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { Middleware } from './types.js'; - -export const REQUEST_HEADERS = '__waku_requestHeaders'; - -export const headers: Middleware = () => { - return async (ctx, next) => { - ctx.context[REQUEST_HEADERS] = ctx.req.headers; - await next(); - }; -}; diff --git a/packages/waku/src/lib/middleware/rsc.ts b/packages/waku/src/lib/middleware/rsc.ts index ee92e860d..bcb275784 100644 --- a/packages/waku/src/lib/middleware/rsc.ts +++ b/packages/waku/src/lib/middleware/rsc.ts @@ -19,10 +19,10 @@ export const rsc: Middleware = (options) => { : resolveConfig(options.config); return async (ctx, next) => { - const [{ middleware: _removed, ...config }, entries] = await Promise.all([ - configPromise, - entriesPromise, - ]); + const [ + { middleware: _removed1, unstable_honoEnhancer: _removed2, ...config }, + entries, + ] = await Promise.all([configPromise, entriesPromise]); const basePrefix = config.basePath + config.rscBase + '/'; if (ctx.req.url.pathname.startsWith(basePrefix)) { const { headers } = ctx.req; diff --git a/packages/waku/src/lib/middleware/ssr.ts b/packages/waku/src/lib/middleware/ssr.ts index 06e290956..09179dfe3 100644 --- a/packages/waku/src/lib/middleware/ssr.ts +++ b/packages/waku/src/lib/middleware/ssr.ts @@ -22,10 +22,10 @@ export const ssr: Middleware = (options) => { return async (ctx, next) => { const { unstable_devServer: devServer } = ctx; - const [{ middleware: _removed, ...config }, entries] = await Promise.all([ - configPromise, - entriesPromise, - ]); + const [ + { middleware: _removed1, unstable_honoEnhancer: _removed2, ...config }, + entries, + ] = await Promise.all([configPromise, entriesPromise]); const entriesDev = devServer && (await devServer.loadEntriesDev(config)); try { const htmlHead = devServer diff --git a/packages/waku/src/lib/middleware/types.ts b/packages/waku/src/lib/middleware/types.ts index e09c4663b..97c391966 100644 --- a/packages/waku/src/lib/middleware/types.ts +++ b/packages/waku/src/lib/middleware/types.ts @@ -1,13 +1,15 @@ import type { Config } from '../../config.js'; -import type { EntriesDev, EntriesPrd } from '../../server.js'; + +// TODO should we move this to somewhere else? +import type { EntriesDev, EntriesPrd } from '../../minimal/server.js'; export type ClonableModuleNode = { url: string; file: string }; export type HandlerReq = { - body: ReadableStream; - url: URL; - method: string; - headers: Record; + readonly body: ReadableStream | null; + readonly url: URL; + readonly method: string; + readonly headers: Readonly>; }; export type HandlerRes = { @@ -19,7 +21,9 @@ export type HandlerRes = { export type HandlerContext = { readonly req: HandlerReq; readonly res: HandlerRes; + /** @deprecated use `data` */ readonly context: Record; + readonly data: Record; unstable_devServer?: { rootDir: string; resolveClientEntry: (id: string) => string; @@ -30,6 +34,12 @@ export type HandlerContext = { pathname: string, ) => Promise>; }; + unstable_modules?: { + rsdwServer: unknown; + rdServer: unknown; + rsdwClient: unknown; + wakuMinimalClient: unknown; + }; }; export type Handler = ( 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 fdde76a48..0f84d4e4e 100644 --- a/packages/waku/src/lib/plugins/vite-plugin-rsc-transform.ts +++ b/packages/waku/src/lib/plugins/vite-plugin-rsc-transform.ts @@ -60,7 +60,7 @@ const transformClient = ( const exportNames = collectExportNames(mod); let newCode = ` import { createServerReference } from 'react-server-dom-webpack/client'; -import { callServerRsc } from 'waku/client'; +import { callServerRsc } from 'waku/minimal/client'; `; for (const name of exportNames) { newCode += ` diff --git a/packages/waku/src/lib/renderers/html-renderer.ts b/packages/waku/src/lib/renderers/html-renderer.ts index b2cf077b0..94a6f75d6 100644 --- a/packages/waku/src/lib/renderers/html-renderer.ts +++ b/packages/waku/src/lib/renderers/html-renderer.ts @@ -5,9 +5,9 @@ import type { default as RSDWClientType } from 'react-server-dom-webpack/client. import { injectRSCPayload } from 'rsc-html-stream/server'; import type * as WakuClientType from '../../client.js'; -import type { EntriesPrd } from '../../server.js'; +import type { EntriesPrd } from '../../minimal/server.js'; import { SRC_MAIN } from '../constants.js'; -import type { ResolvedConfig } from '../config.js'; +import type { PureConfig } from '../config.js'; import { concatUint8Arrays } from '../utils/stream.js'; import { joinPath, @@ -24,6 +24,7 @@ export const CLIENT_MODULE_MAP = { 'rd-server': 'react-dom/server.edge', 'rsdw-client': 'react-server-dom-webpack/client.edge', 'waku-client': 'waku/client', + 'waku-minimal-client': 'waku/minimal/client', } as const; export const CLIENT_PREFIX = 'client/'; @@ -168,7 +169,7 @@ const rectifyHtml = () => { export const renderHtml = async ( opts: { - config: Omit; + config: PureConfig; pathname: string; searchParams: URLSearchParams; htmlHead: string; diff --git a/packages/waku/src/lib/renderers/html.ts b/packages/waku/src/lib/renderers/html.ts new file mode 100644 index 000000000..5a3560a98 --- /dev/null +++ b/packages/waku/src/lib/renderers/html.ts @@ -0,0 +1,269 @@ +import { createElement } from 'react'; +import type { ReactNode, FunctionComponent, ComponentProps } from 'react'; +import type * as RDServerType from 'react-dom/server.edge'; +import type { default as RSDWClientType } from 'react-server-dom-webpack/client.edge'; +import { injectRSCPayload } from 'rsc-html-stream/server'; + +import type * as WakuMinimalClientType from '../../minimal/client.js'; +import type { PureConfig } from '../config.js'; +import { SRC_MAIN } from '../constants.js'; +import { concatUint8Arrays, streamFromPromise } from '../utils/stream.js'; +import { + joinPath, + filePathToFileURL, + fileURLToFilePath, + encodeFilePathToAbsolute, +} from '../utils/path.js'; +import { encodeRscPath } from './utils.js'; +import { renderRsc, renderRscElement } from './rsc.js'; +// TODO move types somewhere +import type { HandlerContext } from '../middleware/types.js'; + +// HACK depending on these constants is not ideal +import { DEFAULT_HTML_HEAD } from '../plugins/vite-plugin-rsc-index.js'; + +type Elements = Record; + +const fakeFetchCode = ` +Promise.resolve(new Response(new ReadableStream({ + start(c) { + const d = (self.__FLIGHT_DATA ||= []); + const t = new TextEncoder(); + const f = (s) => c.enqueue(typeof s === 'string' ? t.encode(s) : s); + d.forEach(f); + d.push = f; + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => c.close()); + } else { + c.close(); + } + } +}))) +` + .split('\n') + .map((line) => line.trim()) + .join(''); + +const CLOSING_HEAD = ''; +const CLOSING_BODY = ''; + +const injectHtmlHead = ( + urlForFakeFetch: string, + htmlHead: string, + mainJsPath: string, // for DEV only, pass `''` for PRD +) => { + const modifyHeadAndBody = (data: string) => { + const closingHeadIndex = data.indexOf(CLOSING_HEAD); + let [head, body] = + closingHeadIndex === -1 + ? ['' + CLOSING_HEAD, data] + : [ + data.slice(0, closingHeadIndex + CLOSING_HEAD.length), + data.slice(closingHeadIndex + CLOSING_HEAD.length), + ]; + head = + head.slice(0, -CLOSING_HEAD.length) + + DEFAULT_HTML_HEAD + + htmlHead + + CLOSING_HEAD; + const matchPrefetched = head.match( + // HACK This is very brittle + /(.*]*>\nglobalThis\.__WAKU_PREFETCHED__ = {\n)(.*?)(\n};.*)/s, + ); + if (matchPrefetched) { + head = + matchPrefetched[1] + + ` '${urlForFakeFetch}': ${fakeFetchCode},` + + matchPrefetched[3]; + } + let code = ` +globalThis.__WAKU_HYDRATE__ = true; +`; + if (!matchPrefetched) { + code += ` +globalThis.__WAKU_PREFETCHED__ = { + '${urlForFakeFetch}': ${fakeFetchCode}, +}; +`; + } + if (code) { + head = + head.slice(0, -CLOSING_HEAD.length) + + `` + + CLOSING_HEAD; + } + if (mainJsPath) { + const closingBodyIndex = body.indexOf(CLOSING_BODY); + const [firstPart, secondPart] = + closingBodyIndex === -1 + ? [body, ''] + : [body.slice(0, closingBodyIndex), body.slice(closingBodyIndex)]; + body = + firstPart + + `` + + secondPart; + } + return head + body; + }; + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + let headSent = false; + let data = ''; + return new TransformStream({ + transform(chunk, controller) { + if (!(chunk instanceof Uint8Array)) { + throw new Error('Unknown chunk type'); + } + data += decoder.decode(chunk); + if (!headSent) { + if (!/]*>/.test(data)) { + return; + } + headSent = true; + data = modifyHeadAndBody(data); + } + controller.enqueue(encoder.encode(data)); + data = ''; + }, + flush(controller) { + if (!headSent) { + headSent = true; + data = modifyHeadAndBody(data); + controller.enqueue(encoder.encode(data)); + data = ''; + } + }, + }); +}; + +// HACK for now, do we want to use HTML parser? +const rectifyHtml = () => { + const pending: Uint8Array[] = []; + const decoder = new TextDecoder(); + let timer: ReturnType | undefined; + return new TransformStream({ + transform(chunk, controller) { + if (!(chunk instanceof Uint8Array)) { + throw new Error('Unknown chunk type'); + } + pending.push(chunk); + if (/<\/\w+>$/.test(decoder.decode(chunk))) { + clearTimeout(timer); + timer = setTimeout(() => { + controller.enqueue(concatUint8Arrays(pending.splice(0))); + }); + } + }, + flush(controller) { + clearTimeout(timer); + if (pending.length) { + controller.enqueue(concatUint8Arrays(pending.splice(0))); + } + }, + }); +}; + +export function renderHtml( + config: PureConfig, + ctx: Pick, + htmlHead: string, + elements: Elements, + html: ReactNode, + rscPath: string, +): ReadableStream { + const modules = ctx.unstable_modules; + if (!modules) { + throw new Error('handler middleware required (missing modules)'); + } + const { + default: { renderToReadableStream }, + } = modules.rdServer as { default: typeof RDServerType }; + const { + default: { createFromReadableStream }, + } = modules.rsdwClient as { default: typeof RSDWClientType }; + const { ServerRootInternal: ServerRoot } = + modules.wakuMinimalClient as typeof WakuMinimalClientType; + + const stream = renderRsc(config, ctx, elements); + const htmlStream = renderRscElement(config, ctx, html); + const isDev = !!ctx.unstable_devServer; + const rootDir = ctx.unstable_devServer?.rootDir || ''; + const moduleMap = new Proxy( + {} as Record>, + { + get(_target, filePath: string) { + return new Proxy( + {}, + { + get(_target, name: string) { + if (isDev) { + // TODO too long, we need to refactor this logic + let file = filePath + .slice(config.basePath.length) + .split('?')[0]!; + const isFsPath = file.startsWith('@fs/'); + file = isFsPath ? file.slice('@fs'.length) : file; + const fileWithAbsolutePath = isFsPath + ? file + : encodeFilePathToAbsolute(joinPath(rootDir, file)); + const wakuDist = joinPath( + fileURLToFilePath(import.meta.url), + '../../..', + ); + if (fileWithAbsolutePath.startsWith(wakuDist)) { + const id = + 'waku' + + fileWithAbsolutePath + .slice(wakuDist.length) + .replace(/\.\w+$/, ''); + (globalThis as any).__WAKU_CLIENT_CHUNK_LOAD__(id); + return { id, chunks: [id], name }; + } + const id = filePathToFileURL(file); + (globalThis as any).__WAKU_CLIENT_CHUNK_LOAD__(id); + return { id, chunks: [id], name }; + } + // !isDev + const id = filePath.slice(config.basePath.length); + (globalThis as any).__WAKU_CLIENT_CHUNK_LOAD__(id); + return { id, chunks: [id], name }; + }, + }, + ); + }, + }, + ); + const [stream1, stream2] = stream.tee(); + const elementsPromise: Promise = createFromReadableStream(stream1, { + ssrManifest: { moduleMap, moduleLoading: null }, + }); + const htmlNode: Promise = createFromReadableStream(htmlStream, { + ssrManifest: { moduleMap, moduleLoading: null }, + }); + const readable = streamFromPromise( + renderToReadableStream( + createElement( + ServerRoot as FunctionComponent< + Omit, 'children'> + >, + { elements: elementsPromise }, + htmlNode as any, + ), + { + onError(err: unknown) { + console.error(err); + }, + }, + ), + ) + .pipeThrough(rectifyHtml()) + .pipeThrough( + injectHtmlHead( + config.basePath + config.rscBase + '/' + encodeRscPath(rscPath), + htmlHead, + isDev ? `${config.basePath}${config.srcDir}/${SRC_MAIN}` : '', + ), + ) + .pipeThrough(injectRSCPayload(stream2)); + return readable; +} diff --git a/packages/waku/src/lib/renderers/rsc-renderer.ts b/packages/waku/src/lib/renderers/rsc-renderer.ts index e9aac4e97..45ca31495 100644 --- a/packages/waku/src/lib/renderers/rsc-renderer.ts +++ b/packages/waku/src/lib/renderers/rsc-renderer.ts @@ -3,12 +3,11 @@ import type { default as RSDWServerType } from 'react-server-dom-webpack/server. import { unstable_getPlatformObject } from '../../server.js'; import type { - EntriesDev, - EntriesPrd, setAllEnvInternal as setAllEnvInternalType, runWithRenderStoreInternal as runWithRenderStoreInternalType, } from '../../server.js'; -import type { ResolvedConfig } from '../config.js'; +import type { EntriesDev, EntriesPrd } from '../../minimal/server.js'; +import type { PureConfig } from '../config.js'; import { filePathToFileURL } from '../utils/path.js'; import { streamToArrayBuffer } from '../utils/stream.js'; import { decodeFuncId } from '../renderers/utils.js'; @@ -25,12 +24,12 @@ const resolveClientEntryForPrd = (id: string, config: { basePath: string }) => { export type RenderRscArgs = { env: Record; - config: Omit; + config: PureConfig; rscPath: string; context: Record | undefined; // TODO we hope to get only decoded one decodedBody?: unknown; - body?: ReadableStream | undefined; + body?: ReadableStream | null; contentType?: string | undefined; moduleIdCallback?: ((id: string) => void) | undefined; onError?: (err: unknown) => void; @@ -231,7 +230,7 @@ export async function renderRsc( type GetBuildConfigArgs = { env: Record; - config: Omit; + config: PureConfig; }; type GetBuildConfigOpts = { entries: EntriesPrd }; @@ -300,7 +299,7 @@ export async function getBuildConfig( export type GetSsrConfigArgs = { env: Record; - config: Omit; + config: PureConfig; pathname: string; searchParams: URLSearchParams; }; diff --git a/packages/waku/src/lib/renderers/rsc.ts b/packages/waku/src/lib/renderers/rsc.ts new file mode 100644 index 000000000..c54b61a3c --- /dev/null +++ b/packages/waku/src/lib/renderers/rsc.ts @@ -0,0 +1,152 @@ +import type { ReactNode } from 'react'; +import type { default as RSDWServerType } from 'react-server-dom-webpack/server.edge'; + +import type { PureConfig } from '../config.js'; +// TODO move types somewhere +import type { HandlerContext } from '../middleware/types.js'; +import { filePathToFileURL } from '../utils/path.js'; +import { streamToArrayBuffer } from '../utils/stream.js'; +import { bufferToString, parseFormData } from '../utils/buffer.js'; + +const resolveClientEntryForPrd = (id: string, config: { basePath: string }) => { + return config.basePath + id + '.js'; +}; + +export function renderRsc( + config: PureConfig, + ctx: Pick, + elements: Record, + moduleIdCallback?: (id: string) => void, +): ReadableStream { + const modules = ctx.unstable_modules; + if (!modules) { + throw new Error('handler middleware required (missing modules)'); + } + const { + default: { + renderToReadableStream, + // decodeReply, + }, + } = modules.rsdwServer as { default: typeof RSDWServerType }; + const resolveClientEntry = ctx.unstable_devServer + ? ctx.unstable_devServer.resolveClientEntry + : resolveClientEntryForPrd; + const clientBundlerConfig = new Proxy( + {}, + { + get(_target, encodedId: string) { + const [file, name] = encodedId.split('#') as [string, string]; + const id = resolveClientEntry(file, config); + moduleIdCallback?.(id); + return { id, chunks: [id], name, async: true }; + }, + }, + ); + return renderToReadableStream(elements, clientBundlerConfig); +} + +export function renderRscElement( + config: PureConfig, + ctx: Pick, + element: ReactNode, +): ReadableStream { + const modules = ctx.unstable_modules; + if (!modules) { + throw new Error('handler middleware required (missing modules)'); + } + const { + default: { + renderToReadableStream, + // decodeReply, + }, + } = modules.rsdwServer as { default: typeof RSDWServerType }; + const resolveClientEntry = ctx.unstable_devServer + ? ctx.unstable_devServer.resolveClientEntry + : resolveClientEntryForPrd; + const clientBundlerConfig = new Proxy( + {}, + { + get(_target, encodedId: string) { + const [file, name] = encodedId.split('#') as [string, string]; + const id = resolveClientEntry(file, config); + return { id, chunks: [id], name, async: true }; + }, + }, + ); + return renderToReadableStream(element, clientBundlerConfig); +} + +export async function collectClientModules( + config: PureConfig, + rsdwServer: { default: typeof RSDWServerType }, + elements: Record, +): Promise { + const { + default: { renderToReadableStream }, + } = rsdwServer; + const idSet = new Set(); + const clientBundlerConfig = new Proxy( + {}, + { + get(_target, encodedId: string) { + const [file, name] = encodedId.split('#') as [string, string]; + const id = resolveClientEntryForPrd(file, config); + idSet.add(id); + return { id, chunks: [id], name, async: true }; + }, + }, + ); + const readable = renderToReadableStream(elements, clientBundlerConfig); + await new Promise((resolve, reject) => { + const writable = new WritableStream({ + close() { + resolve(); + }, + abort(reason) { + reject(reason); + }, + }); + readable.pipeTo(writable).catch(reject); + }); + return Array.from(idSet); +} + +export async function decodeBody( + ctx: Pick, +): Promise { + const isDev = !!ctx.unstable_devServer; + const modules = ctx.unstable_modules; + if (!modules) { + throw new Error('handler middleware required (missing modules)'); + } + const { + default: { decodeReply }, + } = modules.rsdwServer as { default: typeof RSDWServerType }; + const serverBundlerConfig = new Proxy( + {}, + { + get(_target, encodedId: string) { + const [fileId, name] = encodedId.split('#') as [string, string]; + const id = isDev ? filePathToFileURL(fileId) : fileId + '.js'; + return { id, chunks: [id], name, async: true }; + }, + }, + ); + let decodedBody: unknown = ctx.req.url.searchParams; + if (ctx.req.body) { + const bodyBuf = await streamToArrayBuffer(ctx.req.body); + const contentType = ctx.req.headers['content-type']; + if ( + typeof contentType === 'string' && + contentType.startsWith('multipart/form-data') + ) { + // XXX This doesn't support streaming unlike busboy + const formData = await parseFormData(bodyBuf, contentType); + decodedBody = await decodeReply(formData, serverBundlerConfig); + } else if (bodyBuf.byteLength > 0) { + const bodyStr = bufferToString(bodyBuf); + decodedBody = await decodeReply(bodyStr, serverBundlerConfig); + } + } + return decodedBody; +} diff --git a/packages/waku/src/lib/utils/stream.ts b/packages/waku/src/lib/utils/stream.ts index 5dfa4f152..1e32da6cc 100644 --- a/packages/waku/src/lib/utils/stream.ts +++ b/packages/waku/src/lib/utils/stream.ts @@ -66,3 +66,23 @@ export const stringToStream = (str: string): ReadableStream => { }, }); }; + +export const streamFromPromise = (promise: Promise) => + new ReadableStream({ + async start(controller) { + try { + const stream = await promise; + const reader = stream.getReader(); + let result: ReadableStreamReadResult; + do { + result = await reader.read(); + if (result.value) { + controller.enqueue(result.value); + } + } while (!result.done); + controller.close(); + } catch (err) { + controller.error(err); + } + }, + }); diff --git a/packages/waku/src/middleware/context.ts b/packages/waku/src/middleware/context.ts new file mode 100644 index 000000000..19fbf3008 --- /dev/null +++ b/packages/waku/src/middleware/context.ts @@ -0,0 +1,2 @@ +export { context as default } from '../lib/middleware/context.js'; +export { getContext } from '../lib/middleware/context.js'; diff --git a/packages/waku/src/middleware/handler.ts b/packages/waku/src/middleware/handler.ts new file mode 100644 index 000000000..d95239b5a --- /dev/null +++ b/packages/waku/src/middleware/handler.ts @@ -0,0 +1 @@ +export { handler as default } from '../lib/middleware/handler.js'; diff --git a/packages/waku/src/middleware/headers.ts b/packages/waku/src/middleware/headers.ts deleted file mode 100644 index 6aeae397a..000000000 --- a/packages/waku/src/middleware/headers.ts +++ /dev/null @@ -1 +0,0 @@ -export { headers as default } from '../lib/middleware/headers.js'; diff --git a/packages/waku/src/minimal/client.ts b/packages/waku/src/minimal/client.ts new file mode 100644 index 000000000..da3915a13 --- /dev/null +++ b/packages/waku/src/minimal/client.ts @@ -0,0 +1,335 @@ +/// +'use client'; + +import { + Component, + createContext, + createElement, + memo, + use, + useCallback, + useEffect, + useState, +} from 'react'; +import type { ReactNode } from 'react'; +import RSDWClient from 'react-server-dom-webpack/client'; + +import { encodeRscPath, encodeFuncId } from '../lib/renderers/utils.js'; + +const { createFromFetch, encodeReply } = RSDWClient; + +declare global { + interface ImportMeta { + readonly env: Record; + } +} + +const BASE_PATH = `${import.meta.env?.WAKU_CONFIG_BASE_PATH}${ + import.meta.env?.WAKU_CONFIG_RSC_BASE +}/`; + +const checkStatus = async ( + responsePromise: Promise, +): Promise => { + const response = await responsePromise; + if (!response.ok) { + const err = new Error(response.statusText); + (err as any).statusCode = response.status; + throw err; + } + return response; +}; + +type Elements = Promise> & { + prev?: Record | undefined; +}; + +const getCached = (c: () => T, m: WeakMap, k: object): T => + (m.has(k) ? m : m.set(k, c())).get(k) as T; +const cache1 = new WeakMap(); +const mergeElements = (a: Elements, b: Elements): Elements => { + const getResult = () => { + const promise: Elements = new Promise((resolve, reject) => { + Promise.all([a, b]) + .then(([a, b]) => { + const nextElements = { ...a, ...b }; + delete nextElements._value; + promise.prev = a; + resolve(nextElements); + }) + .catch((e) => { + a.then( + (a) => { + promise.prev = a; + reject(e); + }, + () => { + promise.prev = a.prev; + reject(e); + }, + ); + }); + }); + return promise; + }; + const cache2 = getCached(() => new WeakMap(), cache1, a); + return getCached(getResult, cache2, b); +}; + +type SetElements = (updater: (prev: Elements) => Elements) => void; +type EnhanceCreateData = ( + createData: ( + responsePromise: Promise, + ) => Promise>, +) => (responsePromise: Promise) => Promise>; + +const ENTRY = 'e'; +const SET_ELEMENTS = 's'; +const ENHANCE_CREATE_DATA = 'd'; + +type FetchCache = { + [ENTRY]?: [rscPath: string, rscParams: unknown, elements: Elements]; + [SET_ELEMENTS]?: SetElements; + [ENHANCE_CREATE_DATA]?: EnhanceCreateData | undefined; +}; + +const defaultFetchCache: FetchCache = {}; + +/** + * callServer callback + * This is not a public API. + */ +export const callServerRsc = async ( + funcId: string, + args: unknown[], + fetchCache = defaultFetchCache, +) => { + const enhanceCreateData = fetchCache[ENHANCE_CREATE_DATA] || ((d) => d); + const createData = (responsePromise: Promise) => + createFromFetch>(checkStatus(responsePromise), { + callServer: (funcId: string, args: unknown[]) => + callServerRsc(funcId, args, fetchCache), + }); + const url = BASE_PATH + encodeRscPath(encodeFuncId(funcId)); + const responsePromise = + args.length === 1 && args[0] instanceof URLSearchParams + ? fetch(url + '?' + args[0]) + : encodeReply(args).then((body) => fetch(url, { method: 'POST', body })); + const data = enhanceCreateData(createData)(responsePromise); + // FIXME this causes rerenders even if data is empty + fetchCache[SET_ELEMENTS]?.((prev) => mergeElements(prev, data)); + return (await data)._value; +}; + +const prefetchedParams = new WeakMap, unknown>(); + +const fetchRscInternal = (url: string, rscParams: unknown) => + rscParams === undefined + ? fetch(url) + : rscParams instanceof URLSearchParams + ? fetch(url + '?' + rscParams) + : encodeReply(rscParams).then((body) => + fetch(url, { method: 'POST', body }), + ); + +export const fetchRsc = ( + rscPath: string, + rscParams?: unknown, + fetchCache = defaultFetchCache, +): Elements => { + const entry = fetchCache[ENTRY]; + if (entry && entry[0] === rscPath && entry[1] === rscParams) { + return entry[2]; + } + const enhanceCreateData = fetchCache[ENHANCE_CREATE_DATA] || ((d) => d); + const createData = (responsePromise: Promise) => + createFromFetch>(checkStatus(responsePromise), { + callServer: (funcId: string, args: unknown[]) => + callServerRsc(funcId, args, fetchCache), + }); + const prefetched = ((globalThis as any).__WAKU_PREFETCHED__ ||= {}); + const url = BASE_PATH + encodeRscPath(rscPath); + const hasValidPrefetchedResponse = + !!prefetched[url] && + // HACK .has() is for the initial hydration + // It's limited and may result in a wrong result. FIXME + (!prefetchedParams.has(prefetched[url]) || + prefetchedParams.get(prefetched[url]) === rscParams); + const responsePromise = hasValidPrefetchedResponse + ? prefetched[url] + : fetchRscInternal(url, rscParams); + delete prefetched[url]; + const data = enhanceCreateData(createData)(responsePromise); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + fetchCache[ENTRY] = [rscPath, rscParams, data]; + return data; +}; + +export const prefetchRsc = (rscPath: string, rscParams?: unknown): void => { + const prefetched = ((globalThis as any).__WAKU_PREFETCHED__ ||= {}); + const url = BASE_PATH + encodeRscPath(rscPath); + if (!(url in prefetched)) { + prefetched[url] = fetchRscInternal(url, rscParams); + prefetchedParams.set(prefetched[url], rscParams); + } +}; + +const RefetchContext = createContext< + (rscPath: string, rscParams?: unknown) => void +>(() => { + throw new Error('Missing Root component'); +}); +const ElementsContext = createContext(null); + +export const Root = ({ + initialRscPath, + initialRscParams, + fetchCache = defaultFetchCache, + unstable_enhanceCreateData, + children, +}: { + initialRscPath?: string; + initialRscParams?: unknown; + fetchCache?: FetchCache; + unstable_enhanceCreateData?: EnhanceCreateData; + children: ReactNode; +}) => { + fetchCache[ENHANCE_CREATE_DATA] = unstable_enhanceCreateData; + const [elements, setElements] = useState(() => + fetchRsc(initialRscPath || '', initialRscParams, fetchCache), + ); + useEffect(() => { + fetchCache[SET_ELEMENTS] = setElements; + }, [fetchCache, setElements]); + const refetch = useCallback( + (rscPath: string, rscParams?: unknown) => { + // clear cache entry before fetching + delete fetchCache[ENTRY]; + const data = fetchRsc(rscPath, rscParams, fetchCache); + setElements((prev) => mergeElements(prev, data)); + }, + [fetchCache], + ); + return createElement( + RefetchContext.Provider, + { value: refetch }, + createElement(ElementsContext.Provider, { value: elements }, children), + ); +}; + +export const useRefetch = () => use(RefetchContext); + +const ChildrenContext = createContext(undefined); +const ChildrenContextProvider = memo(ChildrenContext.Provider); + +type OuterSlotProps = { + elementsPromise: Elements; + shouldRenderPrev: ((err: unknown) => boolean) | undefined; + renderSlot: (elements: Record) => ReactNode; + children?: ReactNode; +}; + +class OuterSlot extends Component { + constructor(props: OuterSlotProps) { + super(props); + this.state = {}; + } + static getDerivedStateFromError(error: unknown) { + return { error }; + } + render() { + if ('error' in this.state) { + const e = this.state.error; + if (e instanceof Error && !('statusCode' in e)) { + // HACK we assume any error as Not Found, + // probably caused by history api fallback + (e as any).statusCode = 404; + } + if (this.props.shouldRenderPrev?.(e) && this.props.elementsPromise.prev) { + const elements = this.props.elementsPromise.prev; + return this.props.renderSlot(elements); + } else { + throw e; + } + } + return this.props.children; + } +} + +const InnerSlot = ({ + elementsPromise, + renderSlot, +}: { + elementsPromise: Elements; + renderSlot: (elements: Record) => ReactNode; +}) => { + const elements = use(elementsPromise); + return renderSlot(elements); +}; + +/** + * Slot component + * This is used under the Root component. + * Slot id is the key of elements returned by the server. + * + * If the server returns this + * ``` + * { 'foo':
foo
, 'bar':
bar
} + * ``` + * then you can use this component like this + * ``` + * + * ``` + */ +export const Slot = ({ + id, + children, + fallback, + unstable_shouldRenderPrev, +}: { + id: string; + children?: ReactNode; + fallback?: ReactNode; + unstable_shouldRenderPrev?: (err: unknown) => boolean; +}) => { + const elementsPromise = use(ElementsContext); + if (!elementsPromise) { + throw new Error('Missing Root component'); + } + const renderSlot = (elements: Record) => { + if (!(id in elements)) { + if (fallback) { + return fallback; + } + throw new Error('Not found: ' + id); + } + return createElement( + ChildrenContextProvider, + { value: children }, + elements[id], + ); + }; + return createElement( + OuterSlot, + { + elementsPromise, + shouldRenderPrev: unstable_shouldRenderPrev, + renderSlot, + }, + createElement(InnerSlot, { elementsPromise, renderSlot }), + ); +}; + +export const Children = () => use(ChildrenContext); + +/** + * ServerRoot for SSR + * This is not a public API. + */ +export const ServerRootInternal = ({ + elements, + children, +}: { + elements: Elements; + children: ReactNode; +}) => createElement(ElementsContext.Provider, { value: elements }, children); diff --git a/packages/waku/src/minimal/server.ts b/packages/waku/src/minimal/server.ts new file mode 100644 index 000000000..05410d7e9 --- /dev/null +++ b/packages/waku/src/minimal/server.ts @@ -0,0 +1,103 @@ +import type { ReactNode } from 'react'; + +import type { Config } from '../config.js'; +import type { PathSpec } from '../lib/utils/path.js'; +// TODO move types somewhere +import type { HandlerReq, HandlerRes } from '../lib/middleware/types.js'; + +type Elements = Record; + +export type BuildConfig = { + pathname: string | PathSpec; // TODO drop support for string? + isStatic?: boolean | undefined; + entries?: { + rscPath: string; + skipPrefetch?: boolean | undefined; + isStatic?: boolean | undefined; + }[]; + context?: Record; // TODO remove with new_defineEntries? + customCode?: string; // optional code to inject TODO hope to remove this +}[]; + +export type RenderEntries = ( + rscPath: string, + options: { + rscParams: unknown | undefined; + }, +) => Promise; + +export type GetBuildConfig = ( + unstable_collectClientModules: (rscPath: string) => Promise, +) => Promise; + +export type GetSsrConfig = ( + pathname: string, + options: { + searchParams: URLSearchParams; + }, +) => Promise<{ + rscPath: string; + rscParams?: unknown; + html: ReactNode; +} | null>; + +export function defineEntries( + renderEntries: RenderEntries, + getBuildConfig?: GetBuildConfig, + getSsrConfig?: GetSsrConfig, +) { + return { renderEntries, getBuildConfig, getSsrConfig }; +} + +export type EntriesDev = { + default: ReturnType; +}; + +export type EntriesPrd = EntriesDev & { + loadConfig: () => Promise; + loadModule: (id: string) => Promise; + dynamicHtmlPaths: [pathSpec: PathSpec, htmlHead: string][]; + publicIndexHtml: string; + buildData?: Record; // must be JSON serializable +}; + +// ----------------------------------------------------- +// new_defineEntries +// Eventually replaces defineEntries +// ----------------------------------------------------- + +type HandleRequest = ( + input: ( + | { type: 'component'; rscPath: string; rscParams: unknown } + | { + type: 'function'; + fn: (...args: unknown[]) => Promise; + args: unknown[]; + } + | { type: 'custom'; pathname: string } + ) & { + req: HandlerReq; + }, + utils: { + renderRsc: (elements: Record) => ReadableStream; + renderHtml: ( + elements: Elements, + html: ReactNode, + rscPath: string, + ) => { + body: ReadableStream; + headers: Record<'content-type', string>; + }; + }, +) => Promise; + +type new_GetBuildConfig = (utils: { + unstable_collectClientModules: (elements: Elements) => Promise; +}) => Promise; + +export function new_defineEntries(fns: { + unstable_handleRequest: HandleRequest; + unstable_getBuildConfig: new_GetBuildConfig; +}) { + return fns; +} diff --git a/packages/waku/src/router/client.ts b/packages/waku/src/router/client.ts index cb3ea4af4..5f6a154eb 100644 --- a/packages/waku/src/router/client.ts +++ b/packages/waku/src/router/client.ts @@ -23,7 +23,13 @@ import type { MouseEvent, } from 'react'; -import { fetchRsc, prefetchRsc, Root, Slot, useRefetch } from '../client.js'; +import { + fetchRsc, + prefetchRsc, + Root, + Slot, + useRefetch, +} from '../minimal/client.js'; import { getComponentIds, encodeRoutePath, diff --git a/packages/waku/src/router/define-router.ts b/packages/waku/src/router/define-router.ts index 4e2b19a60..8516f9642 100644 --- a/packages/waku/src/router/define-router.ts +++ b/packages/waku/src/router/define-router.ts @@ -11,8 +11,8 @@ import type { RenderEntries, GetBuildConfig, GetSsrConfig, -} from '../server.js'; -import { Children, Slot } from '../client.js'; +} from '../minimal/server.js'; +import { Children, Slot } from '../minimal/client.js'; import { getComponentIds, encodeRoutePath, diff --git a/packages/waku/src/server.ts b/packages/waku/src/server.ts index 73fe5866c..24f9c43e5 100644 --- a/packages/waku/src/server.ts +++ b/packages/waku/src/server.ts @@ -1,77 +1,54 @@ import type { AsyncLocalStorage as AsyncLocalStorageType } from 'node:async_hooks'; -import type { ReactNode } from 'react'; -import type { Config } from './config.js'; -import type { PathSpec } from './lib/utils/path.js'; -import { REQUEST_HEADERS } from './lib/middleware/headers.js'; +// This can't be relative import +import { getContext } from 'waku/middleware/context'; -type Elements = Record; +import { defineEntries as defineEntriesOrig } from './minimal/server.js'; +/** @deprecated */ +export const defineEntries = defineEntriesOrig; -export type BuildConfig = { - pathname: string | PathSpec; // TODO drop support for string? - isStatic?: boolean | undefined; - entries?: { - rscPath: string; - skipPrefetch?: boolean | undefined; - isStatic?: boolean | undefined; - }[]; - context?: Record; - customCode?: string; // optional code to inject TODO hope to remove this -}[]; - -export type RenderEntries = ( - rscPath: string, - options: { - rscParams: unknown | undefined; - }, -) => Promise; - -export type GetBuildConfig = ( - unstable_collectClientModules: (rscPath: string) => Promise, -) => Promise; - -export type GetSsrConfig = ( - pathname: string, - options: { - searchParams: URLSearchParams; - }, -) => Promise<{ - rscPath: string; - rscParams?: unknown; - html: ReactNode; -} | null>; +/** + * This is an internal function and not for public use. + */ +export function setAllEnvInternal(newEnv: Readonly>) { + (globalThis as any).__WAKU_SERVER_ENV__ = newEnv; +} -export function defineEntries( - renderEntries: RenderEntries, - getBuildConfig?: GetBuildConfig, - getSsrConfig?: GetSsrConfig, -) { - return { renderEntries, getBuildConfig, getSsrConfig }; +export function getEnv(key: string): string | undefined { + return (globalThis as any).__WAKU_SERVER_ENV__?.[key]; } -export type EntriesDev = { - default: ReturnType; -}; +export function unstable_getHeaders(): Readonly> { + return getContext().req.headers; +} -export type EntriesPrd = EntriesDev & { - loadConfig: () => Promise; - loadModule: (id: string) => Promise; - dynamicHtmlPaths: [pathSpec: PathSpec, htmlHead: string][]; - publicIndexHtml: string; +type PlatformObject = { buildData?: Record; // must be JSON serializable -}; - -let serverEnv: Readonly> = {}; + buildOptions?: { + deploy?: + | 'vercel-static' + | 'vercel-serverless' + | 'netlify-static' + | 'netlify-functions' + | 'cloudflare' + | 'partykit' + | 'deno' + | 'aws-lambda' + | undefined; + unstable_phase?: + | 'analyzeEntries' + | 'buildServerBundle' + | 'buildSsrBundle' + | 'buildClientBundle' + | 'buildDeploy'; + }; +} & Record; -/** - * This is an internal function and not for public use. - */ -export function setAllEnvInternal(newEnv: typeof serverEnv) { - serverEnv = newEnv; -} +(globalThis as any).__WAKU_PLATFORM_OBJECT__ ||= {}; -export function getEnv(key: string): string | undefined { - return serverEnv[key]; +// TODO tentative name +export function unstable_getPlatformObject(): PlatformObject { + return (globalThis as any).__WAKU_PLATFORM_OBJECT__; } type RenderStore<> = { @@ -81,15 +58,6 @@ type RenderStore<> = { let renderStorage: AsyncLocalStorageType | undefined; -// TODO top-level await doesn't work. Let's revisit after supporting "use server" -// try { -// const { AsyncLocalStorage } = await import('node:async_hooks'); -// renderStorage = new AsyncLocalStorage(); -// } catch (e) { -// console.warn( -// 'AsyncLocalStorage is not available, rerender and getCustomContext are only available in sync.', -// ); -// } import('node:async_hooks') .then(({ AsyncLocalStorage }) => { renderStorage = new AsyncLocalStorage(); @@ -105,6 +73,7 @@ let currentRenderStore: RenderStore | undefined; /** * This is an internal function and not for public use. + * @deprecated */ export const runWithRenderStoreInternal = ( renderStore: RenderStore, @@ -122,6 +91,7 @@ export const runWithRenderStoreInternal = ( } }; +/** @deprecated use new_defineEntries */ export function rerender(rscPath: string, rscParams?: unknown) { const renderStore = renderStorage?.getStore() ?? currentRenderStore; if (!renderStore) { @@ -130,6 +100,7 @@ export function rerender(rscPath: string, rscParams?: unknown) { renderStore.rerender(rscPath, rscParams); } +/** @deprecated use getContext from waku/middleware/context */ export function unstable_getCustomContext< CustomContext extends Record = Record, >(): CustomContext { @@ -139,39 +110,3 @@ export function unstable_getCustomContext< } return renderStore.context as CustomContext; } - -export function unstable_getHeaders(): Record { - return (unstable_getCustomContext()[REQUEST_HEADERS] || {}) as Record< - string, - string - >; -} - -type PlatformObject = { - buildData?: Record; // must be JSON serializable - buildOptions?: { - deploy?: - | 'vercel-static' - | 'vercel-serverless' - | 'netlify-static' - | 'netlify-functions' - | 'cloudflare' - | 'partykit' - | 'deno' - | 'aws-lambda' - | undefined; - unstable_phase?: - | 'analyzeEntries' - | 'buildServerBundle' - | 'buildSsrBundle' - | 'buildClientBundle' - | 'buildDeploy'; - }; -} & Record; - -(globalThis as any).__WAKU_PLATFORM_OBJECT__ ||= {}; - -// TODO tentative name -export function unstable_getPlatformObject(): PlatformObject { - return (globalThis as any).__WAKU_PLATFORM_OBJECT__; -} diff --git a/packages/waku/tests/vite-plugin-rsc-transform-internals.test.ts b/packages/waku/tests/vite-plugin-rsc-transform-internals.test.ts index d95bab6a2..2efd9ac1a 100644 --- a/packages/waku/tests/vite-plugin-rsc-transform-internals.test.ts +++ b/packages/waku/tests/vite-plugin-rsc-transform-internals.test.ts @@ -395,7 +395,7 @@ export default async function log4(mesg) { expect(await transform(code, '/src/func.ts')).toMatchInlineSnapshot(` " import { createServerReference } from 'react-server-dom-webpack/client'; - import { callServerRsc } from 'waku/client'; + import { callServerRsc } from 'waku/minimal/client'; export const log1 = createServerReference('/src/func.ts#log1', callServerRsc);