11#!/usr/bin/env node
22
33import { spawn } from "node:child_process" ;
4- import { mkdir , readFile , writeFile } from "node:fs/promises" ;
4+ import { createServer } from "node:http" ;
5+ import { mkdir , readFile , stat , writeFile } from "node:fs/promises" ;
56import path from "node:path" ;
67import process from "node:process" ;
78
@@ -34,15 +35,17 @@ async function main() {
3435 const mode = args . local ? "local" : "url" ;
3536 const auditUrl = await resolveAuditUrl ( ) ;
3637
37- let localServerProcess = null ;
38+ let localServer = null ;
3839
3940 try {
4041 if ( args . local ) {
4142 const localDir = String ( args . dir ?? DEFAULT_LOCAL_DIR ) ;
4243 const port = Number ( args . port ?? DEFAULT_PORT ) ;
44+ const host = String ( args . host ?? DEFAULT_HOST ) ;
4345
44- localServerProcess = startStaticServer ( localDir , port ) ;
46+ localServer = await startStaticServer ( localDir , host , port ) ;
4547 await waitForUrl ( auditUrl , 30_000 ) ;
48+ await runLocalPreflight ( auditUrl ) ;
4649 }
4750
4851 await runLighthouse ( {
@@ -67,8 +70,8 @@ async function main() {
6770 console . log ( `- Raw JSON: ${ reportJsonPath } ` ) ;
6871 console . log ( `- Codex MD: ${ reportMarkdownPath } ` ) ;
6972 } finally {
70- if ( localServerProcess ) {
71- localServerProcess . kill ( "SIGTERM" ) ;
73+ if ( localServer ) {
74+ await stopStaticServer ( localServer ) ;
7275 }
7376 }
7477}
@@ -89,35 +92,118 @@ async function resolveAuditUrl() {
8992 return String ( args . url ) ;
9093}
9194
92- function startStaticServer ( staticDir , port ) {
93- console . log ( `Starting local static server: ${ staticDir } on port ${ port } ` ) ;
95+ async function startStaticServer ( staticDir , host , port ) {
96+ console . log ( `Starting local static server: ${ staticDir } on ${ host } :${ port } ` ) ;
97+ const rootDir = path . resolve ( staticDir ) ;
9498
95- const child = spawn (
96- getNpxCommand ( ) ,
97- [ "-y" , "serve" , staticDir , "-l" , String ( port ) ] ,
98- {
99- stdio : [ "ignore" , "pipe" , "pipe" ] ,
100- shell : false ,
99+ const server = createServer ( async ( request , response ) => {
100+ try {
101+ const requestUrl = new URL ( request . url ?? "/" , `http://${ host } :${ port } ` ) ;
102+ const pathname = decodeURIComponent ( requestUrl . pathname ) ;
103+ const filePath = await resolveFilePath ( rootDir , pathname ) ;
104+
105+ if ( ! filePath ) {
106+ response . writeHead ( 404 , { "Cache-Control" : "no-store" } ) ;
107+ response . end ( "Not Found" ) ;
108+ return ;
109+ }
110+
111+ const headers = getResponseHeaders ( filePath ) ;
112+ response . writeHead ( 200 , headers ) ;
113+
114+ if ( request . method === "HEAD" ) {
115+ response . end ( ) ;
116+ return ;
117+ }
118+
119+ const content = await readFile ( filePath ) ;
120+ response . end ( content ) ;
121+ } catch ( error ) {
122+ response . writeHead ( 500 , { "Cache-Control" : "no-store" } ) ;
123+ response . end ( "Internal Server Error" ) ;
124+ console . error ( "[serve] request handling failed:" , error ) ;
101125 }
102- ) ;
126+ } ) ;
103127
104- child . stdout . on ( "data" , ( chunk ) => {
105- const text = chunk . toString ( ) . trim ( ) ;
106- if ( text ) console . log ( `[serve] ${ text } ` ) ;
128+ await new Promise ( ( resolve , reject ) => {
129+ server . once ( "error" , reject ) ;
130+ server . listen ( port , host , ( ) => resolve ( ) ) ;
107131 } ) ;
108132
109- child . stderr . on ( "data" , ( chunk ) => {
110- const text = chunk . toString ( ) . trim ( ) ;
111- if ( text ) console . error ( `[serve] ${ text } ` ) ;
133+ return server ;
134+ }
135+
136+ async function stopStaticServer ( server ) {
137+ await new Promise ( ( resolve , reject ) => {
138+ server . close ( ( error ) => {
139+ if ( error ) {
140+ reject ( error ) ;
141+ return ;
142+ }
143+ resolve ( ) ;
144+ } ) ;
112145 } ) ;
146+ }
147+
148+ async function resolveFilePath ( rootDir , pathname ) {
149+ const normalizedPath = pathname === "/" ? "/index.html" : pathname ;
150+ const requestPath = normalizedPath . endsWith ( "/" )
151+ ? `${ normalizedPath } index.html`
152+ : normalizedPath ;
153+
154+ const candidates = [ requestPath ] ;
155+ const withoutFirstSegment = requestPath . replace ( / ^ \/ [ ^ / ] + (? = \/ ) / , "" ) ;
156+ if ( withoutFirstSegment && withoutFirstSegment !== requestPath ) {
157+ candidates . push ( withoutFirstSegment ) ;
158+ }
113159
114- child . on ( "exit" , ( code ) => {
115- if ( code !== null && code !== 0 ) {
116- console . error ( `Local static server exited with code ${ code } ` ) ;
160+ for ( const candidate of candidates ) {
161+ const absolutePath = path . resolve ( rootDir , `.${ candidate } ` ) ;
162+ if ( ! absolutePath . startsWith ( rootDir ) ) {
163+ continue ;
117164 }
118- } ) ;
165+ if ( await fileExists ( absolutePath ) ) {
166+ return absolutePath ;
167+ }
168+ }
169+
170+ return null ;
171+ }
172+
173+ function getResponseHeaders ( filePath ) {
174+ const extension = path . extname ( filePath ) ;
175+ const contentType = CONTENT_TYPES [ extension ] ?? "application/octet-stream" ;
176+ const cacheControl = getCacheControlForPath ( filePath ) ;
177+
178+ return {
179+ "Content-Type" : contentType ,
180+ "Cache-Control" : cacheControl ,
181+ } ;
182+ }
183+
184+ function getCacheControlForPath ( filePath ) {
185+ if ( filePath . includes ( `${ path . sep } _next${ path . sep } static${ path . sep } ` ) ) {
186+ return "public, max-age=31536000, immutable" ;
187+ }
188+
189+ if ( / \. [ 0 - 9 a - f ] { 8 , } \. / i. test ( path . basename ( filePath ) ) ) {
190+ return "public, max-age=31536000, immutable" ;
191+ }
192+
193+ if ( filePath . endsWith ( ".html" ) ) {
194+ return "no-cache" ;
195+ }
196+
197+ return "public, max-age=3600" ;
198+ }
119199
120- return child ;
200+ async function fileExists ( filePath ) {
201+ try {
202+ const entry = await stat ( filePath ) ;
203+ return entry . isFile ( ) ;
204+ } catch {
205+ return false ;
206+ }
121207}
122208
123209async function runLighthouse ( {
@@ -338,6 +424,55 @@ async function waitForUrl(url, timeoutMs) {
338424 throw new Error ( `Local server did not become ready: ${ waitUrl . toString ( ) } ` ) ;
339425}
340426
427+ async function runLocalPreflight ( auditUrl ) {
428+ const preflightUrls = new Set ( [ auditUrl ] ) ;
429+ const html = await fetchText ( auditUrl ) ;
430+ const assetPaths = extractAssetPaths ( html ) ;
431+ const baseUrl = new URL ( auditUrl ) ;
432+
433+ for ( const assetPath of assetPaths ) {
434+ preflightUrls . add ( new URL ( assetPath , baseUrl ) . toString ( ) ) ;
435+ }
436+
437+ const failures = [ ] ;
438+
439+ for ( const url of preflightUrls ) {
440+ const response = await fetch ( url , { method : "HEAD" } ) ;
441+ if ( response . status >= 400 ) {
442+ failures . push ( `${ response . status } ${ url } ` ) ;
443+ }
444+ }
445+
446+ if ( failures . length > 0 ) {
447+ throw new Error (
448+ `Local preflight failed. Critical paths are unreachable:\n${ failures . join ( "\n" ) } `
449+ ) ;
450+ }
451+ }
452+
453+ async function fetchText ( url ) {
454+ const response = await fetch ( url ) ;
455+ if ( ! response . ok ) {
456+ throw new Error ( `Failed to load preflight HTML (${ response . status } ): ${ url } ` ) ;
457+ }
458+
459+ return response . text ( ) ;
460+ }
461+
462+ function extractAssetPaths ( html ) {
463+ const matches = html . matchAll ( / (?: s r c | h r e f ) = [ " ' ] ( [ ^ " ' ] + ) [ " ' ] / g) ;
464+ const assetPaths = new Set ( ) ;
465+
466+ for ( const match of matches ) {
467+ const value = match [ 1 ] ;
468+ if ( ! value . startsWith ( "/" ) ) continue ;
469+ if ( ! value . includes ( "/_next/static/" ) ) continue ;
470+ assetPaths . add ( value ) ;
471+ }
472+
473+ return assetPaths ;
474+ }
475+
341476function runCommand ( command , commandArgs ) {
342477 return new Promise ( ( resolve , reject ) => {
343478 const child = spawn ( command , commandArgs , {
@@ -435,4 +570,16 @@ Options:
435570 --chromeFlags <flags> Extra Chrome flags.
436571 --help Show help.
437572` ) ;
438- }
573+ }
574+
575+ const CONTENT_TYPES = {
576+ ".css" : "text/css; charset=utf-8" ,
577+ ".html" : "text/html; charset=utf-8" ,
578+ ".ico" : "image/x-icon" ,
579+ ".js" : "application/javascript; charset=utf-8" ,
580+ ".json" : "application/json; charset=utf-8" ,
581+ ".svg" : "image/svg+xml" ,
582+ ".txt" : "text/plain; charset=utf-8" ,
583+ ".webp" : "image/webp" ,
584+ ".woff2" : "font/woff2" ,
585+ } ;
0 commit comments