swarmy 0.1.1 → 0.2.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/README.md CHANGED
@@ -39,7 +39,35 @@ Provision ephemeral Docker containers across multiple nodes and interact with th
39
39
  - Docker
40
40
  - npm
41
41
 
42
- ### Manager
42
+ ### Using npx (recommended)
43
+
44
+ **Manager:**
45
+
46
+ ```bash
47
+ npx --yes swarmy@latest manager start
48
+ ```
49
+
50
+ This prints the token and connection instructions for workers.
51
+
52
+ **Worker** (on each node):
53
+
54
+ ```bash
55
+ npx --yes swarmy@latest worker start --url http://<manager-ip>:5174 --token <token>
56
+ ```
57
+
58
+ **Other commands:**
59
+
60
+ ```bash
61
+ npx swarmy manager status # Show PID + token
62
+ npx swarmy manager stop # Stop manager
63
+ npx swarmy manager restart # Restart manager
64
+ npx swarmy worker status # Show worker PID
65
+ npx swarmy worker stop # Stop worker
66
+ ```
67
+
68
+ ### From source (development)
69
+
70
+ **Manager:**
43
71
 
44
72
  ```bash
45
73
  cd manager
@@ -49,9 +77,7 @@ npm run dev
49
77
 
50
78
  The manager starts on port 5174. An auth token is auto-generated at `manager/data/token` on first run.
51
79
 
52
- ### Worker
53
-
54
- On each node that will run containers:
80
+ **Worker** (on each node):
55
81
 
56
82
  ```bash
57
83
  cd worker
@@ -90,7 +116,7 @@ Installs to `/opt/swarm`. Services auto-restart on failure.
90
116
 
91
117
  | Variable | Component | Description |
92
118
  | --------------- | --------- | -------------------------------------- |
93
- | `PORT` | Manager | HTTP port (default: 3000) |
119
+ | `PORT` | Manager | HTTP port (default: 5174) |
94
120
  | `SWARM_URL` | Worker | Manager URL to connect to |
95
121
  | `SWARM_TOKEN` | Worker | Auth token for registration |
96
122
  | `SWARM_NODE_ID` | Worker | Node identifier (default: hostname) |
@@ -110,6 +136,9 @@ All REST endpoints require `Authorization: Bearer <token>` header.
110
136
  | `POST` | `/api/sessions` | Spawn terminal session in container |
111
137
  | `DELETE` | `/api/sessions/:id` | Kill session |
112
138
  | `POST` | `/api/sessions/:id/input` | Send input to session |
139
+ | `PATCH` | `/api/sessions/:id` | Toggle interactive mode |
140
+ | `POST` | `/api/containers/:id/thumbnail` | Upload VNC thumbnail (base64 JPEG) |
141
+ | `GET` | `/api/containers/:id/thumbnail` | Get VNC thumbnail (supports `?token=`) |
113
142
  | `GET` | `/api/status` | Server status |
114
143
 
115
144
  ## Tech Stack
package/bin/swarmy.js CHANGED
@@ -139,31 +139,177 @@ const cliArgs = process.argv.slice(2);
139
139
  const role = cliArgs[0];
140
140
  const action = cliArgs[1];
141
141
 
