11// api/ssr.ts
22import fs from "node:fs" ;
33import path from "node:path" ;
4- import { pathToFileURL } from "node:url" ;
4+ import { fileURLToPath , pathToFileURL } from "node:url" ;
55import { PassThrough } from "node:stream" ;
6+ import { randomUUID } from "node:crypto" ;
67import type { IncomingMessage , ServerResponse } from "node:http" ;
78import type { PipeableStream } from "react-dom/server" ;
89
@@ -31,6 +32,8 @@ type ManifestEntry = {
3132 src ?: string ;
3233} ;
3334
35+ const ROOT_DIR = path . resolve ( path . dirname ( fileURLToPath ( import . meta. url ) ) , ".." ) ;
36+
3437function pickRender ( mod : unknown ) : RenderFn | null {
3538 if ( ! mod || typeof mod !== "object" ) return null ;
3639 const m = mod as SsrModule ;
@@ -49,14 +52,15 @@ function toPublicPath(file?: string): string | null {
4952
5053function buildSsrHead ( ) : string {
5154 const parts : string [ ] = [ ] ;
52- const manifestPath = path . join ( process . cwd ( ) , "dist" , "client" , ".vite" , "manifest.json" ) ;
55+ const manifestPaths = [
56+ path . join ( ROOT_DIR , "dist" , ".vite" , "manifest.json" ) ,
57+ path . join ( ROOT_DIR , "dist" , "client" , ".vite" , "manifest.json" ) ,
58+ ] ;
5359
5460 try {
55- if ( fs . existsSync ( manifestPath ) ) {
56- const manifest = JSON . parse ( fs . readFileSync ( manifestPath , "utf8" ) ) as Record <
57- string ,
58- ManifestEntry
59- > ;
61+ const manifestPath = manifestPaths . find ( ( candidate ) => fs . existsSync ( candidate ) ) ;
62+ if ( manifestPath ) {
63+ const manifest = JSON . parse ( fs . readFileSync ( manifestPath , "utf8" ) ) as Record < string , ManifestEntry > ;
6064 const entryKey =
6165 Object . keys ( manifest ) . find ( ( key ) => manifest [ key ] ?. src ?. includes ( "entry-client" ) ) ??
6266 Object . keys ( manifest ) . find ( ( key ) => manifest [ key ] ?. isEntry ) ;
@@ -127,28 +131,107 @@ function formatErrorForDebug(err: unknown): string {
127131 }
128132}
129133
130- export default async function handler ( req : IncomingMessage , res : ServerResponse ) {
131- const ABORT_DELAY_MS = 10_000 ;
134+ type TemplateParts = { head : string ; tail : string } ;
132135
133- try {
134- const url = absoluteUrl ( req ) ;
135- const DEBUG = url . searchParams . has ( "__ssr_debug" ) ;
136+ function minimalTemplate ( ) : TemplateParts {
137+ return {
138+ head :
139+ "<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"></head><body><div id=\"root\">" ,
140+ tail : "</div></body></html>" ,
141+ } ;
142+ }
136143
137- const templatePath = path . join ( process . cwd ( ) , "dist" , "server" , "template.html" ) ;
144+ function loadTemplate ( ) : TemplateParts {
145+ const templatePath = path . join ( ROOT_DIR , "dist" , "server" , "template.html" ) ;
146+ try {
138147 const template = fs . readFileSync ( templatePath , "utf8" ) ;
139148 const templateWithHead = template . replace ( "<!--ssr-head-->" , buildSsrHead ( ) ) ;
140- const { head, tail } = splitTemplate ( templateWithHead ) ;
149+ return splitTemplate ( templateWithHead ) ;
150+ } catch ( err ) {
151+ console . error ( "SSR template load failed:" , err ) ;
152+ return minimalTemplate ( ) ;
153+ }
154+ }
155+
156+ function resolveEntryServerPath ( ) : string | null {
157+ const serverDir = path . join ( ROOT_DIR , "dist" , "server" ) ;
158+ const candidates = [ "entry-server.js" , "entry-server.mjs" , "entry-server.cjs" ] ;
159+
160+ for ( const name of candidates ) {
161+ const candidate = path . join ( serverDir , name ) ;
162+ if ( fs . existsSync ( candidate ) ) return candidate ;
163+ }
164+
165+ try {
166+ const files = fs . readdirSync ( serverDir ) ;
167+ const match = files . find ( ( file ) => / ^ e n t r y - s e r v e r \. ( m j s | c j s | j s ) $ / i. test ( file ) ) ;
168+ return match ? path . join ( serverDir , match ) : null ;
169+ } catch {
170+ return null ;
171+ }
172+ }
173+
174+ function respondWithShell (
175+ res : ServerResponse ,
176+ parts : TemplateParts ,
177+ requestId : string ,
178+ opts : { debug : boolean ; error ?: unknown }
179+ ) {
180+ if ( ! res . headersSent ) {
181+ res . statusCode = 200 ;
182+ res . setHeader ( "content-type" , "text/html; charset=utf-8" ) ;
183+ res . setHeader ( "cache-control" , "no-store" ) ;
184+ res . setHeader ( "x-ssr" , "0" ) ;
185+ res . setHeader ( "x-ssr-request-id" , requestId ) ;
186+ if ( opts . debug ) {
187+ res . setHeader ( "x-ssr-debug" , "1" ) ;
188+ }
189+ }
141190
142- const entryPath = path . join ( process . cwd ( ) , "dist" , "server" , "entry-server.js" ) ;
191+ if ( opts . debug && opts . error ) {
192+ const payload = escapeHtml ( formatErrorForDebug ( opts . error ) ) ;
193+ const debugTemplate = minimalTemplate ( ) ;
194+ const style =
195+ "<style>body{font-family:ui-monospace,Menlo,Monaco,Consolas,monospace;padding:24px;background:#0b0b0c;color:#f2f2f2;}pre{white-space:pre-wrap;word-break:break-word;background:#151519;border:1px solid #2a2a33;padding:16px;border-radius:8px;}</style>" ;
196+ res . end ( `${ debugTemplate . head } ${ style } <pre>${ payload } </pre>${ debugTemplate . tail } ` ) ;
197+ return ;
198+ }
199+
200+ res . end ( parts . head + parts . tail ) ;
201+ }
202+
203+ export default async function handler ( req : IncomingMessage , res : ServerResponse ) {
204+ const ABORT_DELAY_MS = 10_000 ;
205+ const requestId = randomUUID ( ) ;
206+
207+ try {
208+ const url = absoluteUrl ( req ) ;
209+ const DEBUG = url . searchParams . has ( "__ssr_debug" ) || req . headers [ "x-ssr-debug" ] === "1" ;
210+ const templateParts = loadTemplate ( ) ;
211+ const { head, tail } = templateParts ;
212+
213+ const entryPath = resolveEntryServerPath ( ) ;
214+ if ( ! entryPath ) {
215+ const err = new Error ( "SSR entry-server bundle not found in dist/server" ) ;
216+ console . error ( `[SSR ${ requestId } ] entry not found` ) ;
217+ respondWithShell ( res , templateParts , requestId , { debug : DEBUG , error : err } ) ;
218+ return ;
219+ }
143220 const entryUrl = pathToFileURL ( entryPath ) . href ;
144221
145- const mod = ( await import ( entryUrl ) ) as unknown ;
146- const render = pickRender ( mod ) ;
222+ let render : RenderFn | null = null ;
223+ try {
224+ const mod = ( await import ( entryUrl ) ) as unknown ;
225+ render = pickRender ( mod ) ;
226+ } catch ( err ) {
227+ console . error ( `[SSR ${ requestId } ] entry import failed:` , err ) ;
228+ respondWithShell ( res , templateParts , requestId , { debug : DEBUG , error : err } ) ;
229+ return ;
230+ }
147231
148232 if ( ! render ) {
149- res . statusCode = 500 ;
150- res . setHeader ( "content-type" , "text/plain; charset=utf-8" ) ;
151- res . end ( "SSR entry missing render() export" ) ;
233+ console . error ( `[SSR ${ requestId } ] entry missing render() export` ) ;
234+ respondWithShell ( res , templateParts , requestId , { debug : DEBUG } ) ;
152235 return ;
153236 }
154237
@@ -158,6 +241,7 @@ export default async function handler(req: IncomingMessage, res: ServerResponse)
158241 res . setHeader ( "content-type" , "text/html; charset=utf-8" ) ;
159242 res . setHeader ( "cache-control" , "no-store" ) ;
160243 res . setHeader ( "x-ssr" , "1" ) ;
244+ res . setHeader ( "x-ssr-request-id" , requestId ) ;
161245
162246 let shellFlushed = false ;
163247 let pipeable : PipeableStream | null = null ;
@@ -213,10 +297,8 @@ export default async function handler(req: IncomingMessage, res: ServerResponse)
213297 // If shell failed before onShellReady, we still want to return an HTML document.
214298 // Debug mode: show error details in the browser.
215299 if ( ! shellFlushed ) {
216- if ( DEBUG ) {
217- if ( ! res . writableEnded ) res . end ( `<pre>${ escapeHtml ( formatErrorForDebug ( err ) ) } </pre>` ) ;
218- } else {
219- if ( ! res . writableEnded ) res . end ( head + tail ) ;
300+ if ( ! res . writableEnded ) {
301+ respondWithShell ( res , templateParts , requestId , { debug : DEBUG , error : err } ) ;
220302 }
221303 return ;
222304 }
@@ -233,12 +315,16 @@ export default async function handler(req: IncomingMessage, res: ServerResponse)
233315 } ;
234316
235317 // Start React SSR stream
236- pipeable = render ( url . pathname + url . search , null , opts ) ;
318+ try {
319+ pipeable = render ( url . pathname + url . search , null , opts ) ;
320+ } catch ( err ) {
321+ console . error ( `[SSR ${ requestId } ] render crashed:` , err ) ;
322+ respondWithShell ( res , templateParts , requestId , { debug : DEBUG , error : err } ) ;
323+ }
237324 } catch ( err ) {
238- console . error ( "SSR function crashed:" , err ) ;
239- res . statusCode = 500 ;
240- res . setHeader ( "content-type" , "text/plain; charset=utf-8" ) ;
241- res . end ( "SSR function crashed" ) ;
325+ console . error ( `[SSR ${ requestId } ] function crashed:` , err ) ;
326+ const DEBUG = req . headers [ "x-ssr-debug" ] === "1" ;
327+ respondWithShell ( res , loadTemplate ( ) , requestId , { debug : DEBUG , error : err } ) ;
242328 }
243329}
244330
0 commit comments