11/**
22 * Framework-agnostic, in-process entry point for @workflow/web.
33 *
4- * Unlike `server.js` (which starts a standalone Express HTTP server), this
4+ * Unlike `server.js` (which starts a standalone HTTP server of its own ), this
55 * exports a single Web-standard fetch handler that another server can mount
66 * under an arbitrary base path — e.g. `@workflow/nitro` mounting the dashboard
77 * at `/_workflow` without spawning a second server/port.
1717 */
1818
1919import { existsSync } from 'node:fs' ;
20- import { readFile , stat } from 'node:fs/promises' ;
2120import path from 'node:path' ;
2221import { fileURLToPath , pathToFileURL } from 'node:url' ;
2322import { recordDashboard } from './registry.js' ;
23+ import { createStaticHandler } from './static.js' ;
2424
2525const __dirname = path . dirname ( fileURLToPath ( import . meta. url ) ) ;
2626const buildDir = path . resolve ( __dirname , 'build' ) ;
2727const clientDir = path . join ( buildDir , 'client' ) ;
2828const serverEntry = path . join ( buildDir , 'server' , 'index.js' ) ;
2929
30- // Minimal extension -> MIME map for serving the static client build. A wrong
31- // Content-Type on the entry module breaks the whole app, so be explicit.
32- const MIME_TYPES = {
33- '.js' : 'text/javascript' ,
34- '.mjs' : 'text/javascript' ,
35- '.css' : 'text/css' ,
36- '.json' : 'application/json' ,
37- '.map' : 'application/json' ,
38- '.html' : 'text/html' ,
39- '.ico' : 'image/x-icon' ,
40- '.svg' : 'image/svg+xml' ,
41- '.png' : 'image/png' ,
42- '.jpg' : 'image/jpeg' ,
43- '.jpeg' : 'image/jpeg' ,
44- '.gif' : 'image/gif' ,
45- '.webp' : 'image/webp' ,
46- '.avif' : 'image/avif' ,
47- '.woff' : 'font/woff' ,
48- '.woff2' : 'font/woff2' ,
49- '.ttf' : 'font/ttf' ,
50- '.otf' : 'font/otf' ,
51- '.txt' : 'text/plain' ,
52- '.wasm' : 'application/wasm' ,
53- } ;
54-
5530/** Normalize a mount path: `/` or empty -> "" (root); otherwise strip trailing slash. */
5631function normalizeBasename ( basename ) {
5732 if ( ! basename || basename === '/' ) return '' ;
@@ -94,6 +69,16 @@ async function buildHandler(basename) {
9469 ) ;
9570 }
9671 const ssr = mod . createFetchHandler ( basename || '/' ) ;
72+ const serveStatic = createStaticHandler ( {
73+ dir : clientDir ,
74+ basename,
75+ // The host owns the connection and may compress the response itself. Even
76+ // where it doesn't, this runs inside someone else's process — spending its
77+ // CPU and libuv threads on brotli for what is almost always a localhost
78+ // dev server is a bad trade. `server.js` leaves compression on, since it
79+ // can be self-hosted over a real network.
80+ compress : false ,
81+ } ) ;
9782
9883 return async ( request ) => {
9984 // Advertise this dashboard so the CLI can defer to it (best-effort, once).
@@ -111,76 +96,8 @@ async function buildHandler(basename) {
11196 // ignore — registration must never affect request handling
11297 }
11398
114- const staticResponse = await tryServeStatic ( request , basename ) ;
99+ const staticResponse = await serveStatic ( request ) ;
115100 if ( staticResponse ) return staticResponse ;
116101 return ssr ( request ) ;
117102 } ;
118103}
119-
120- /**
121- * Resolve a request to a path-within-the-client-build (relative to `clientDir`),
122- * stripping `basename` and guarding against traversal. Returns null when the
123- * request can't map to a client file (so it should fall through to the SSR
124- * handler) — e.g. the index, a directory, or an out-of-tree path.
125- */
126- function resolveClientFile ( request , basename ) {
127- if ( request . method !== 'GET' && request . method !== 'HEAD' ) return null ;
128-
129- let pathname ;
130- try {
131- pathname = decodeURIComponent ( new URL ( request . url ) . pathname ) ;
132- } catch {
133- return null ;
134- }
135-
136- if ( basename ) {
137- if ( ! pathname . startsWith ( `${ basename } /` ) ) return null ; // index/other -> SSR
138- pathname = pathname . slice ( basename . length ) ;
139- }
140-
141- const relative = pathname . replace ( / ^ \/ + / , '' ) ;
142- if ( ! relative || relative . endsWith ( '/' ) ) return null ;
143-
144- const filePath = path . join ( clientDir , relative ) ;
145- // Guard against path traversal escaping the client build directory.
146- if ( filePath !== clientDir && ! filePath . startsWith ( clientDir + path . sep ) ) {
147- return null ;
148- }
149- return { filePath, relative } ;
150- }
151-
152- /**
153- * Serve a file from the prebuilt client bundle if the request maps to one;
154- * otherwise return null so the request falls through to the SSR handler.
155- */
156- async function tryServeStatic ( request , basename ) {
157- const resolved = resolveClientFile ( request , basename ) ;
158- if ( ! resolved ) return null ;
159- const { filePath, relative } = resolved ;
160-
161- let stats ;
162- try {
163- stats = await stat ( filePath ) ;
164- } catch {
165- return null ;
166- }
167- if ( ! stats . isFile ( ) ) return null ;
168-
169- const ext = path . extname ( filePath ) . toLowerCase ( ) ;
170- const headers = new Headers ( {
171- 'Content-Type' : MIME_TYPES [ ext ] ?? 'application/octet-stream' ,
172- // Hashed assets are content-addressed and safe to cache forever; other
173- // client files (e.g. favicon) get a short cache.
174- 'Cache-Control' : relative . startsWith ( 'assets/' )
175- ? 'public, max-age=31536000, immutable'
176- : 'public, max-age=3600' ,
177- } ) ;
178-
179- if ( request . method === 'HEAD' ) {
180- headers . set ( 'Content-Length' , String ( stats . size ) ) ;
181- return new Response ( null , { status : 200 , headers } ) ;
182- }
183-
184- const body = await readFile ( filePath ) ;
185- return new Response ( body , { status : 200 , headers } ) ;
186- }
0 commit comments