142
- function usage() {
142
+ const hasFlag = (flag) => cliArgs.includes(flag);
143
+
144
+ function parseFlag(flag) {
145
+ const idx = cliArgs.indexOf(flag);
146
+ if (idx === -1 || idx + 1 >= cliArgs.length) return undefined;
147
+ return cliArgs[idx + 1];
148
+ }
149
+
150
+ function usageMain() {
143
151
  console.log(`
144
152
  swarmy - Distributed container management
145
153
 
146
154
  Usage:
147
- swarmy manager start [--port PORT]
155
+ swarmy manager <command> Manage the central server
156
+ swarmy worker <command> Manage a worker node
157
+ swarmy --help Show this help
158
+
159
+ Run 'swarmy manager --help' or 'swarmy worker --help' for command details.
160
+ `);
161
+ process.exit(0);
162
+ }
163
+
164
+ function usageManager() {
165
+ console.log(`
166
+ swarmy manager - Manage the central server
167
+
168
+ Commands:
169
+ start [--port PORT] Start the manager (default port: 5174)
170
+ stop Stop the manager
171
+ restart [--port PORT] Restart the manager
172
+ status Show manager status and token
173
+
174
+ Examples:
175
+ swarmy manager start
176
+ swarmy manager start --port 3000
177
+ swarmy manager status
178
+ swarmy manager stop
179
+ `);
180
+ process.exit(0);
181
+ }
182
+
183
+ function usageWorker() {
184
+ console.log(`
185
+ swarmy worker - Manage a worker node
186
+
187
+ Commands:
188
+ start --url URL --token TOKEN [--hostname NAME]
189
+ Connect worker to a manager
190
+ stop Stop the worker
191
+ restart --url URL --token TOKEN [--hostname NAME]
192
+ Restart the worker
193
+ status Show worker status
194
+
195
+ Examples:
196
+ swarmy worker start --url http://192.168.1.10:5174 --token abc123
197
+ swarmy worker start --url http://manager:5174 --token abc123 --hostname gpu-node-01
198
+ swarmy worker status
199
+ swarmy worker stop
200
+ `);
201
+ process.exit(0);
202
+ }
203
+
204
+ // Only handle --help at top level (swarmy --help) or role level (swarmy manager --help)
205
+ // Action-level help (swarmy manager start --help) is handled in the switch cases
206
+ if (role === "--help" || role === "-h") usageMain();
207
+
208
+ if (!role) usageMain();
209
+ if (!action) {
210
+ if (role === "manager") usageManager();
211
+ else if (role === "worker") usageWorker();
212
+ else usageMain();
213
+ }
214
+
215
+ function usageManagerStart() {
216
+ console.log(`
217
+ swarmy manager start - Start the central server
218
+
219
+ Options:
220
+ --port PORT HTTP port (default: 5174)
221
+
222
+ Examples:
223
+ swarmy manager start
224
+ swarmy manager start --port 3000
225
+ `);
226
+ process.exit(0);
227
+ }
228
+
229
+ function usageManagerStop() {
230
+ console.log(`
231
+ swarmy manager stop - Stop the central server
232
+
233
+ Usage:
148
234
  swarmy manager stop
149
- swarmy manager restart [--port PORT]
235
+ `);
236
+ process.exit(0);
237
+ }
238
+
239
+ function usageManagerRestart() {
240
+ console.log(`
241
+ swarmy manager restart - Restart the central server
242
+
243
+ Options:
244
+ --port PORT HTTP port (default: 5174)
245
+
246
+ Examples:
247
+ swarmy manager restart
248
+ swarmy manager restart --port 3000
249
+ `);
250
+ process.exit(0);
251
+ }
252
+
253
+ function usageManagerStatus() {
254
+ console.log(`
255
+ swarmy manager status - Show manager status and token
256
+
257
+ Usage:
150
258
  swarmy manager status
259
+ `);
260
+ process.exit(0);
261
+ }
262
+
263
+ function usageWorkerStart() {
264
+ console.log(`
265
+ swarmy worker start - Connect worker to a manager
266
+
267
+ Options:
268
+ --url URL Manager URL (required)
269
+ --token TOKEN Auth token (required)
270
+ --hostname NAME Custom hostname identifier
271
+
272
+ Examples:
273
+ swarmy worker start --url http://192.168.1.10:5174 --token abc123
274
+ swarmy worker start --url http://manager:5174 --token abc123 --hostname gpu-node-01
275
+ `);
276
+ process.exit(0);
277
+ }
151
278
 
152
- swarmy worker start --url URL --token TOKEN [--hostname NAME]
279
+ function usageWorkerStop() {
280
+ console.log(`
281
+ swarmy worker stop - Stop the worker
282
+
283
+ Usage:
153
284
  swarmy worker stop
154
- swarmy worker restart --url URL --token TOKEN [--hostname NAME]
155
- swarmy worker status
156
285
  `);
157
- process.exit(1);
286
+ process.exit(0);
158
287
  }
159
288
 
160
- function parseFlag(flag) {
161
- const idx = cliArgs.indexOf(flag);
162
- if (idx === -1 || idx + 1 >= cliArgs.length) return undefined;
163
- return cliArgs[idx + 1];
289
+ function usageWorkerRestart() {
290
+ console.log(`
291
+ swarmy worker restart - Restart the worker
292
+
293
+ Options:
294
+ --url URL Manager URL (required)
295
+ --token TOKEN Auth token (required)
296
+ --hostname NAME Custom hostname identifier
297
+
298
+ Examples:
299
+ swarmy worker restart --url http://192.168.1.10:5174 --token abc123
300
+ `);
301
+ process.exit(0);
164
302
  }
165
303
 
166
- if (!role || !action) usage();
304
+ function usageWorkerStatus() {
305
+ console.log(`
306
+ swarmy worker status - Show worker status
307
+
308
+ Usage:
309
+ swarmy worker status
310
+ `);
311
+ process.exit(0);
312
+ }
167
313
 
168
314
  function startManager() {
169
315
  const port = parseFlag("--port") ?? "5174";
@@ -189,44 +335,54 @@ function startWorker() {
189
335
  }
190
336
 
191
337
  if (role === "manager") {
338
+ if (action === "--help" || action === "-h") usageManager();
192
339
  switch (action) {
193
340
  case "start":
341
+ if (hasFlag("--help") || hasFlag("-h")) usageManagerStart();
194
342
  startManager();
195
343
  break;
196
344
  case "stop":
345
+ if (hasFlag("--help") || hasFlag("-h")) usageManagerStop();
197
346
  stopProcess("manager");
198
347
  break;
199
348
  case "restart":
349
+ if (hasFlag("--help") || hasFlag("-h")) usageManagerRestart();
200
350
  stopProcess("manager");
201
351
  setTimeout(startManager, 1000);
202
352
  break;
203
353
  case "status":
354
+ if (hasFlag("--help") || hasFlag("-h")) usageManagerStatus();
204
355
  showStatus("manager");
205
356
  if (getStatus("manager").running) {
206
357
  console.log(`Token: ${ensureToken()}`);
207
358
  }
208
359
  break;
209
360
  default:
210
- usage();
361
+ usageManager();
211
362
  }
212
363
  } else if (role === "worker") {
364
+ if (action === "--help" || action === "-h") usageWorker();
213
365
  switch (action) {
214
366
  case "start":
367
+ if (hasFlag("--help") || hasFlag("-h")) usageWorkerStart();
215
368
  startWorker();
216
369
  break;
217
370
  case "stop":
371
+ if (hasFlag("--help") || hasFlag("-h")) usageWorkerStop();
218
372
  stopProcess("worker");
219
373
  break;
220
374
  case "restart":
375
+ if (hasFlag("--help") || hasFlag("-h")) usageWorkerRestart();
221
376
  stopProcess("worker");
222
377
  setTimeout(startWorker, 1000);
223
378
  break;
224
379
  case "status":
380
+ if (hasFlag("--help") || hasFlag("-h")) usageWorkerStatus();
225
381
  showStatus("worker");
226
382
  break;
227
383
  default:
228
- usage();
384
+ usageWorker();
229
385
  }
230
386
  } else {
231
- usage();
387
+ usageMain();
232
388
  }
@@ -0,0 +1,52 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { fileURLToPath } from "url";
5
+ import type { Request, Response, NextFunction } from "express";
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const TOKEN_PATH = path.join(__dirname, "../../data/token");
9
+
10
+ let cachedToken: string | null = null;
11
+
12
+ export function getToken(): string {
13
+ if (cachedToken) return cachedToken;
14
+
15
+ // Allow env var override
16
+ if (process.env.SWARM_TOKEN) {
17
+ cachedToken = process.env.SWARM_TOKEN;
18
+ return cachedToken;
19
+ }
20
+
21
+ const tokenDir = path.dirname(TOKEN_PATH);
22
+ fs.mkdirSync(tokenDir, { recursive: true });
23
+
24
+ if (fs.existsSync(TOKEN_PATH)) {
25
+ cachedToken = fs.readFileSync(TOKEN_PATH, "utf-8").trim();
26
+ } else {
27
+ cachedToken = crypto.randomBytes(32).toString("hex");
28
+ fs.writeFileSync(TOKEN_PATH, cachedToken, { mode: 0o600 });
29
+ }
30
+
31
+ return cachedToken;
32
+ }
33
+
34
+ export function validateToken(token: string): boolean {
35
+ return token === getToken();
36
+ }
37
+
38
+ export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
39
+ const header = req.headers.authorization;
40
+ if (!header || !header.startsWith("Bearer ")) {
41
+ res.status(401).json({ error: "Missing or invalid Authorization header" });
42
+ return;
43
+ }
44
+
45
+ const token = header.slice(7);
46
+ if (!validateToken(token)) {
47
+ res.status(403).json({ error: "Invalid token" });
48
+ return;
49
+ }
50
+
51
+ next();
52
+ }
@@ -0,0 +1,251 @@
1
+ import Database from "better-sqlite3";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { fileURLToPath } from "url";
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ const DB_PATH = path.join(__dirname, "../../data/swarm.db");
8
+
9
+ let db: Database.Database;
10
+
11
+ export function getDb(): Database.Database {
12
+ if (!db) {
13
+ fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
14
+ db = new Database(DB_PATH);
15
+ db.pragma("journal_mode = WAL");
16
+ db.pragma("foreign_keys = ON");
17
+ initSchema(db);
18
+ }
19
+ return db;
20
+ }
21
+
22
+ function initSchema(db: Database.Database): void {
23
+ db.exec(`
24
+ CREATE TABLE IF NOT EXISTS nodes (
25
+ id TEXT PRIMARY KEY,
26
+ hostname TEXT,
27
+ ip TEXT,
28
+ status TEXT DEFAULT 'offline',
29
+ max_containers INTEGER DEFAULT 5,
30
+ last_heartbeat INTEGER,
31
+ created_at INTEGER DEFAULT (unixepoch()),
32
+ updated_at INTEGER DEFAULT (unixepoch())
33
+ );
34
+
35
+ CREATE TABLE IF NOT EXISTS containers (
36
+ id TEXT PRIMARY KEY,
37
+ node_id TEXT REFERENCES nodes(id),
38
+ docker_id TEXT,
39
+ image TEXT DEFAULT 'swarm-base',
40
+ status TEXT DEFAULT 'running',
41
+ vnc_port INTEGER,
42
+ max_sessions INTEGER DEFAULT 10,
43
+ created_at INTEGER DEFAULT (unixepoch()),
44
+ updated_at INTEGER DEFAULT (unixepoch())
45
+ );
46
+
47
+ CREATE TABLE IF NOT EXISTS sessions (
48
+ id TEXT PRIMARY KEY,
49
+ container_id TEXT REFERENCES containers(id),
50
+ command TEXT DEFAULT 'bash',
51
+ status TEXT DEFAULT 'running',
52
+ exit_code INTEGER,
53
+ interactive INTEGER DEFAULT 1,
54
+ scrollback TEXT DEFAULT '',
55
+ created_at INTEGER DEFAULT (unixepoch()),
56
+ updated_at INTEGER DEFAULT (unixepoch())
57
+ );
58
+ `);
59
+ }
60
+
61
+ // --- Node queries ---
62
+
63
+ export function upsertNode(
64
+ id: string,
65
+ hostname: string,
66
+ ip: string,
67
+ maxContainers: number
68
+ ): void {
69
+ getDb()
70
+ .prepare(
71
+ `INSERT INTO nodes (id, hostname, ip, status, max_containers, last_heartbeat, updated_at)
72
+ VALUES (?, ?, ?, 'online', ?, unixepoch(), unixepoch())
73
+ ON CONFLICT(id) DO UPDATE SET
74
+ hostname = excluded.hostname,
75
+ ip = excluded.ip,
76
+ status = 'online',
77
+ max_containers = excluded.max_containers,
78
+ last_heartbeat = unixepoch(),
79
+ updated_at = unixepoch()`
80
+ )
81
+ .run(id, hostname, ip, maxContainers);
82
+ }
83
+
84
+ export function updateNodeHeartbeat(id: string): void {
85
+ getDb()
86
+ .prepare(
87
+ `UPDATE nodes SET last_heartbeat = unixepoch(), status = 'online', updated_at = unixepoch() WHERE id = ?`
88
+ )
89
+ .run(id);
90
+ }
91
+
92
+ export function markNodeStatus(id: string, status: string): void {
93
+ getDb()
94
+ .prepare(`UPDATE nodes SET status = ?, updated_at = unixepoch() WHERE id = ?`)
95
+ .run(status, id);
96
+ }
97
+
98
+ export function getAllNodes(): any[] {
99
+ return getDb().prepare(`SELECT * FROM nodes ORDER BY created_at`).all();
100
+ }
101
+
102
+ export function getNode(id: string): any {
103
+ return getDb().prepare(`SELECT * FROM nodes WHERE id = ?`).get(id);
104
+ }
105
+
106
+ export function getStaleNodes(thresholdSeconds: number): any[] {
107
+ return getDb()
108
+ .prepare(
109
+ `SELECT * FROM nodes WHERE status = 'online' AND last_heartbeat < unixepoch() - ?`
110
+ )
111
+ .all(thresholdSeconds);
112
+ }
113
+
114
+ export function markReconnectingNodes(): void {
115
+ getDb()
116
+ .prepare(
117
+ `UPDATE nodes SET status = 'reconnecting', updated_at = unixepoch() WHERE status = 'online'`
118
+ )
119
+ .run();
120
+ }
121
+
122
+ export function removeNode(id: string): void {
123
+ const containers = getContainersByNode(id);
124
+ for (const c of containers) {
125
+ getDb().prepare(`DELETE FROM sessions WHERE container_id = ?`).run(c.id);
126
+ }
127
+ getDb().prepare(`DELETE FROM containers WHERE node_id = ?`).run(id);
128
+ getDb().prepare(`DELETE FROM nodes WHERE id = ?`).run(id);
129
+ }
130
+
131
+ export function markStaleReconnectingOffline(thresholdSeconds: number): number {
132
+ return getDb()
133
+ .prepare(
134
+ `UPDATE nodes SET status = 'offline', updated_at = unixepoch()
135
+ WHERE status = 'reconnecting' AND updated_at < unixepoch() - ?`
136
+ )
137
+ .run(thresholdSeconds).changes;
138
+ }
139
+
140
+ // --- Container queries ---
141
+
142
+ export function insertContainer(
143
+ id: string,
144
+ nodeId: string,
145
+ dockerId: string,
146
+ image: string,
147
+ vncPort: number
148
+ ): void {
149
+ getDb()
150
+ .prepare(
151
+ `INSERT INTO containers (id, node_id, docker_id, image, vnc_port) VALUES (?, ?, ?, ?, ?)`
152
+ )
153
+ .run(id, nodeId, dockerId, image, vncPort);
154
+ }
155
+
156
+ export function updateContainerDocker(id: string, dockerId: string, vncPort: number): void {
157
+ getDb()
158
+ .prepare(
159
+ `UPDATE containers SET docker_id = ?, vnc_port = ?, status = 'running', updated_at = unixepoch() WHERE id = ?`
160
+ )
161
+ .run(dockerId, vncPort, id);
162
+ }
163
+
164
+ export function removeContainer(id: string): void {
165
+ getDb().prepare(`DELETE FROM sessions WHERE container_id = ?`).run(id);
166
+ getDb().prepare(`DELETE FROM containers WHERE id = ?`).run(id);
167
+ }
168
+
169
+ export function getContainersByNode(nodeId: string): any[] {
170
+ return getDb()
171
+ .prepare(`SELECT * FROM containers WHERE node_id = ? ORDER BY created_at`)
172
+ .all(nodeId);
173
+ }
174
+
175
+ export function getContainer(id: string): any {
176
+ return getDb().prepare(`SELECT * FROM containers WHERE id = ?`).get(id);
177
+ }
178
+
179
+ export function countContainersByNode(nodeId: string): number {
180
+ const row = getDb()
181
+ .prepare(`SELECT COUNT(*) as count FROM containers WHERE node_id = ? AND status = 'running'`)
182
+ .get(nodeId) as any;
183
+ return row.count;
184
+ }
185
+
186
+ // --- Session queries ---
187
+
188
+ export function insertSession(id: string, containerId: string, command: string): void {
189
+ getDb()
190
+ .prepare(`INSERT INTO sessions (id, container_id, command) VALUES (?, ?, ?)`)
191
+ .run(id, containerId, command);
192
+ }
193
+
194
+ export function markSessionExited(id: string, exitCode: number): void {
195
+ getDb()
196
+ .prepare(
197
+ `UPDATE sessions SET status = 'exited', exit_code = ?, updated_at = unixepoch() WHERE id = ?`
198
+ )
199
+ .run(exitCode, id);
200
+ }
201
+
202
+ export function removeSession(id: string): void {
203
+ getDb().prepare(`DELETE FROM sessions WHERE id = ?`).run(id);
204
+ }
205
+
206
+ export function getSessionsByContainer(containerId: string): any[] {
207
+ return getDb()
208
+ .prepare(`SELECT * FROM sessions WHERE container_id = ? ORDER BY created_at`)
209
+ .all(containerId);
210
+ }
211
+
212
+ export function getSession(id: string): any {
213
+ return getDb().prepare(`SELECT * FROM sessions WHERE id = ?`).get(id);
214
+ }
215
+
216
+ export function countSessionsByContainer(containerId: string): number {
217
+ const row = getDb()
218
+ .prepare(
219
+ `SELECT COUNT(*) as count FROM sessions WHERE container_id = ? AND status = 'running'`
220
+ )
221
+ .get(containerId) as any;
222
+ return row.count;
223
+ }
224
+
225
+ export function updateScrollback(id: string, scrollback: string): void {
226
+ getDb()
227
+ .prepare(`UPDATE sessions SET scrollback = ?, updated_at = unixepoch() WHERE id = ?`)
228
+ .run(scrollback, id);
229
+ }
230
+
231
+ export function updateSessionInteractive(id: string, interactive: boolean): void {
232
+ getDb()
233
+ .prepare(`UPDATE sessions SET interactive = ?, updated_at = unixepoch() WHERE id = ?`)
234
+ .run(interactive ? 1 : 0, id);
235
+ }
236
+
237
+ // --- Full state (for UI snapshot) ---
238
+
239
+ export function getFullState(): {
240
+ nodes: any[];
241
+ containers: any[];
242
+ sessions: any[];
243
+ } {
244
+ return {
245
+ nodes: getDb().prepare(`SELECT * FROM nodes ORDER BY created_at`).all(),
246
+ containers: getDb().prepare(`SELECT * FROM containers ORDER BY created_at`).all(),
247
+ sessions: getDb()
248
+ .prepare(`SELECT id, container_id, command, status, exit_code, interactive, created_at, updated_at FROM sessions ORDER BY created_at`)
249
+ .all(),
250
+ };
251
+ }
@@ -0,0 +1,85 @@
1
+ import express from "express";
2
+ import { createServer } from "http";
3
+ import { createServer as createViteServer } from "vite";
4
+ import path from "path";
5
+ import { fileURLToPath } from "url";
6
+ import { authMiddleware, getToken, validateToken } from "./auth.js";
7
+ import { ensureThumbnailDir, getThumbnailPath } from "./thumbnails.js";
8
+ import { markReconnectingNodes, markStaleReconnectingOffline, getStaleNodes, markNodeStatus } from "./db.js";
9
+ import { setupWorkerWs } from "./ws/worker.js";
10
+ import { setupUiWs } from "./ws/ui.js";
11
+ import { setupSessionWs, startScrollbackFlush } from "./ws/session.js";
12
+ import { setupVncWs } from "./ws/vnc.js";
13
+ import nodesRouter from "./routes/nodes.js";
14
+ import containersRouter from "./routes/containers.js";
15
+ import sessionsRouter from "./routes/sessions.js";
16
+ import statusRouter from "./routes/status.js";
17
+
18
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
+ const PORT = parseInt(process.env.PORT ?? "5174", 10);
20
+
21
+ async function main() {
22
+ ensureThumbnailDir();
23
+ markReconnectingNodes();
24
+ const app = express();
25
+ app.use(express.json());
26
+
27
+ app.get("/api/containers/:id/thumbnail", (req, res) => {
28
+ const token = (req.query.token as string) ?? req.headers.authorization?.slice(7);
29
+ if (!token || !validateToken(token)) { res.status(403).json({ error: "Invalid token" }); return; }
30
+ const thumbPath = getThumbnailPath(req.params.id);
31
+ if (!thumbPath) { res.status(404).json({ error: "No thumbnail" }); return; }
32
+ res.set("Cache-Control", "no-cache");
33
+ res.sendFile(thumbPath);
34
+ });
35
+
36
+ app.use("/api/nodes", authMiddleware, nodesRouter);
37
+ app.use("/api/containers", authMiddleware, containersRouter);
38
+ app.use("/api/sessions", authMiddleware, sessionsRouter);
39
+ app.use("/api/status", authMiddleware, statusRouter);
40
+
41
+ const httpServer = createServer(app);
42
+
43
+ const workerWss = setupWorkerWs();
44
+ const uiWss = setupUiWs();
45
+ const sessionWss = setupSessionWs();
46
+ const vncWss = setupVncWs();
47
+ startScrollbackFlush();
48
+
49
+ httpServer.on("upgrade", (req, socket, head) => {
50
+ const url = new URL(req.url ?? "", `http://${req.headers.host}`);
51
+ const p = url.pathname;
52
+ if (p === "/ws/worker") {
53
+ workerWss.handleUpgrade(req, socket, head, (ws) => workerWss.emit("connection", ws, req));
54
+ } else if (p === "/ws/ui") {
55
+ uiWss.handleUpgrade(req, socket, head, (ws) => uiWss.emit("connection", ws, req));
56
+ } else if (p.startsWith("/ws/session/")) {
57
+ sessionWss.handleUpgrade(req, socket, head, (ws) => sessionWss.emit("connection", ws, req));
58
+ } else if (p.startsWith("/ws/vnc/")) {
59
+ vncWss.handleUpgrade(req, socket, head, (ws) => vncWss.emit("connection", ws, req));
60
+ }
61
+ // else: let Vite HMR handle its own upgrades
62
+ });
63
+
64
+ const vite = await createViteServer({
65
+ configFile: path.join(__dirname, "../../vite.config.ts"),
66
+ server: {
67
+ middlewareMode: true,
68
+ hmr: { server: httpServer },
69
+ },
70
+ });
71
+ app.use(vite.middlewares);
72
+
73
+ setInterval(() => {
74
+ const stale = getStaleNodes(30);
75
+ for (const node of stale) { markNodeStatus(node.id, "offline"); }
76
+ markStaleReconnectingOffline(60);
77
+ }, 10_000);
78
+
79
+ httpServer.listen(PORT, () => {
80
+ console.log(`Swarm Manager running on http://localhost:${PORT}`);
81
+ console.log(`Token: ${getToken()}`);
82
+ });
83
+ }
84
+
85
+ main().catch(console.error);