11import assert from "assert" ;
22import http from "http" ;
3+ import net from "net" ;
4+ import { Duplex } from "stream" ;
35import exitHook from "exit-hook" ;
46import getPort from "get-port" ;
57import { bold , green , grey } from "kleur/colors" ;
@@ -12,9 +14,9 @@ import {
1214 Response ,
1315 fetch ,
1416} from "undici" ;
17+ import { WebSocketServer } from "ws" ;
1518import { z } from "zod" ;
1619import { setupCf } from "./cf" ;
17-
1820import {
1921 GatewayConstructor ,
2022 GatewayFactory ,
@@ -178,6 +180,8 @@ export class Miniflare {
178180 // Aborted when dispose() is called
179181 readonly #disposeController: AbortController ;
180182 #loopbackServer?: StoppableServer ;
183+ #loopbackPort?: number ;
184+ readonly #liveReloadServer: WebSocketServer ;
181185
182186 constructor ( opts : MiniflareOptions ) {
183187 // Initialise plugin gateway factories and routers
@@ -201,6 +205,7 @@ export class Miniflare {
201205 ) ;
202206
203207 this . #disposeController = new AbortController ( ) ;
208+ this . #liveReloadServer = new WebSocketServer ( { noServer : true } ) ;
204209 this . #runtimeMutex = new Mutex ( ) ;
205210 this . #initPromise = this . #runtimeMutex. runWith ( ( ) => this . #init( ) ) ;
206211 }
@@ -218,22 +223,32 @@ export class Miniflare {
218223 }
219224 }
220225
226+ #handleReload( ) {
227+ // Reload all connected live reload clients
228+ for ( const ws of this . #liveReloadServer. clients ) {
229+ ws . close ( 1012 , "Service Restart" ) ;
230+ }
231+ }
232+
221233 async #init( ) {
222234 // This function must be run with `#runtimeMutex` held
223235
224236 // Start loopback server (how the runtime accesses with Miniflare's storage)
225- this . #loopbackServer = await this . #startLoopbackServer( 0 , "127.0.0.1" ) ;
237+ // using the same host as the main runtime server. This means we can use the
238+ // loopback server for live reload updates too.
239+ const host = this . #sharedOpts. core . host ?? "127.0.0.1" ;
240+ this . #loopbackServer = await this . #startLoopbackServer( 0 , host ) ;
226241 const address = this . #loopbackServer. address ( ) ;
227242 // Note address would be string with unix socket
228243 assert ( address !== null && typeof address === "object" ) ;
229244 // noinspection JSObjectNullOrUndefined
230- const loopbackPort = address . port ;
245+ this . # loopbackPort = address . port ;
231246
232247 // Start runtime
233248 const opts : RuntimeOptions = {
234- entryHost : this . #sharedOpts . core . host ?? "127.0.0.1" ,
249+ entryHost : host ,
235250 entryPort : this . #sharedOpts. core . port ?? ( await getPort ( { port : 8787 } ) ) ,
236- loopbackPort,
251+ loopbackPort : this . #loopbackPort ,
237252 inspectorPort : this . #sharedOpts. core . inspectorPort ,
238253 verbose : this . #sharedOpts. core . verbose ,
239254 } ;
@@ -248,8 +263,9 @@ export class Miniflare {
248263
249264 // Wait for runtime to start
250265 if ( ( await this . #waitForRuntime( ) ) && ! this . #runtimeMutex. hasWaiting ) {
251- // Only log if there aren't pending updates
266+ // Only log and trigger reload if there aren't pending updates
252267 console . log ( bold ( green ( `Ready on ${ this . #runtimeEntryURL} 🎉` ) ) ) ;
268+ this . #handleReload( ) ;
253269 }
254270 }
255271
@@ -345,12 +361,39 @@ export class Miniflare {
345361 res . end ( ) ;
346362 } ;
347363
364+ #handleLoopbackUpgrade = (
365+ req : http . IncomingMessage ,
366+ socket : Duplex ,
367+ head : Buffer
368+ ) => {
369+ // Only interested in pathname so base URL doesn't matter
370+ const { pathname } = new URL ( req . url ?? "" , "http://localhost" ) ;
371+
372+ // If this is the path for live-reload, handle the request
373+ if ( pathname === "/core/reload" ) {
374+ this . #liveReloadServer. handleUpgrade ( req , socket , head , ( ws ) => {
375+ this . #liveReloadServer. emit ( "connection" , ws , req ) ;
376+ } ) ;
377+ return ;
378+ }
379+
380+ // Otherwise, return a not found HTTP response
381+ const res = new http . ServerResponse ( req ) ;
382+ // `socket` is guaranteed to be an instance of `net.Socket`:
383+ // https://nodejs.org/api/http.html#event-upgrade_1
384+ assert ( socket instanceof net . Socket ) ;
385+ res . assignSocket ( socket ) ;
386+ res . writeHead ( 404 ) ;
387+ res . end ( ) ;
388+ } ;
389+
348390 #startLoopbackServer(
349391 port : string | number ,
350392 hostname ?: string
351393 ) : Promise < StoppableServer > {
352394 return new Promise ( ( resolve ) => {
353395 const server = stoppable ( http . createServer ( this . #handleLoopback) ) ;
396+ server . on ( "upgrade" , this . #handleLoopbackUpgrade) ;
354397 server . listen ( port as any , hostname , ( ) => resolve ( server ) ) ;
355398 } ) ;
356399 }
@@ -401,6 +444,9 @@ export class Miniflare {
401444 const optionsVersion = this . #optionsVersion;
402445 const allWorkerOpts = this . #workerOpts;
403446 const sharedOpts = this . #sharedOpts;
447+ const loopbackPort = this . #loopbackPort;
448+ // #assembleConfig is always called after the loopback server is created
449+ assert ( loopbackPort !== undefined ) ;
404450
405451 sharedOpts . core . cf = await setupCf ( sharedOpts . core . cf ) ;
406452
@@ -447,6 +493,7 @@ export class Miniflare {
447493 workerIndex : i ,
448494 durableObjectClassNames,
449495 additionalModules,
496+ loopbackPort,
450497 } ) ;
451498 if ( pluginServices !== undefined ) {
452499 for ( const service of pluginServices ) {
@@ -503,10 +550,11 @@ export class Miniflare {
503550 await this . #runtime. updateConfig ( configBuffer ) ;
504551
505552 if ( ( await this . #waitForRuntime( ) ) && ! this . #runtimeMutex. hasWaiting ) {
506- // Only log if this was the last pending update
553+ // Only log and trigger reload if this was the last pending update
507554 console . log (
508555 bold ( green ( `Updated and ready on ${ this . #runtimeEntryURL} 🎉` ) )
509556 ) ;
557+ this . #handleReload( ) ;
510558 }
511559 }
512560
0 commit comments