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:
parent
7c9ca7c3ae
commit
3bcf621a1c
5 changed files with 139 additions and 3 deletions
18
src/cli.ts
18
src/cli.ts
|
@ -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) {
|
||||||
|
|
|
@ -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">
|
||||||
|
|
74
src/utils/event-server.test.ts
Normal file
74
src/utils/event-server.test.ts
Normal 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
39
src/utils/event-server.ts
Normal 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;
|
||||||
|
}
|
|
@ -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",
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue