procdeck 0.1.0

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/dist/cli.js ADDED
@@ -0,0 +1,688 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ DEFAULT_UI_PORT,
4
+ loadConfig,
5
+ resolveSpec,
6
+ usesOwnPort
7
+ } from "./chunk-SBVZ6G4G.js";
8
+
9
+ // src/cli.ts
10
+ import { Effect as Effect3, Fiber as Fiber2 } from "effect";
11
+
12
+ // src/supervisor.ts
13
+ import { Effect, Fiber, Latch, PubSub } from "effect";
14
+
15
+ // src/proc.ts
16
+ import { execFile } from "child_process";
17
+ import { chmodSync, statSync } from "fs";
18
+ import { createRequire } from "module";
19
+ import { dirname, join } from "path";
20
+ import { spawn } from "node-pty";
21
+ var fixSpawnHelperExecBit = () => {
22
+ if (process.platform !== "darwin") return;
23
+ try {
24
+ const require2 = createRequire(import.meta.url);
25
+ const packageRoot = dirname(require2.resolve("node-pty/package.json"));
26
+ const helper = join(packageRoot, "prebuilds", `darwin-${process.arch}`, "spawn-helper");
27
+ const mode = statSync(helper).mode;
28
+ if ((mode & 73) === 0) chmodSync(helper, mode | 493);
29
+ } catch {
30
+ }
31
+ };
32
+ fixSpawnHelperExecBit();
33
+ var describeCommand = (spec) => spec.shell ?? (spec.cmd ?? []).join(" ");
34
+ var resolveArgv = (spec) => {
35
+ if (spec.shell !== void 0) {
36
+ return { file: process.env["SHELL"] ?? "/bin/sh", args: ["-c", spec.shell] };
37
+ }
38
+ const [file, ...args] = spec.cmd ?? [];
39
+ if (file === void 0) {
40
+ throw new Error(`proc "${spec.id}" defines neither shell nor cmd`);
41
+ }
42
+ return { file, args };
43
+ };
44
+ var buildEnv = (spec) => {
45
+ const env = {};
46
+ for (const [key, value] of Object.entries(process.env)) {
47
+ if (value !== void 0) env[key] = value;
48
+ }
49
+ env["TERM"] = "xterm-256color";
50
+ env["FORCE_COLOR"] = "1";
51
+ return { ...env, ...spec.env };
52
+ };
53
+ var spawnProc = (options) => {
54
+ const { spec, root, cols, rows, onData } = options;
55
+ const { file, args } = resolveArgv(spec);
56
+ const pty = spawn(file, args, {
57
+ name: "xterm-256color",
58
+ cols,
59
+ rows,
60
+ cwd: spec.cwd === void 0 ? root : spec.cwd,
61
+ env: buildEnv(spec)
62
+ });
63
+ pty.onData(onData);
64
+ const exited = new Promise((resolve) => {
65
+ pty.onExit(
66
+ ({ exitCode, signal }) => resolve(signal === void 0 ? { exitCode } : { exitCode, signal })
67
+ );
68
+ });
69
+ return { pty, pid: pty.pid, exited };
70
+ };
71
+ var runPreflight = (shell, cwd, timeoutMs = 15e3) => new Promise((resolve) => {
72
+ execFile(
73
+ process.env["SHELL"] ?? "/bin/sh",
74
+ ["-c", shell],
75
+ { cwd, timeout: timeoutMs, maxBuffer: 1024 * 1024 },
76
+ (error, stdout, stderr) => {
77
+ resolve({ ok: error === null, output: `${stdout}${stderr}` });
78
+ }
79
+ );
80
+ });
81
+ var killTree = (pid, signal) => {
82
+ try {
83
+ process.kill(-pid, signal);
84
+ } catch {
85
+ try {
86
+ process.kill(pid, signal);
87
+ } catch {
88
+ }
89
+ }
90
+ };
91
+ var isGroupAlive = (pid) => {
92
+ try {
93
+ process.kill(-pid, 0);
94
+ return true;
95
+ } catch {
96
+ try {
97
+ process.kill(pid, 0);
98
+ return true;
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+ };
104
+ var delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
105
+ var terminate = async (proc, graceMs = 4e3) => {
106
+ if (!isGroupAlive(proc.pid)) return;
107
+ killTree(proc.pid, "SIGTERM");
108
+ const timedOut = /* @__PURE__ */ Symbol("timeout");
109
+ const outcome = await Promise.race([proc.exited, delay(graceMs).then(() => timedOut)]);
110
+ if (outcome !== timedOut && !isGroupAlive(proc.pid)) return;
111
+ killTree(proc.pid, "SIGKILL");
112
+ for (let i = 0; i < 10 && isGroupAlive(proc.pid); i++) {
113
+ await delay(100);
114
+ }
115
+ };
116
+
117
+ // src/ports.ts
118
+ import { execFile as execFile2 } from "child_process";
119
+ import { createServer } from "net";
120
+ var run = (cmd, args) => new Promise((resolve) => {
121
+ execFile2(cmd, args, (_error, stdout) => resolve(stdout ?? ""));
122
+ });
123
+ var detectPorts = async (pgid) => {
124
+ const pids = (await run("pgrep", ["-g", String(pgid)])).split("\n").filter(Boolean);
125
+ if (pids.length === 0) return [];
126
+ const output = await run("lsof", [
127
+ "-a",
128
+ "-P",
129
+ "-n",
130
+ "-iTCP",
131
+ "-sTCP:LISTEN",
132
+ "-p",
133
+ pids.join(",")
134
+ ]);
135
+ const ports = /* @__PURE__ */ new Set();
136
+ for (const line of output.split("\n")) {
137
+ const match = /:(\d+)\s+\(LISTEN\)/.exec(line);
138
+ if (match !== null) ports.add(Number(match[1]));
139
+ }
140
+ return [...ports].sort((a, b) => a - b);
141
+ };
142
+ var samePorts = (a, b) => a.length === b.length && a.every((port, i) => port === b[i]);
143
+ var isInternalPort = (port) => port >= 49152 || port >= 9229 && port <= 9249;
144
+ var findFreePorts = async (count) => {
145
+ const servers = await Promise.all(
146
+ Array.from(
147
+ { length: count },
148
+ () => new Promise((resolve, reject) => {
149
+ const server = createServer();
150
+ server.once("error", reject);
151
+ server.listen(0, "127.0.0.1", () => resolve(server));
152
+ })
153
+ )
154
+ );
155
+ const ports = servers.map((server) => server.address().port);
156
+ await Promise.all(
157
+ servers.map((server) => new Promise((done) => server.close(() => done())))
158
+ );
159
+ return ports;
160
+ };
161
+
162
+ // src/supervisor.ts
163
+ var DEFAULT_COLS = 120;
164
+ var DEFAULT_ROWS = 30;
165
+ var PORT_POLL_INTERVAL = "1 second";
166
+ var EVENT_BUFFER = 5e3;
167
+ var requiredPort = (spec) => {
168
+ if (spec.url === void 0) return void 0;
169
+ try {
170
+ const url = new URL(spec.url);
171
+ return url.port === "" ? void 0 : Number(url.port);
172
+ } catch {
173
+ return void 0;
174
+ }
175
+ };
176
+ var ALERT_TAIL = 4096;
177
+ var make = Effect.fn("procdeck.supervisor")(function* (loaded) {
178
+ const scope = yield* Effect.scope;
179
+ const events = yield* PubSub.sliding({
180
+ capacity: EVENT_BUFFER,
181
+ replay: EVENT_BUFFER
182
+ });
183
+ const wantsPort = loaded.config.procs.filter(usesOwnPort);
184
+ const freePorts = yield* Effect.promise(() => findFreePorts(wantsPort.length));
185
+ const assigned = new Map(wantsPort.map((spec, index) => [spec.id, freePorts[index]]));
186
+ const runtimes = /* @__PURE__ */ new Map();
187
+ for (const spec of loaded.config.procs) {
188
+ const assignedPort = assigned.get(spec.id);
189
+ const resolved = resolveSpec(spec, assigned);
190
+ runtimes.set(spec.id, {
191
+ spec,
192
+ resolved: assignedPort === void 0 ? resolved : { ...resolved, env: { PORT: String(assignedPort), ...resolved.env } },
193
+ assignedPort,
194
+ status: { id: spec.id, state: "stopped" },
195
+ proc: void 0,
196
+ ready: Latch.makeUnsafe(false),
197
+ waiter: void 0,
198
+ ports: [],
199
+ tail: "",
200
+ cols: DEFAULT_COLS,
201
+ rows: DEFAULT_ROWS
202
+ });
203
+ }
204
+ const emit = (event) => {
205
+ PubSub.publishUnsafe(events, event);
206
+ };
207
+ const setStatus = (runtime, status) => {
208
+ runtime.status = status;
209
+ emit({ type: "status", status });
210
+ };
211
+ const require_ = (id) => {
212
+ const runtime = runtimes.get(id);
213
+ if (runtime === void 0) throw new Error(`unknown proc "${id}"`);
214
+ return runtime;
215
+ };
216
+ const markUnready = (runtime) => {
217
+ runtime.ready.closeUnsafe();
218
+ runtime.ports = [];
219
+ };
220
+ const handleData = (runtime, data) => {
221
+ emit({ type: "log", id: runtime.spec.id, data });
222
+ const alerts = runtime.spec.alerts ?? [];
223
+ if (alerts.length === 0 || runtime.status.alert !== void 0) return;
224
+ runtime.tail = (runtime.tail + data).slice(-ALERT_TAIL);
225
+ for (const alert of alerts) {
226
+ if (new RegExp(alert.pattern).test(runtime.tail)) {
227
+ setStatus(runtime, { ...runtime.status, alert: alert.label });
228
+ return;
229
+ }
230
+ }
231
+ };
232
+ const startSync = (runtime) => {
233
+ if (runtime.proc !== void 0) return;
234
+ setStatus(runtime, { id: runtime.spec.id, state: "starting" });
235
+ markUnready(runtime);
236
+ runtime.tail = "";
237
+ let proc;
238
+ try {
239
+ proc = spawnProc({
240
+ spec: runtime.resolved,
241
+ root: loaded.root,
242
+ cols: runtime.cols,
243
+ rows: runtime.rows,
244
+ onData: (data) => handleData(runtime, data)
245
+ });
246
+ } catch (cause) {
247
+ emit({
248
+ type: "log",
249
+ id: runtime.spec.id,
250
+ data: `\r
251
+ procdeck: spawn failed: ${String(cause)}\r
252
+ `
253
+ });
254
+ setStatus(runtime, { id: runtime.spec.id, state: "exited", exitCode: -1 });
255
+ return;
256
+ }
257
+ runtime.proc = proc;
258
+ if (runtime.spec.readyWhen === "started") runtime.ready.openUnsafe();
259
+ setStatus(runtime, {
260
+ id: runtime.spec.id,
261
+ state: "running",
262
+ pid: proc.pid,
263
+ startedAt: Date.now()
264
+ });
265
+ void proc.exited.then(({ exitCode, signal }) => {
266
+ if (runtime.proc !== proc) return;
267
+ runtime.proc = void 0;
268
+ markUnready(runtime);
269
+ setStatus(runtime, {
270
+ id: runtime.spec.id,
271
+ state: "exited",
272
+ exitCode,
273
+ ...signal === void 0 ? {} : { signal }
274
+ });
275
+ });
276
+ };
277
+ const gate = Effect.fn("procdeck.preflight")(function* (runtime) {
278
+ const preflight = runtime.spec.preflight;
279
+ if (preflight === void 0) return true;
280
+ setStatus(runtime, { id: runtime.spec.id, state: "starting" });
281
+ const result = yield* Effect.promise(
282
+ () => runPreflight(preflight.shell, runtime.spec.cwd ?? loaded.root)
283
+ );
284
+ const expected = preflight.expect === void 0 || new RegExp(preflight.expect).test(result.output);
285
+ if (result.ok && expected) return true;
286
+ const reason = result.ok ? ` (output lacks /${preflight.expect}/)` : "";
287
+ const lines = [
288
+ `procdeck: preflight failed${reason}: ${preflight.shell}`,
289
+ ...result.output.split("\n").filter((line) => line.length > 0),
290
+ ...preflight.hint === void 0 ? [] : [`procdeck: hint: ${preflight.hint}`]
291
+ ];
292
+ emit({ type: "log", id: runtime.spec.id, data: `\r
293
+ ${lines.join("\r\n")}\r
294
+ ` });
295
+ runtime.waiter = void 0;
296
+ setStatus(runtime, {
297
+ id: runtime.spec.id,
298
+ state: "blocked",
299
+ ...preflight.hint === void 0 ? {} : { hint: preflight.hint }
300
+ });
301
+ return false;
302
+ });
303
+ const start = Effect.fn("procdeck.start")(function* (id) {
304
+ const runtime = require_(id);
305
+ if (runtime.proc !== void 0 || runtime.waiter !== void 0) return;
306
+ const needs = runtime.spec.needs ?? [];
307
+ if (needs.length === 0 && runtime.spec.preflight === void 0) {
308
+ return yield* Effect.sync(() => startSync(runtime));
309
+ }
310
+ if (needs.length > 0) setStatus(runtime, { id: runtime.spec.id, state: "waiting" });
311
+ let finished = false;
312
+ let self;
313
+ const fiber2 = yield* Effect.forkIn(
314
+ Effect.forEach(needs, (need) => require_(need).ready.await, { discard: true }).pipe(
315
+ Effect.andThen(gate(runtime)),
316
+ Effect.andThen((cleared) => Effect.sync(() => cleared && startSync(runtime))),
317
+ Effect.ensuring(
318
+ Effect.sync(() => {
319
+ finished = true;
320
+ if (runtime.waiter === self) runtime.waiter = void 0;
321
+ })
322
+ )
323
+ ),
324
+ scope
325
+ );
326
+ self = fiber2;
327
+ if (!finished) runtime.waiter = fiber2;
328
+ });
329
+ const stop = Effect.fn("procdeck.stop")(function* (id) {
330
+ const runtime = require_(id);
331
+ const waiter = runtime.waiter;
332
+ if (waiter !== void 0) {
333
+ runtime.waiter = void 0;
334
+ yield* Fiber.interrupt(waiter);
335
+ setStatus(runtime, { id: runtime.spec.id, state: "stopped" });
336
+ return;
337
+ }
338
+ const proc = runtime.proc;
339
+ if (proc === void 0) return;
340
+ runtime.proc = void 0;
341
+ markUnready(runtime);
342
+ yield* Effect.promise(() => terminate(proc));
343
+ if (runtime.status.state !== "exited") {
344
+ setStatus(runtime, { id: runtime.spec.id, state: "stopped" });
345
+ }
346
+ });
347
+ const restart = Effect.fn("procdeck.restart")(function* (id) {
348
+ yield* stop(id);
349
+ yield* start(id);
350
+ });
351
+ const pollPorts = Effect.gen(function* () {
352
+ while (true) {
353
+ yield* Effect.sleep(PORT_POLL_INTERVAL);
354
+ for (const runtime of runtimes.values()) {
355
+ const proc = runtime.proc;
356
+ if (proc === void 0) continue;
357
+ const ports = yield* Effect.promise(() => detectPorts(proc.pid));
358
+ if (runtime.proc !== proc) continue;
359
+ if (!samePorts(ports, runtime.ports)) {
360
+ runtime.ports = ports;
361
+ setStatus(runtime, { ...runtime.status, ports });
362
+ }
363
+ if (runtime.spec.readyWhen !== "started") {
364
+ const required = requiredPort(runtime.resolved) ?? runtime.assignedPort;
365
+ const ready = required === void 0 ? ports.length > 0 : ports.includes(required);
366
+ if (ready) runtime.ready.openUnsafe();
367
+ }
368
+ }
369
+ }
370
+ });
371
+ yield* Effect.forkIn(pollPorts, scope);
372
+ const uiPort = loaded.config.port ?? DEFAULT_UI_PORT;
373
+ const supervisor = {
374
+ events,
375
+ list: () => [...runtimes.values()].map((runtime) => ({
376
+ id: runtime.spec.id,
377
+ command: describeCommand(runtime.resolved),
378
+ ...runtime.resolved.url === void 0 ? {} : { url: runtime.resolved.url },
379
+ ...runtime.assignedPort === void 0 ? {} : { assignedPort: runtime.assignedPort },
380
+ proxyUrl: `http://${runtime.spec.id}.localhost:${uiPort}`,
381
+ status: runtime.status
382
+ })),
383
+ start,
384
+ stop,
385
+ restart,
386
+ resize: (id, cols, rows) => {
387
+ const runtime = runtimes.get(id);
388
+ if (runtime === void 0 || cols <= 0 || rows <= 0) return;
389
+ runtime.cols = cols;
390
+ runtime.rows = rows;
391
+ try {
392
+ runtime.proc?.pty.resize(cols, rows);
393
+ } catch {
394
+ }
395
+ },
396
+ write: (id, data) => {
397
+ runtimes.get(id)?.proc?.pty.write(data);
398
+ },
399
+ proxyPort: (id) => {
400
+ const runtime = runtimes.get(id);
401
+ if (runtime === void 0) return void 0;
402
+ return runtime.assignedPort ?? requiredPort(runtime.resolved) ?? runtime.ports.find((port) => !isInternalPort(port));
403
+ }
404
+ };
405
+ const stopAll = Effect.forEach([...runtimes.keys()], stop, {
406
+ concurrency: "unbounded",
407
+ discard: true
408
+ });
409
+ for (const runtime of runtimes.values()) {
410
+ if (runtime.spec.autostart !== false) yield* start(runtime.spec.id);
411
+ }
412
+ return { supervisor, stopAll };
413
+ });
414
+ var makeSupervisor = (loaded) => Effect.acquireRelease(make(loaded), ({ stopAll }) => stopAll).pipe(
415
+ Effect.map(({ supervisor }) => supervisor)
416
+ );
417
+
418
+ // src/server.ts
419
+ import { createServer as createServer2, request as httpRequest } from "http";
420
+ import { readFile } from "fs/promises";
421
+ import { existsSync } from "fs";
422
+ import { Readable } from "stream";
423
+ import * as path from "path";
424
+ import { Effect as Effect2, Stream } from "effect";
425
+ import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
426
+ var API = "/__procdeck/api";
427
+ var HEARTBEAT = "20 seconds";
428
+ var distDir = path.join(import.meta.dirname, "..", "ui", "dist");
429
+ var MIME = {
430
+ ".html": "text/html; charset=utf-8",
431
+ ".js": "text/javascript",
432
+ ".css": "text/css",
433
+ ".map": "application/json",
434
+ ".svg": "image/svg+xml",
435
+ ".png": "image/png",
436
+ ".woff2": "font/woff2"
437
+ };
438
+ var NO_DIST = "procdeck UI is not built \u2014 run `pnpm --filter procdeck build` first.";
439
+ var serveStatic = (pathname) => Effect2.gen(function* () {
440
+ if (!existsSync(distDir)) {
441
+ return HttpServerResponse.text(NO_DIST, { status: 503 });
442
+ }
443
+ const relative = pathname === "/" ? "index.html" : pathname.slice(1);
444
+ const resolved = path.join(distDir, relative);
445
+ const file = resolved.startsWith(distDir) && existsSync(resolved) ? resolved : path.join(distDir, "index.html");
446
+ const bytes = yield* Effect2.promise(() => readFile(file));
447
+ return HttpServerResponse.uint8Array(bytes, {
448
+ headers: { "content-type": MIME[path.extname(file)] ?? "application/octet-stream" }
449
+ });
450
+ });
451
+ var sse = (supervisor) => Effect2.sync(() => {
452
+ const encoder = new TextEncoder();
453
+ const body = Stream.fromPubSub(supervisor.events).pipe(
454
+ Stream.map((event) => `data: ${JSON.stringify(event)}
455
+
456
+ `),
457
+ Stream.merge(Stream.tick(HEARTBEAT).pipe(Stream.map(() => ": ping\n\n"))),
458
+ Stream.map((chunk) => encoder.encode(chunk))
459
+ );
460
+ return HttpServerResponse.stream(body, {
461
+ headers: {
462
+ "content-type": "text/event-stream",
463
+ "cache-control": "no-cache, no-transform"
464
+ }
465
+ });
466
+ });
467
+ var manifest = (deck) => ({
468
+ name: `${deck.name} \xB7 procdeck`,
469
+ short_name: deck.name,
470
+ start_url: "/",
471
+ scope: "/",
472
+ display: "standalone",
473
+ background_color: "#0e1116",
474
+ theme_color: "#151a21",
475
+ icons: [
476
+ { src: "/icon-192.png", sizes: "192x192", type: "image/png" },
477
+ { src: "/icon-512.png", sizes: "512x512", type: "image/png" },
478
+ { src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" }
479
+ ]
480
+ });
481
+ var routes = (supervisor, deck) => HttpRouter.addAll([
482
+ HttpRouter.route(
483
+ "GET",
484
+ `${API}/deck`,
485
+ () => Effect2.sync(() => HttpServerResponse.jsonUnsafe(deck))
486
+ ),
487
+ HttpRouter.route(
488
+ "GET",
489
+ "/manifest.webmanifest",
490
+ () => Effect2.sync(
491
+ () => HttpServerResponse.text(JSON.stringify(manifest(deck)), {
492
+ headers: { "content-type": "application/manifest+json" }
493
+ })
494
+ )
495
+ ),
496
+ HttpRouter.route(
497
+ "GET",
498
+ `${API}/procs`,
499
+ () => Effect2.sync(() => HttpServerResponse.jsonUnsafe(supervisor.list()))
500
+ ),
501
+ HttpRouter.route("GET", `${API}/events`, () => sse(supervisor)),
502
+ HttpRouter.route(
503
+ "POST",
504
+ `${API}/procs/:id/:action`,
505
+ (request) => Effect2.gen(function* () {
506
+ const params = yield* HttpRouter.params;
507
+ const id = params["id"] ?? "";
508
+ const body = yield* request.json.pipe(Effect2.catch(() => Effect2.succeed(null)));
509
+ switch (params["action"]) {
510
+ case "start":
511
+ yield* supervisor.start(id);
512
+ break;
513
+ case "stop":
514
+ yield* supervisor.stop(id);
515
+ break;
516
+ case "restart":
517
+ yield* supervisor.restart(id);
518
+ break;
519
+ case "resize":
520
+ supervisor.resize(id, body?.cols ?? 0, body?.rows ?? 0);
521
+ break;
522
+ case "input":
523
+ supervisor.write(id, body?.data ?? "");
524
+ break;
525
+ default:
526
+ return HttpServerResponse.jsonUnsafe({ error: "unknown action" }, { status: 404 });
527
+ }
528
+ return HttpServerResponse.jsonUnsafe({ ok: true });
529
+ })
530
+ ),
531
+ // Everything else is the built UI.
532
+ HttpRouter.route("GET", "/", () => serveStatic("/")),
533
+ HttpRouter.route(
534
+ "GET",
535
+ "/*",
536
+ (request) => serveStatic(new URL(request.url, "http://localhost").pathname)
537
+ )
538
+ ]);
539
+ var hostProcId = (host) => {
540
+ if (host === void 0) return void 0;
541
+ const name = host.split(":")[0].toLowerCase();
542
+ return name.endsWith(".localhost") ? name.slice(0, -".localhost".length) : void 0;
543
+ };
544
+ var upstreamOptions = (req, port) => ({
545
+ host: "localhost",
546
+ port,
547
+ autoSelectFamily: true,
548
+ method: req.method,
549
+ path: req.url,
550
+ headers: { ...req.headers, host: `localhost:${port}` }
551
+ });
552
+ var refuse = (res, status, message) => {
553
+ if (!res.headersSent) res.writeHead(status, { "content-type": "text/plain; charset=utf-8" });
554
+ res.end(`procdeck: ${message}
555
+ `);
556
+ };
557
+ var proxyRequest = (supervisor, id, req, res) => {
558
+ const port = supervisor.proxyPort(id);
559
+ if (port === void 0) {
560
+ return refuse(res, 503, `"${id}" has no listening port yet \u2014 is it running?`);
561
+ }
562
+ const upstream = httpRequest(upstreamOptions(req, port), (upRes) => {
563
+ res.writeHead(upRes.statusCode ?? 502, upRes.headers);
564
+ upRes.pipe(res);
565
+ });
566
+ upstream.on("error", () => refuse(res, 502, `"${id}" refused the connection on :${port}`));
567
+ req.pipe(upstream);
568
+ };
569
+ var proxyUpgrade = (supervisor, id, req, socket, head) => {
570
+ const port = supervisor.proxyPort(id);
571
+ if (port === void 0) {
572
+ socket.destroy();
573
+ return;
574
+ }
575
+ const upstream = httpRequest(upstreamOptions(req, port));
576
+ upstream.on("upgrade", (upRes, upSocket, upHead) => {
577
+ const lines = ["HTTP/1.1 101 Switching Protocols"];
578
+ for (let i = 0; i < upRes.rawHeaders.length; i += 2) {
579
+ lines.push(`${upRes.rawHeaders[i]}: ${upRes.rawHeaders[i + 1]}`);
580
+ }
581
+ socket.write(`${lines.join("\r\n")}\r
582
+ \r
583
+ `);
584
+ if (upHead.length > 0) socket.write(upHead);
585
+ if (head.length > 0) upSocket.write(head);
586
+ upSocket.pipe(socket);
587
+ socket.pipe(upSocket);
588
+ const drop = () => {
589
+ upSocket.destroy();
590
+ socket.destroy();
591
+ };
592
+ upSocket.on("error", drop);
593
+ socket.on("error", drop);
594
+ });
595
+ upstream.on("response", (upRes) => {
596
+ socket.end(`HTTP/1.1 ${upRes.statusCode ?? 502} ${upRes.statusMessage ?? ""}\r
597
+ connection: close\r
598
+ \r
599
+ `);
600
+ });
601
+ upstream.on("error", () => socket.destroy());
602
+ socket.on("error", () => upstream.destroy());
603
+ upstream.end();
604
+ };
605
+ var handleNode = async (handler, req, res) => {
606
+ const controller = new AbortController();
607
+ res.on("close", () => controller.abort());
608
+ const method = req.method ?? "GET";
609
+ const request = new Request(`http://localhost${req.url ?? "/"}`, {
610
+ method,
611
+ headers: req.headers,
612
+ body: method === "GET" || method === "HEAD" ? null : Readable.toWeb(req),
613
+ duplex: "half",
614
+ signal: controller.signal
615
+ });
616
+ try {
617
+ const response = await handler(request);
618
+ res.writeHead(response.status, Object.fromEntries(response.headers));
619
+ if (response.body !== null) {
620
+ for await (const chunk of response.body) {
621
+ res.write(chunk);
622
+ }
623
+ }
624
+ res.end();
625
+ } catch {
626
+ if (!res.headersSent) res.writeHead(500);
627
+ res.end();
628
+ }
629
+ };
630
+ var serve = (supervisor, port, deck) => Effect2.acquireRelease(
631
+ Effect2.gen(function* () {
632
+ const { handler, dispose } = HttpRouter.toWebHandler(routes(supervisor, deck), {
633
+ disableLogger: true
634
+ });
635
+ const ids = new Set(supervisor.list().map((info) => info.id));
636
+ const http = createServer2((req, res) => {
637
+ const id = hostProcId(req.headers.host);
638
+ if (id !== void 0 && ids.has(id)) return proxyRequest(supervisor, id, req, res);
639
+ void handleNode(handler, req, res);
640
+ });
641
+ http.on("upgrade", (req, socket, head) => {
642
+ const id = hostProcId(req.headers.host);
643
+ if (id === void 0 || !ids.has(id)) return socket.destroy();
644
+ proxyUpgrade(supervisor, id, req, socket, head);
645
+ });
646
+ yield* Effect2.callback((resume) => {
647
+ http.once("error", (cause) => resume(Effect2.fail(cause)));
648
+ http.listen(port, () => resume(Effect2.void));
649
+ });
650
+ return { http, dispose };
651
+ }),
652
+ ({ http, dispose }) => Effect2.promise(async () => {
653
+ http.closeAllConnections();
654
+ await new Promise((done) => http.close(() => done()));
655
+ await dispose();
656
+ })
657
+ ).pipe(Effect2.as(port));
658
+
659
+ // src/cli.ts
660
+ var configPath = process.argv[2] ?? "procdeck.config.ts";
661
+ var main = Effect3.gen(function* () {
662
+ const loaded = yield* loadConfig(configPath);
663
+ const supervisor = yield* makeSupervisor(loaded);
664
+ const port = yield* serve(supervisor, loaded.config.port ?? DEFAULT_UI_PORT, { name: loaded.name });
665
+ yield* Effect3.log(`procdeck listening on http://localhost:${port}`);
666
+ yield* Effect3.never;
667
+ });
668
+ var program = Effect3.scoped(main).pipe(
669
+ Effect3.catchCause(
670
+ (cause) => Effect3.gen(function* () {
671
+ yield* Effect3.logError("procdeck failed", cause);
672
+ process.exitCode = 1;
673
+ })
674
+ )
675
+ );
676
+ var fiber = Effect3.runFork(program);
677
+ var shuttingDown = false;
678
+ for (const signal of ["SIGINT", "SIGTERM"]) {
679
+ process.on(signal, () => {
680
+ if (shuttingDown) process.exit(1);
681
+ shuttingDown = true;
682
+ Effect3.runFork(Effect3.log("shutting down, terminating processes\u2026 (Ctrl-C again to force)"));
683
+ Effect3.runPromise(Fiber2.interrupt(fiber)).then(
684
+ () => process.exit(0),
685
+ () => process.exit(1)
686
+ );
687
+ });
688
+ }