From f22899e42476bbbd2c69c194fa635932c191f684 Mon Sep 17 00:00:00 2001 From: abearxiong Date: Tue, 14 Apr 2026 10:09:00 +0800 Subject: [PATCH] feat: implement dynamic page loading and server components --- server.js | 125 ++++++++++++++++++++++++++++++++++++ src/framework/entry.rsc.tsx | 53 ++++++++++++++- src/pages/a/index.tsx | 9 +++ src/pages/b/index.tsx | 10 +++ src/root.tsx | 38 +++++++---- 5 files changed, 220 insertions(+), 15 deletions(-) create mode 100644 server.js create mode 100644 src/pages/a/index.tsx create mode 100644 src/pages/b/index.tsx diff --git a/server.js b/server.js new file mode 100644 index 0000000..be773b7 --- /dev/null +++ b/server.js @@ -0,0 +1,125 @@ +// Production server for RSC +// Run with: node server.js + +import { createServer } from 'node:http' +import { readFile } from 'node:fs/promises' +import { join, extname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = fileURLToPath(new URL('.', import.meta.url)) +const PORT = process.env.PORT || 3000 + +// Import RSC handler - it exports { fetch: handler } +const rscHandler = (await import('./dist/rsc/index.js')).default.fetch + +// MIME types +const mimeTypes = { + '.html': 'text/html', + '.js': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.svg': 'image/svg+xml', +} + +const server = createServer(async (req, res) => { + const url = new URL(req.url, `http://localhost:${PORT}`) + + // Serve static client assets first (these don't go through RSC handler) + if (url.pathname.startsWith('/dist/client') || + url.pathname.startsWith('/assets') || + url.pathname === '/vite.svg') { + // Map paths to dist/client/* + let distPath + if (url.pathname.startsWith('/assets')) { + distPath = join(__dirname, '/dist/client', url.pathname) + } else if (url.pathname === '/vite.svg') { + distPath = join(__dirname, '/dist/client/vite.svg') + } else { + distPath = join(__dirname, url.pathname) + } + + try { + const content = await readFile(distPath) + const ext = extname(distPath) + const contentType = mimeTypes[ext] || 'application/octet-stream' + res.setHeader('Content-Type', contentType) + res.end(content) + } catch (err) { + res.statusCode = 404 + res.end('Not Found') + } + return + } + + // All other routes use RSC handler (handles SSR, RSC, and dynamic pages) + try { + // Read the body first (for POST requests) + const bodyContent = req.method !== 'GET' + ? await new Promise((resolve, reject) => { + const chunks = [] + req.on('data', chunk => chunks.push(chunk)) + req.on('end', () => resolve(Buffer.concat(chunks))) + req.on('error', reject) + }) + : undefined + + // Clone the headers (req.headers is an object, not Map) + const headers = new Headers() + for (const key of Object.keys(req.headers)) { + const value = req.headers[key] + if (key.toLowerCase() !== 'content-length') { + headers.set(key, value) + } + } + + // Create request with absolute URL (RSC handler requires full URL) + const rscRequest = new Request(`http://localhost:${PORT}${url.pathname}${url.search}`, { + method: req.method, + headers, + body: bodyContent, + }) + + const response = await rscHandler(rscRequest) + res.statusCode = response.status + + // Forward headers + for (const [key, value] of response.headers.entries()) { + if (key !== 'transfer-encoding' && key !== 'content-length') { + res.setHeader(key, value) + } + } + + // For RSC stream, pipe directly + if (response.headers.get('content-type')?.includes('x-component')) { + response.body.pipe(res) + return + } + + // For HTML, inject correct client assets base path + const html = await response.text() + const modifiedHtml = html.replace( + /import\("\/@id\/__x00__/g, + 'import("/dist/client/@id/__x00__' + ).replace( + /href="\/src\//g, + 'href="/dist/client/src/' + ).replace( + /src="\/src\//g, + 'src="/dist/client/src/' + ) + + res.setHeader('Content-Type', 'text/html') + res.end(modifiedHtml) + } catch (err) { + console.error('RSC handler error:', err) + res.statusCode = 500 + res.end('Internal Server Error') + } +}) + +server.listen(PORT, () => { + console.log(`RSC Server running at http://localhost:${PORT}/`) + console.log(`Press Ctrl+C to stop`) +}) diff --git a/src/framework/entry.rsc.tsx b/src/framework/entry.rsc.tsx index c9cf5c4..9a9e751 100644 --- a/src/framework/entry.rsc.tsx +++ b/src/framework/entry.rsc.tsx @@ -9,6 +9,7 @@ import { import type { ReactFormState } from 'react-dom/client' import { Root } from '../root.tsx' import { parseRenderRequest } from './request.tsx' +import type { ComponentType } from 'react' // The schema of payload which is serialized into RSC stream on rsc environment // and deserialized on ssr/client environments. @@ -23,6 +24,47 @@ export type RscPayload = { formState?: ReactFormState } +// Dynamic page module loading - all pages are lazy loaded on demand +const pageModules = import.meta.glob('../pages/**/*.tsx', { eager: false }) + +// Find page component by pathname - dynamically resolves paths +async function findPage(pathname: string): Promise | null> { + const segments = pathname.split('/').filter(Boolean) + + // Build candidate paths to try + const candidates: string[] = [] + + if (segments.length === 0) { + // Root path: try index.tsx + candidates.push('../pages/index.tsx') + } else { + // Try original case path: /a -> pages/a/index.tsx, /blog -> pages/blog/index.tsx + const originalPath = `../pages/${segments.join('/')}/index.tsx` + candidates.push(originalPath) + + // Try capitalized file: /pageA -> pages/PageA.tsx + const capitalizedPath = `../pages/${segments.map(s => + s.charAt(0).toUpperCase() + s.slice(1) + ).join('/')}.tsx` + candidates.push(capitalizedPath) + + // Try capitalized index route: /a -> pages/A/index.tsx + const capitalizedIndexPath = `${capitalizedPath.replace('.tsx', '')}/index.tsx` + candidates.push(capitalizedIndexPath) + } + + // Find first matching module + for (const candidate of candidates) { + const loader = pageModules[candidate] + if (loader) { + const mod = await loader() as { default: ComponentType } + return mod.default + } + } + + return null +} + // the plugin by default assumes `rsc` entry having default export of request handler. // however, how server entries are executed can be customized by registering own server handler. export default { fetch: handler } @@ -73,12 +115,21 @@ async function handler(request: Request): Promise { } } + // Dynamic page resolution based on URL pathname + const pathname = renderRequest.url.pathname + const Page = await findPage(pathname) + + // If no page found and it's not the root path, return 404 + if (!Page && pathname !== '/') { + return new Response('Not Found: ' + pathname, { status: 404 }) + } + // serialization from React VDOM tree to RSC stream. // we render RSC stream after handling server function request // so that new render reflects updated state from server function call // to achieve single round trip to mutate and fetch from server. const rscPayload: RscPayload = { - root: , + root: : undefined} />, formState, returnValue, } diff --git a/src/pages/a/index.tsx b/src/pages/a/index.tsx new file mode 100644 index 0000000..5ad0c17 --- /dev/null +++ b/src/pages/a/index.tsx @@ -0,0 +1,9 @@ +'use server'; + +export default async function List() { + return ( +
+

