feat: implement dynamic page loading and server components

This commit is contained in:
2026-04-14 10:09:00 +08:00
parent 11e75d0e11
commit f22899e424
5 changed files with 220 additions and 15 deletions

125
server.js Normal file
View File

@@ -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`)
})

View File

@@ -9,6 +9,7 @@ import {
import type { ReactFormState } from 'react-dom/client' import type { ReactFormState } from 'react-dom/client'
import { Root } from '../root.tsx' import { Root } from '../root.tsx'
import { parseRenderRequest } from './request.tsx' import { parseRenderRequest } from './request.tsx'
import type { ComponentType } from 'react'
// The schema of payload which is serialized into RSC stream on rsc environment // The schema of payload which is serialized into RSC stream on rsc environment
// and deserialized on ssr/client environments. // and deserialized on ssr/client environments.
@@ -23,6 +24,47 @@ export type RscPayload = {
formState?: ReactFormState 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<ComponentType<any> | 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<any> }
return mod.default
}
}
return null
}
// the plugin by default assumes `rsc` entry having default export of request handler. // 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. // however, how server entries are executed can be customized by registering own server handler.
export default { fetch: handler } export default { fetch: handler }
@@ -73,12 +115,21 @@ async function handler(request: Request): Promise<Response> {
} }
} }
// 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. // serialization from React VDOM tree to RSC stream.
// we render RSC stream after handling server function request // we render RSC stream after handling server function request
// so that new render reflects updated state from server function call // so that new render reflects updated state from server function call
// to achieve single round trip to mutate and fetch from server. // to achieve single round trip to mutate and fetch from server.
const rscPayload: RscPayload = { const rscPayload: RscPayload = {
root: <Root url={renderRequest.url} />, root: <Root url={renderRequest.url} page={Page ? <Page /> : undefined} />,
formState, formState,
returnValue, returnValue,
} }

9
src/pages/a/index.tsx Normal file
View File

@@ -0,0 +1,9 @@
'use server';
export default async function List() {
return (
<div>
<h1>List A</h1>
</div>
);
}

10
src/pages/b/index.tsx Normal file
View File

@@ -0,0 +1,10 @@
'use server';
export default async function List() {
return (
<div>
<h1>List B</h1>
<div>123</div>
</div>
);
}

View File

@@ -5,7 +5,7 @@ import reactLogo from './assets/react.svg'
import { ClientCounter } from './client.tsx' import { ClientCounter } from './client.tsx'
import List from './List' import List from './List'
export function Root(props: { url: URL }) { export function Root(props: { url: URL; page?: React.ReactNode }) {
return ( return (
<html lang="en"> <html lang="en">
<head> <head>
@@ -21,7 +21,7 @@ export function Root(props: { url: URL }) {
) )
} }
function App(props: { url: URL }) { function App(props: { url: URL; page?: React.ReactNode }) {
return ( return (
<div id="root"> <div id="root">
<div> <div>
@@ -36,18 +36,8 @@ function App(props: { url: URL }) {
</a> </a>
</div> </div>
<h1>Vite + RSC</h1> <h1>Vite + RSC</h1>
<div className="card"> {/* Dynamic page content */}
<ClientCounter /> {props.page || <DefaultHome />}
</div>
<div className="card">
<form action={updateServerCounter.bind(null, 1)}>
<button>Server Counter: {getServerCounter()}</button>
</form>
</div>
<div className="card">Request URL: {props.url?.href}</div>
<div className="card">
<List />
</div>
<ul className="read-the-docs"> <ul className="read-the-docs">
<li> <li>
Edit <code>src/client.tsx</code> to test client HMR. Edit <code>src/client.tsx</code> to test client HMR.
@@ -73,3 +63,23 @@ function App(props: { url: URL }) {
</div> </div>
) )
} }
// Default home page content when no dynamic page is provided
function DefaultHome() {
return (
<>
<div className="card">
<ClientCounter />
</div>
<div className="card">
<form action={updateServerCounter.bind(null, 1)}>
<button>Server Counter: {getServerCounter()}</button>
</form>
</div>
<div className="card">Request URL: Home</div>
<div className="card">
<List />
</div>
</>
)
}