1+ import { Server } from "node:http" ;
2+ import { err , ok , Result } from "neverthrow" ;
13import { createServer as createLiveReloadServer } from "livereload" ;
24import connectLiveReload from "connect-livereload" ;
35import express , { Express } from "express" ;
@@ -11,6 +13,7 @@ import { NetworkService } from "../../infrastructure/network-service.js";
1113import { UrlPath } from "../../types/file/urlPath.js" ;
1214import { LauncherService } from "../../infrastructure/launcher-service.js" ;
1315import { DebounceService } from "../../infrastructure/debounce-service.js" ;
16+ import { BuildContext } from "../../types/build-context.js" ;
1417
1518export class PortalServeAction {
1619 private readonly prompts : PortalServePrompts = new PortalServePrompts ( ) ;
@@ -35,28 +38,68 @@ export class PortalServeAction {
3538 hotReload : boolean ,
3639 onAfterServe ?: ( ) => void
3740 ) : Promise < ActionResult > {
38- const generatePortalAction = new GenerateAction ( this . configDir , this . commandMetadata , this . authKey ) ;
39- const result = await generatePortalAction . execute ( buildDirectory , portalDirectory , true , false ) ;
40- if ( result . isFailed ( ) ) {
41+ const buildContext = new BuildContext ( buildDirectory ) ;
42+ if ( ! ( await buildContext . exists ( ) ) ) {
43+ this . prompts . noPortalSource ( buildDirectory ) ;
44+ return ActionResult . failed ( ) ;
45+ }
46+ let buildConfig ;
47+ try {
48+ buildConfig = await buildContext . getBuildFileContents ( ) ;
49+ } catch {
50+ this . prompts . invalidBuildConfig ( buildDirectory ) ;
4151 return ActionResult . failed ( ) ;
4252 }
4353
4454 const servePort = await this . networkService . getServerPort ( [ port , 3000 , 3001 , 3002 ] ) ;
45- if ( servePort != port && ! onAfterServe ) {
55+ if ( servePort != port ) {
4656 this . prompts . usingFallbackPort ( port , servePort ) ;
4757 }
58+ const serveUrl = new UrlPath ( `http://localhost:${ servePort } ` ) ;
59+
60+ // Update the configured localhost base URL to the actual serve URL BEFORE
61+ // generation bakes it into the portal artifacts; otherwise the portal would load
62+ // its content from the wrong port and fail to render.
63+ const updatedBuildConfig = buildConfig . updateBuildConfigBaseUrl ( serveUrl ) ;
64+ if ( updatedBuildConfig !== buildConfig ) {
65+ await buildContext . updateBuildFileContents ( updatedBuildConfig ) ;
66+ this . prompts . baseUrlPortUpdated ( serveUrl ) ;
67+ }
68+
69+ const generatePortalAction = new GenerateAction ( this . configDir , this . commandMetadata , this . authKey ) ;
70+ const result = await generatePortalAction . execute ( buildDirectory , portalDirectory , true , false ) ;
71+ if ( result . isFailed ( ) ) {
72+ return ActionResult . failed ( ) ;
73+ }
4874
4975 const liveReloadPort = await this . networkService . getServerPort ( [ 35729 , 35730 , 35731 , 35732 ] ) ;
5076 const liveReloadServer = createLiveReloadServer ( { port : liveReloadPort } ) ;
77+
78+ // livereload attaches its "error" handler to the inner WebSocket server, not to the
79+ // HTTP server it binds the port on, so a failed bind (e.g. the port was taken in the
80+ // gap since getServerPort) emits an unhandled "error" that would crash the process.
81+ // Guard the HTTP server the same way as the main server below.
82+ const liveReloadHttpServer = ( liveReloadServer as unknown as { config : { server : Server } } ) . config . server ;
83+ if ( ( await this . waitForServerListening ( liveReloadHttpServer ) ) . isErr ( ) ) {
84+ liveReloadServer . close ( ) ;
85+ this . prompts . serverStartFailed ( liveReloadPort ) ;
86+ return ActionResult . failed ( ) ;
87+ }
88+
5189 const server = this . application
5290 . use ( connectLiveReload ( ) )
5391 . use ( express . static ( portalDirectory . toString ( ) , { extensions : [ "html" ] } ) )
5492 . listen ( servePort ) ;
5593
56- const portalUrl = new UrlPath ( `http://localhost:${ servePort } ` ) ;
57- this . prompts . portalServed ( portalUrl ) ;
94+ if ( ( await this . waitForServerListening ( server ) ) . isErr ( ) ) {
95+ liveReloadServer . close ( ) ;
96+ this . prompts . serverStartFailed ( servePort ) ;
97+ return ActionResult . failed ( ) ;
98+ }
99+
100+ this . prompts . portalServed ( serveUrl ) ;
58101 if ( openInBrowser ) {
59- await this . launcherService . openUrlInBrowser ( portalUrl ) ;
102+ await this . launcherService . openUrlInBrowser ( serveUrl ) ;
60103 }
61104 this . prompts . promptForExit ( ) ;
62105
@@ -102,6 +145,22 @@ export class PortalServeAction {
102145
103146 await debounceService . batchSingleRequest ( async ( ) => {
104147 this . prompts . changesDetected ( ) ;
148+ // Re-reconcile on every hot-reload cycle: portalSettings.baseUrl takes
149+ // precedence over generatePortal.baseUrl and may have been added or changed
150+ // since serve started. If a mismatch is found the file is rewritten, which
151+ // triggers a second watcher event — the debounce queues it, and the second
152+ // cycle sees no mismatch and generates cleanly.
153+ try {
154+ const latestConfig = await buildContext . getBuildFileContents ( ) ;
155+ const reconciledConfig = latestConfig . updateBuildConfigBaseUrl ( serveUrl ) ;
156+ if ( reconciledConfig !== latestConfig ) {
157+ await buildContext . updateBuildFileContents ( reconciledConfig ) ;
158+ this . prompts . baseUrlPortUpdated ( serveUrl ) ;
159+ }
160+ } catch {
161+ // Build file temporarily unreadable (e.g. mid-save); skip reconciliation
162+ // this cycle. GenerateAction will surface the error if the file is broken.
163+ }
105164 await generatePortalAction . execute ( buildDirectory , portalDirectory , true , false , false ) ;
106165 liveReloadServer . refresh ( portalDirectory . toString ( ) ) ;
107166 this . clearStandardInput ( ) ;
@@ -122,6 +181,23 @@ export class PortalServeAction {
122181 return ActionResult . success ( ) ;
123182 }
124183
184+ // Resolves ok once the server is bound, or err with the bind error (e.g. EADDRINUSE)
185+ // so a failed listen is reported cleanly instead of crashing via an unhandled "error".
186+ private waitForServerListening ( server : Server ) : Promise < Result < void , Error > > {
187+ return new Promise ( ( resolve ) => {
188+ const onListening = ( ) => {
189+ server . removeListener ( "error" , onError ) ;
190+ resolve ( ok ( undefined ) ) ;
191+ } ;
192+ const onError = ( error : Error ) => {
193+ server . removeListener ( "listening" , onListening ) ;
194+ resolve ( err ( error ) ) ;
195+ } ;
196+ server . once ( "listening" , onListening ) ;
197+ server . once ( "error" , onError ) ;
198+ } ) ;
199+ }
200+
125201 // This clears the standard input to allow interrupts like CTRL+C to work properly.
126202 private clearStandardInput ( ) {
127203 if ( process . platform !== "darwin" && process . stdin . isTTY ) {
0 commit comments