List A

+
+ ); +} \ No newline at end of file diff --git a/src/pages/b/index.tsx b/src/pages/b/index.tsx new file mode 100644 index 0000000..c565e13 --- /dev/null +++ b/src/pages/b/index.tsx @@ -0,0 +1,10 @@ +'use server'; + +export default async function List() { + return ( +
+

List B

+
123
+
+ ); +} \ No newline at end of file diff --git a/src/root.tsx b/src/root.tsx index 116e86a..2e4e851 100644 --- a/src/root.tsx +++ b/src/root.tsx @@ -5,7 +5,7 @@ import reactLogo from './assets/react.svg' import { ClientCounter } from './client.tsx' import List from './List' -export function Root(props: { url: URL }) { +export function Root(props: { url: URL; page?: React.ReactNode }) { return ( @@ -21,7 +21,7 @@ export function Root(props: { url: URL }) { ) } -function App(props: { url: URL }) { +function App(props: { url: URL; page?: React.ReactNode }) { return (
@@ -36,18 +36,8 @@ function App(props: { url: URL }) {

Vite + RSC

-
- -
-
-
- -
-
-
Request URL: {props.url?.href}
-
- -
+ {/* Dynamic page content */} + {props.page || }
  • Edit src/client.tsx to test client HMR. @@ -73,3 +63,23 @@ function App(props: { url: URL }) {
) } + +// Default home page content when no dynamic page is provided +function DefaultHome() { + return ( + <> +
+ +
+
+
+ +
+
+
Request URL: Home
+
+ +
+ + ) +}