Skip to content
This repository was archived by the owner on Mar 13, 2025. It is now read-only.

Commit 5cf84dc

Browse files
authored
Add support for live reload (#429)
This PR brings over Miniflare 2's live reload feature to Miniflare 3. This automatically reloads pages whenever options are changed or the server is restarted. Note that instead of terminating WebSocket's in `workerd`, we terminate them in `Miniflare`. This allows us to instantly send messages to all connected clients once options are applied, reducing the reload latency. ⚡
1 parent be72411 commit 5cf84dc

File tree

5 files changed

+185
-16
lines changed

5 files changed

+185
-16
lines changed

package-lock.json

Lines changed: 49 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/tre/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,15 @@
4646
"stoppable": "^1.1.0",
4747
"undici": "^5.10.0",
4848
"workerd": "^1.20220926.3",
49+
"ws": "^8.11.0",
4950
"zod": "^3.18.0"
5051
},
5152
"devDependencies": {
5253
"@types/debug": "^4.1.7",
5354
"@types/estree": "^1.0.0",
5455
"@types/glob-to-regexp": "^0.4.1",
56+
"@types/http-cache-semantics": "^4.0.1",
5557
"@types/stoppable": "^1.1.1",
56-
"@types/http-cache-semantics": "^4.0.1"
58+
"@types/ws": "^8.5.3"
5759
}
5860
}

packages/tre/src/index.ts

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import assert from "assert";
22
import http from "http";
3+
import net from "net";
4+
import { Duplex } from "stream";
35
import exitHook from "exit-hook";
46
import getPort from "get-port";
57
import { bold, green, grey } from "kleur/colors";
@@ -12,9 +14,9 @@ import {
1214
Response,
1315
fetch,
1416
} from "undici";
17+
import { WebSocketServer } from "ws";
1518
import { z } from "zod";
1619
import { setupCf } from "./cf";
17-
1820
import {
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

Comments
 (0)