1+ #!/usr/bin/env node
2+ /* eslint-env node */
3+ /* eslint-disable no-console */
4+ // Tiny static file server for the production build.
5+ // - Serves real files from ./build (including dotfile dirs like .well-known)
6+ // - Falls back to /index.html only when the requested path does not exist
7+ // (SPA client-side routing).
8+ const http = require ( "node:http" ) ;
9+ const fs = require ( "node:fs" ) ;
10+ const path = require ( "node:path" ) ;
11+
12+ const root = path . join ( __dirname , "build" ) ;
13+ const port = Number ( process . env . PORT ) || 3000 ;
14+ const host = process . env . HOST || "0.0.0.0" ;
15+
16+ const mime = {
17+ ".html" : "text/html; charset=utf-8" ,
18+ ".js" : "application/javascript; charset=utf-8" ,
19+ ".mjs" : "application/javascript; charset=utf-8" ,
20+ ".css" : "text/css; charset=utf-8" ,
21+ ".json" : "application/json; charset=utf-8" ,
22+ ".map" : "application/json; charset=utf-8" ,
23+ ".svg" : "image/svg+xml" ,
24+ ".png" : "image/png" ,
25+ ".jpg" : "image/jpeg" ,
26+ ".jpeg" : "image/jpeg" ,
27+ ".gif" : "image/gif" ,
28+ ".ico" : "image/x-icon" ,
29+ ".webp" : "image/webp" ,
30+ ".woff" : "font/woff" ,
31+ ".woff2" : "font/woff2" ,
32+ ".ttf" : "font/ttf" ,
33+ ".otf" : "font/otf" ,
34+ ".txt" : "text/plain; charset=utf-8" ,
35+ ".pdf" : "application/pdf" ,
36+ ".csv" : "text/csv; charset=utf-8" ,
37+ ".wasm" : "application/wasm" ,
38+ ".xml" : "application/xml; charset=utf-8"
39+ } ;
40+
41+ function contentType ( filePath ) {
42+ return mime [ path . extname ( filePath ) . toLowerCase ( ) ] || "application/octet-stream" ;
43+ }
44+
45+
46+ function safeJoin ( reqPath ) {
47+ let decoded ;
48+ try {
49+ decoded = decodeURIComponent ( reqPath . split ( "?" ) [ 0 ] . split ( "#" ) [ 0 ] ) ;
50+ } catch {
51+ return null ;
52+ }
53+ const resolved = path . normalize ( path . join ( root , decoded ) ) ;
54+ if ( resolved !== root && ! resolved . startsWith ( root + path . sep ) ) return null ;
55+ return resolved ;
56+ }
57+
58+ function cacheControl ( filePath ) {
59+ const ext = path . extname ( filePath ) . toLowerCase ( ) ;
60+ const relativePath = path . relative ( root , filePath ) ;
61+ const isBuildAsset =
62+ relativePath === "assets" ||
63+ relativePath . startsWith ( `assets${ path . sep } ` ) ;
64+
65+ if ( ext === ".html" || ext === "" ) {
66+ return "no-cache" ;
67+ }
68+
69+ return isBuildAsset
70+ ? "public, max-age=31536000, immutable"
71+ : "no-cache" ;
72+ }
73+
74+ function streamFile ( req , res , filePath , stats ) {
75+ const headers = {
76+ "Content-Type" : contentType ( filePath ) ,
77+ "Content-Length" : stats . size ,
78+ "Last-Modified" : stats . mtime . toUTCString ( ) ,
79+ "Cache-Control" : cacheControl ( filePath )
80+ } ;
81+ if ( req . method === "HEAD" ) {
82+ res . writeHead ( 200 , headers ) ;
83+ return res . end ( ) ;
84+ }
85+ const stream = fs . createReadStream ( filePath ) ;
86+ stream . on ( "error" , ( err ) => {
87+ console . error ( "Stream error:" , err ) ;
88+ if ( ! res . headersSent ) {
89+ res . writeHead ( 500 , { "Content-Type" : "text/plain" } ) ;
90+ res . end ( "Internal Server Error" ) ;
91+ } else {
92+ res . destroy ( ) ;
93+ }
94+ } ) ;
95+ res . on ( "close" , ( ) => {
96+ if ( ! stream . destroyed ) stream . destroy ( ) ;
97+ } ) ;
98+ res . on ( "error" , ( err ) => {
99+ console . error ( "Response error:" , err ) ;
100+ if ( ! stream . destroyed ) stream . destroy ( ) ;
101+ } ) ;
102+ res . writeHead ( 200 , headers ) ;
103+ stream . pipe ( res ) ;
104+ }
105+
106+ function sendIndex ( req , res ) {
107+ const indexPath = path . join ( root , "index.html" ) ;
108+ fs . stat ( indexPath , ( err , stats ) => {
109+ if ( err || ! stats || ! stats . isFile ( ) ) {
110+ res . writeHead ( 500 , { "Content-Type" : "text/plain" } ) ;
111+ return res . end ( "index.html not found" ) ;
112+ }
113+ streamFile ( req , res , indexPath , stats ) ;
114+ } ) ;
115+ }
116+
117+ const server = http . createServer ( ( req , res ) => {
118+ if ( req . method === "OPTIONS" ) {
119+ const filePath = safeJoin ( req . url || "/" ) ;
120+ res . writeHead ( 204 ) ;
121+ return res . end ( ) ;
122+ }
123+
124+ if ( req . method !== "GET" && req . method !== "HEAD" ) {
125+ res . writeHead ( 405 , { Allow : "GET, HEAD, OPTIONS" } ) ;
126+ return res . end ( ) ;
127+ }
128+
129+ const reqUrl = req . url || "/" ;
130+ const filePath = safeJoin ( reqUrl ) ;
131+ if ( ! filePath ) {
132+ res . writeHead ( 400 ) ;
133+ return res . end ( "Bad Request" ) ;
134+ }
135+
136+ fs . stat ( filePath , ( err , stats ) => {
137+ if ( err ) {
138+ // No file at this path → SPA fallback to index.html
139+ return sendIndex ( req , res ) ;
140+ }
141+ if ( stats . isFile ( ) ) {
142+ return streamFile ( req , res , filePath , stats ) ;
143+ }
144+ if ( stats . isDirectory ( ) ) {
145+ const indexInDir = path . join ( filePath , "index.html" ) ;
146+ return fs . stat ( indexInDir , ( dirErr , dirStats ) => {
147+ if ( ! dirErr && dirStats && dirStats . isFile ( ) ) {
148+ return streamFile ( req , res , indexInDir , dirStats ) ;
149+ }
150+ return sendIndex ( req , res ) ;
151+ } ) ;
152+ }
153+ // Path exists but is neither file nor directory → SPA fallback
154+ return sendIndex ( req , res ) ;
155+ } ) ;
156+ } ) ;
157+
158+ server . listen ( port , host , ( ) => {
159+ console . log ( `Serving ${ root } on http://${ host } :${ port } ` ) ;
160+ } ) ;
0 commit comments