@ashwin-pc/pi-web 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/supervisor.ts ADDED
@@ -0,0 +1,205 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { createServer, request, type IncomingMessage, type ServerResponse } from "node:http";
3
+ import net from "node:net";
4
+
5
+ const publicHost = process.env.HOST || "127.0.0.1";
6
+ const publicPort = Number(process.env.PORT || 8787);
7
+ const childHost = process.env.PI_WEB_CHILD_HOST || "127.0.0.1";
8
+ const childPort = Number(process.env.PI_WEB_CHILD_PORT || 8788);
9
+ const token = process.env.PI_WEB_TOKEN || "";
10
+ const restartGraceMs = Number(process.env.PI_WEB_RESTART_GRACE_MS || 250);
11
+
12
+ let child: ChildProcess | undefined;
13
+ let childStarting = false;
14
+ let childGeneration = 0;
15
+
16
+ function requestToken(req: IncomingMessage): string {
17
+ const auth = req.headers.authorization || "";
18
+ if (auth.startsWith("Bearer ")) return auth.slice("Bearer ".length);
19
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
20
+ return url.searchParams.get("token") || "";
21
+ }
22
+
23
+ function isAuthorized(req: IncomingMessage): boolean {
24
+ return !token || requestToken(req) === token;
25
+ }
26
+
27
+ function sendJson(res: ServerResponse, status: number, value: unknown): void {
28
+ const body = JSON.stringify(value);
29
+ res.writeHead(status, {
30
+ "content-type": "application/json; charset=utf-8",
31
+ "content-length": Buffer.byteLength(body),
32
+ });
33
+ res.end(body);
34
+ }
35
+
36
+ function startChild(): void {
37
+ if (childStarting) return;
38
+ childStarting = true;
39
+ childGeneration += 1;
40
+ const generation = childGeneration;
41
+
42
+ const env = {
43
+ ...process.env,
44
+ HOST: childHost,
45
+ PORT: String(childPort),
46
+ PI_WEB_DEV: process.env.PI_WEB_DEV || "1",
47
+ PI_WEB_SUPERVISED: "1",
48
+ };
49
+
50
+ console.log(`[supervisor] starting child #${generation} on ${childHost}:${childPort}`);
51
+ const nextChild = spawn(process.execPath, ["--import", "tsx", "server.ts"], {
52
+ cwd: process.cwd(),
53
+ env,
54
+ stdio: ["ignore", "pipe", "pipe"],
55
+ });
56
+ child = nextChild;
57
+
58
+ nextChild.stdout?.on("data", (chunk) => process.stdout.write(`[server] ${chunk}`));
59
+ nextChild.stderr?.on("data", (chunk) => process.stderr.write(`[server] ${chunk}`));
60
+
61
+ nextChild.on("spawn", () => {
62
+ childStarting = false;
63
+ });
64
+
65
+ nextChild.on("exit", (code, signal) => {
66
+ if (child?.pid === undefined || childGeneration !== generation) return;
67
+ console.log(`[supervisor] child #${generation} exited code=${code ?? ""} signal=${signal ?? ""}`);
68
+ child = undefined;
69
+ childStarting = false;
70
+ if (code !== 0 && signal !== "SIGTERM" && signal !== "SIGINT") {
71
+ setTimeout(startChild, 1000);
72
+ }
73
+ });
74
+ }
75
+
76
+ function stopChild(): Promise<void> {
77
+ return new Promise((resolve) => {
78
+ const current = child;
79
+ if (!current || current.killed) return resolve();
80
+
81
+ const timeout = setTimeout(() => {
82
+ current.kill("SIGKILL");
83
+ resolve();
84
+ }, 5000);
85
+
86
+ current.once("exit", () => {
87
+ clearTimeout(timeout);
88
+ resolve();
89
+ });
90
+
91
+ current.kill("SIGTERM");
92
+ });
93
+ }
94
+
95
+ async function restartChild(): Promise<void> {
96
+ console.log("[supervisor] restarting child");
97
+ await stopChild();
98
+ await new Promise((resolve) => setTimeout(resolve, restartGraceMs));
99
+ startChild();
100
+ }
101
+
102
+ function destroyQuietly(socket: NodeJS.WritableStream & { destroy?: (error?: Error) => void }, error?: Error): void {
103
+ socket.destroy?.(error);
104
+ }
105
+
106
+ function proxyHttp(req: IncomingMessage, res: ServerResponse): void {
107
+ const headers = { ...req.headers, host: `${childHost}:${childPort}` };
108
+ const upstream = request({
109
+ host: childHost,
110
+ port: childPort,
111
+ method: req.method,
112
+ path: req.url,
113
+ headers,
114
+ }, (upstreamRes) => {
115
+ res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers);
116
+ upstreamRes.pipe(res);
117
+ });
118
+
119
+ upstream.on("error", (error) => {
120
+ if (!res.headersSent && !res.destroyed) {
121
+ sendJson(res, 502, { ok: false, error: `pi-web child unavailable: ${error.message}` });
122
+ } else {
123
+ destroyQuietly(res, error);
124
+ }
125
+ });
126
+
127
+ req.on("error", (error) => destroyQuietly(upstream, error));
128
+ res.on("error", (error) => destroyQuietly(upstream, error));
129
+ req.pipe(upstream);
130
+ }
131
+
132
+ const supervisor = createServer(async (req, res) => {
133
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
134
+
135
+ if (url.pathname === "/api/restart" || url.pathname === "/__supervisor/restart") {
136
+ if (!isAuthorized(req)) return sendJson(res, 401, { ok: false, error: "Unauthorized" });
137
+ sendJson(res, 202, { ok: true, message: "Restarting pi-web child" });
138
+ void restartChild();
139
+ return;
140
+ }
141
+
142
+ if (url.pathname === "/__supervisor/status") {
143
+ if (!isAuthorized(req)) return sendJson(res, 401, { ok: false, error: "Unauthorized" });
144
+ return sendJson(res, 200, {
145
+ ok: true,
146
+ childPid: child?.pid,
147
+ childGeneration,
148
+ childHost,
149
+ childPort,
150
+ });
151
+ }
152
+
153
+ proxyHttp(req, res);
154
+ });
155
+
156
+ supervisor.on("upgrade", (req, socket, head) => {
157
+ const upstream = net.connect(childPort, childHost);
158
+ let closed = false;
159
+
160
+ const closeBoth = (error?: Error) => {
161
+ if (closed) return;
162
+ closed = true;
163
+ destroyQuietly(socket, error);
164
+ destroyQuietly(upstream, error);
165
+ };
166
+
167
+ socket.on("error", (error) => closeBoth(error));
168
+ upstream.on("error", (error) => closeBoth(error));
169
+ socket.on("close", () => closeBoth());
170
+ upstream.on("close", () => closeBoth());
171
+
172
+ upstream.on("connect", () => {
173
+ upstream.write(`${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`);
174
+ for (const [name, value] of Object.entries(req.headers)) {
175
+ if (Array.isArray(value)) {
176
+ for (const item of value) upstream.write(`${name}: ${item}\r\n`);
177
+ } else if (value !== undefined) {
178
+ upstream.write(`${name}: ${value}\r\n`);
179
+ }
180
+ }
181
+ upstream.write("\r\n");
182
+ if (head.length) upstream.write(head);
183
+ socket.pipe(upstream).pipe(socket);
184
+ });
185
+ });
186
+
187
+ supervisor.on("clientError", (_error, socket) => {
188
+ socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
189
+ });
190
+
191
+ function shutdown(): void {
192
+ console.log("[supervisor] shutting down");
193
+ supervisor.close();
194
+ void stopChild().finally(() => process.exit(0));
195
+ }
196
+
197
+ process.on("SIGINT", shutdown);
198
+ process.on("SIGTERM", shutdown);
199
+
200
+ startChild();
201
+ supervisor.listen(publicPort, publicHost, () => {
202
+ console.log(`[supervisor] listening on http://${publicHost}:${publicPort}`);
203
+ console.log(`[supervisor] child target http://${childHost}:${childPort}`);
204
+ console.log(token ? "[supervisor] restart/status endpoints require token" : "[supervisor] auth disabled");
205
+ });