Reload page when watch restarts serve

Host an EventSource server that notifies current open browser windows
to reload the page when the watch mode has restarted the server.  This
is the last piece to making the page update live after editing and
saving a source file.
This commit is contained in:
Hornwitser 2025-02-03 10:30:35 +01:00
parent 7c9ca7c3ae
commit 3bcf621a1c
5 changed files with 139 additions and 3 deletions

View file

@ -6,6 +6,7 @@ import { pages } from "./pages.js";
import type { Page } from "./types.js"; import type { Page } from "./types.js";
import { resolveRefs } from "./utils/resolve-refs.js"; import { resolveRefs } from "./utils/resolve-refs.js";
import { createHttpServer } from "./utils/http-server.js"; import { createHttpServer } from "./utils/http-server.js";
import { broadcastMessage, createEventServer } from "./utils/event-server.js";
const srcDir = "build/node"; const srcDir = "build/node";
const webDir = "web"; const webDir = "web";
@ -76,14 +77,27 @@ function serve() {
} }
); );
server.listen(8080); server.listen(8080, () => { process.send?.("listening"); });
console.log("Listening on http://localhost:8080"); console.log("Listening on http://localhost:8080");
} }
function watch(script: string) { function watch(script: string) {
const eventServer = createEventServer();
const eventPort = 8081;
eventServer.httpServer.listen(eventPort);
let child: ChildProcess; let child: ChildProcess;
function start() { function start() {
child = fork(script, ["serve"]); child = fork(script, ["serve"], {
env: {
DEV_EVENT_PORT: String(eventPort),
}
});
child.on("message", message => {
if (message === "listening") {
broadcastMessage(eventServer, "reload");
}
})
} }
function restart() { function restart() {
if (child.exitCode === null) { if (child.exitCode === null) {

View file

@ -5,6 +5,14 @@ interface BaseProps {
children: Node | Node[], children: Node | Node[],
} }
export default function BasePage(props: BaseProps) { export default function BasePage(props: BaseProps) {
let reloadScript = null;
if (process.env.DEV_EVENT_PORT) {
const content = `const url = new URL("/events", location);
url.port = ${process.env.DEV_EVENT_PORT};
const source = new EventSource(url);
source.addEventListener("reload", () => location.reload());`;
reloadScript = <script type="module" defer="">{content}</script>;
}
return <html lang="en"> return <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
@ -12,6 +20,7 @@ export default function BasePage(props: BaseProps) {
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<link rel="stylesheet" href="/style.css" /> <link rel="stylesheet" href="/style.css" />
<script type="module" defer="" src="viewport.js" /> <script type="module" defer="" src="viewport.js" />
{ reloadScript }
</head> </head>
<body> <body>
<header class="header"> <header class="header">

View file

@ -0,0 +1,74 @@
import * as assert from "node:assert/strict";
import { after, before, suite, test } from "node:test";
import * as http from "node:http";
import { broadcastMessage, createEventServer } from "./event-server.js";
import { once } from "node:events";
import type { AddressInfo } from "node:net";
suite("function createEventServer", () => {
let server: ReturnType<typeof createEventServer>;
let baseUrl: URL;
before(async () => {
server = createEventServer();
server.httpServer.listen(0, "localhost");
await once(server.httpServer, "listening");
baseUrl = new URL(`http://localhost:${(server.httpServer.address() as AddressInfo).port}`)
});
after(() => {
server.httpServer.close();
})
async function streamRequest(method: string, ref: string) {
const url = new URL(ref, baseUrl);
const request = http.request(url, {
method,
timeout: 1000,
});
request.end();
const response: http.IncomingMessage = (await once(request, "response"))[0];
return { request, response };
}
async function makeRequest(method: string, ref: string) {
type WithBody = Awaited<ReturnType<typeof streamRequest>> & { response: { body: Buffer } };
const { request, response } = await streamRequest(method, ref) as WithBody;
response.body = Buffer.concat(await response.toArray());
return { request, response };
}
test("GET /events", async () => {
const url = new URL("/events", baseUrl);
const { response } = await streamRequest("GET", "/events");
assert.equal(response.statusCode, 200);
assert.equal(response.headers["content-type"], "text/event-stream");
broadcastMessage(server, "test");
broadcastMessage(server, "exit");
for (const stream of server.streams) {
stream.end();
}
const data = Buffer.concat(await response.toArray()).toString();
assert.equal(data, "event: test\ndata\n\nevent: exit\ndata\n\n");
});
async function badRequestTest(method: string) {
const { response } = await makeRequest(method, "/bad-request");
const content = Buffer.from("400 Bad Request");
assert.equal(response.statusCode, 400);
assert.equal(response.headers["content-type"], "text/plain");
assert.equal(Number.parseInt(response.headers["content-length"]!), content.length);
assert.deepEqual(response.body, method === "HEAD" ? Buffer.alloc(0) : content);
}
test("GET /bad-request", async () => {
await badRequestTest("GET");
});
test("HEAD /bad-request", async () => {
await badRequestTest("HEAD");
});
test("POST /bad-request", async () => {
await badRequestTest("POST");
});
});

39
src/utils/event-server.ts Normal file
View file

@ -0,0 +1,39 @@
import * as http from "node:http";
import { writeBadRequest } from "./http-server.js";
export interface EventServer {
httpServer: http.Server;
streams: Set<http.ServerResponse>;
}
export function broadcastMessage(server: EventServer, event: string) {
for (const stream of server.streams) {
stream.write(`event: ${event}\ndata\n\n`);
}
}
function startEventStream(server: EventServer, response: http.ServerResponse) {
response.statusCode = 200;
response.statusMessage = "OK";
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Content-Type", "text/event-stream");
response.flushHeaders();
server.streams.add(response);
response.on("close", () => {
server.streams.delete(response);
});
}
export function createEventServer() {
const server = {
streams: new Set<http.ServerResponse>(),
httpServer: http.createServer((request, response) => {
if (request.method === "GET" && request.url === "/events") {
startEventStream(server, response);
return;
}
writeBadRequest(response, request.method === "HEAD");
}),
};
return server;
}

View file

@ -32,7 +32,7 @@ function writeNotFound(response: http.ServerResponse, suppressContent = false) {
); );
} }
function writeBadRequest(response: http.ServerResponse, suppressContent = false) { export function writeBadRequest(response: http.ServerResponse, suppressContent = false) {
writeResponse( writeResponse(
response, response,
400, "Bad Request", 400, "Bad Request",