agent-companion 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.
@@ -0,0 +1,2056 @@
1
+ #!/usr/bin/env node
2
+ import cors from "cors";
3
+ import express from "express";
4
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
5
+ import fs from "node:fs";
6
+ import { createServer } from "node:http";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { WebSocket, WebSocketServer } from "ws";
10
+
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = path.dirname(__filename);
13
+ const PROJECT_ROOT = path.resolve(__dirname, "..");
14
+
15
+ const RELAY_PORT = toInt(process.env.PORT || process.env.RELAY_PORT, 9797);
16
+ const RELAY_PUBLIC_URL = trimTrailingSlash(
17
+ process.env.RELAY_PUBLIC_URL || process.env.RENDER_EXTERNAL_URL || `http://localhost:${RELAY_PORT}`
18
+ );
19
+ const RELAY_TOKEN_SECRET = resolveRelayTokenSecret();
20
+ const RELAY_WAKE_PROXY_URL = trimTrailingSlash(process.env.RELAY_WAKE_PROXY_URL || process.env.WAKE_PROXY_URL || "");
21
+ const RELAY_WAKE_PROXY_TOKEN = safeText(process.env.RELAY_WAKE_PROXY_TOKEN || process.env.WAKE_PROXY_TOKEN || "", 500);
22
+ const RELAY_WAKE_TIMEOUT_MS = clamp(toInt(process.env.RELAY_WAKE_TIMEOUT_MS, 90_000), 5_000, 5 * 60 * 1000);
23
+ const RELAY_WAKE_POLL_INTERVAL_MS = clamp(toInt(process.env.RELAY_WAKE_POLL_INTERVAL_MS, 1200), 250, 10_000);
24
+ const RELAY_WAKE_REQUEST_TIMEOUT_MS = clamp(toInt(process.env.RELAY_WAKE_REQUEST_TIMEOUT_MS, 6000), 1000, 60_000);
25
+ const PAIRING_TTL_MS = clamp(toInt(process.env.PAIRING_TTL_MS, 10 * 60 * 1000), 30_000, 24 * 60 * 60 * 1000);
26
+ const RPC_TIMEOUT_MS = clamp(toInt(process.env.RELAY_RPC_TIMEOUT_MS, 15_000), 500, 60_000);
27
+ const PREVIEW_DEFAULT_TTL_MS = clamp(toInt(process.env.RELAY_PREVIEW_TTL_MS, 2 * 60 * 60 * 1000), 60_000, 7 * 24 * 60 * 60 * 1000);
28
+ const PREVIEW_MAX_TTL_MS = clamp(
29
+ toInt(process.env.RELAY_PREVIEW_MAX_TTL_MS, 24 * 60 * 60 * 1000),
30
+ PREVIEW_DEFAULT_TTL_MS,
31
+ 14 * 24 * 60 * 60 * 1000
32
+ );
33
+ const PREVIEW_RPC_TIMEOUT_MS = clamp(toInt(process.env.RELAY_PREVIEW_RPC_TIMEOUT_MS, 30_000), 2000, 120_000);
34
+ const CLEANUP_INTERVAL_MS = 30_000;
35
+ const MAX_LAPTOP_RECORDS = 500;
36
+ const MAX_PHONE_TOKENS = 2_000;
37
+ const MAX_PREVIEW_RECORDS = 4_000;
38
+ const PHONE_TOKEN_HEADER = "x-agent-companion-phone-token";
39
+ const LAPTOP_TOKEN_HEADER = "x-agent-companion-laptop-token";
40
+
41
+ const STATE_FILE = path.resolve(PROJECT_ROOT, "relay", "state.json");
42
+ const app = express();
43
+ const server = createServer(app);
44
+ const wss = new WebSocketServer({ noServer: true });
45
+
46
+ let state = loadState();
47
+ let persistTimer = null;
48
+ let shuttingDown = false;
49
+
50
+ const laptopSockets = new Map();
51
+ const pendingRpcs = new Map();
52
+ const pendingWakeAttempts = new Map();
53
+
54
+ app.use(
55
+ cors({
56
+ exposedHeaders: [PHONE_TOKEN_HEADER, LAPTOP_TOKEN_HEADER]
57
+ })
58
+ );
59
+ app.use(express.json({ limit: "2mb" }));
60
+
61
+ if (!RELAY_TOKEN_SECRET) {
62
+ console.warn("[relay] durable token secret missing; pairing may break after relay restarts");
63
+ }
64
+
65
+ app.get("/health", (_req, res) => {
66
+ cleanupExpiredPairings();
67
+ cleanupExpiredPreviews();
68
+ const onlineLaptops = [...laptopSockets.values()].filter((ws) => ws.readyState === WebSocket.OPEN).length;
69
+ const activePairings = state.pairings.filter((item) => !item.claimedAt && item.expiresAt > Date.now()).length;
70
+ const activePreviews = state.previews.filter((item) => item.expiresAt > Date.now()).length;
71
+
72
+ res.json({
73
+ ok: true,
74
+ service: "agent-relay",
75
+ onlineLaptops,
76
+ totalLaptops: state.laptops.length,
77
+ activePairings,
78
+ activePreviews,
79
+ issuedPhoneTokens: state.phones.length,
80
+ wakeProxyEnabled: Boolean(RELAY_WAKE_PROXY_URL)
81
+ });
82
+ });
83
+
84
+ app.get("/pair", (req, res) => {
85
+ const code = normalizePairCode(req.query?.code);
86
+ const pairing = code ? findPairingByCode(code) : null;
87
+
88
+ const body = {
89
+ ok: true,
90
+ message: "Use this code in the Agent Companion app to pair your laptop.",
91
+ code: code || null,
92
+ valid: Boolean(pairing && pairing.expiresAt > Date.now()),
93
+ claimed: Boolean(pairing?.claimedAt),
94
+ expiresAt: pairing?.expiresAt ?? null
95
+ };
96
+
97
+ return res.json(body);
98
+ });
99
+
100
+ app.post("/api/laptops/register", (req, res) => {
101
+ cleanupExpiredPairings();
102
+
103
+ const now = Date.now();
104
+ const laptopId = createId("lap");
105
+ const deviceId = createId("dev");
106
+ const laptopToken = issueLaptopToken(laptopId, deviceId);
107
+ const wakeMacAddress = normalizeMacAddress(
108
+ req.body?.wakeMac || req.body?.wake_mac || req.body?.macAddress || req.body?.wake?.macAddress
109
+ );
110
+
111
+ const laptop = {
112
+ laptopId,
113
+ deviceId,
114
+ laptopToken,
115
+ name: safeText(req.body?.name, 120) || safeText(req.body?.hostname, 120) || null,
116
+ createdAt: now,
117
+ updatedAt: now,
118
+ pairedAt: null,
119
+ pairCode: null,
120
+ pairingExpiresAt: null,
121
+ pairingUrl: null,
122
+ pairingPayload: null,
123
+ lastConnectedAt: null,
124
+ lastDisconnectedAt: null,
125
+ lastSnapshotAt: null,
126
+ latestSnapshot: null,
127
+ wake: {
128
+ macAddress: wakeMacAddress || null
129
+ },
130
+ lastWakeRequestedAt: null,
131
+ lastWakeResult: null
132
+ };
133
+
134
+ mutateState(() => {
135
+ state.laptops.push(laptop);
136
+ trimStateCollections();
137
+ });
138
+
139
+ const pairing = createPairingForLaptop(laptop);
140
+
141
+ res.status(201).json({
142
+ laptopId: laptop.laptopId,
143
+ laptopToken: laptop.laptopToken,
144
+ deviceId: laptop.deviceId,
145
+ pairCode: pairing.code,
146
+ pairingExpiresAt: pairing.expiresAt,
147
+ pairingUrl: pairing.pairingUrl,
148
+ pairingPayload: pairing.pairingPayload
149
+ });
150
+ });
151
+
152
+ app.use("/api/laptops", requireLaptopToken);
153
+
154
+ app.get("/api/laptops/me", (req, res) => {
155
+ const laptop = req.laptopSession;
156
+ const activePairing =
157
+ state.pairings
158
+ .filter((item) => item.laptopId === laptop.laptopId && item.expiresAt > Date.now())
159
+ .sort((a, b) => b.createdAt - a.createdAt)[0] || null;
160
+
161
+ return res.json({
162
+ ok: true,
163
+ laptopId: laptop.laptopId,
164
+ deviceId: laptop.deviceId,
165
+ laptopToken: preferredLaptopToken(laptop),
166
+ name: laptop.name,
167
+ pairedAt: laptop.pairedAt,
168
+ connected: isLaptopConnected(laptop.laptopId),
169
+ lastConnectedAt: laptop.lastConnectedAt,
170
+ lastDisconnectedAt: laptop.lastDisconnectedAt,
171
+ lastSnapshotAt: laptop.lastSnapshotAt,
172
+ wake: {
173
+ macAddress: laptop?.wake?.macAddress || null
174
+ },
175
+ wakeConfigured: Boolean(laptop?.wake?.macAddress),
176
+ lastWakeRequestedAt: laptop.lastWakeRequestedAt || null,
177
+ lastWakeResult: laptop.lastWakeResult || null,
178
+ activePairing: activePairing
179
+ ? {
180
+ code: activePairing.code,
181
+ expiresAt: activePairing.expiresAt,
182
+ pairingUrl: activePairing.pairingUrl
183
+ }
184
+ : null
185
+ });
186
+ });
187
+
188
+ app.post("/api/laptops/pairing", (req, res) => {
189
+ cleanupExpiredPairings();
190
+ const laptop = req.laptopSession;
191
+ const force = Boolean(req.body?.force);
192
+ const wakeMacAddress = normalizeMacAddress(
193
+ req.body?.wakeMac || req.body?.wake_mac || req.body?.macAddress || req.body?.wake?.macAddress
194
+ );
195
+
196
+ if (wakeMacAddress && wakeMacAddress !== laptop?.wake?.macAddress) {
197
+ mutateState(() => {
198
+ laptop.wake = {
199
+ ...(isObject(laptop.wake) ? laptop.wake : {}),
200
+ macAddress: wakeMacAddress
201
+ };
202
+ laptop.updatedAt = Date.now();
203
+ });
204
+ }
205
+
206
+ const existingPairing =
207
+ !force
208
+ ? state.pairings
209
+ .filter((item) => item.laptopId === laptop.laptopId && item.expiresAt > Date.now())
210
+ .sort((a, b) => b.createdAt - a.createdAt)[0] || null
211
+ : null;
212
+
213
+ const pairing = existingPairing || createPairingForLaptop(laptop);
214
+
215
+ return res.json({
216
+ ok: true,
217
+ laptopId: laptop.laptopId,
218
+ deviceId: laptop.deviceId,
219
+ laptopToken: preferredLaptopToken(laptop),
220
+ pairCode: pairing.code,
221
+ pairingExpiresAt: pairing.expiresAt,
222
+ pairingUrl: pairing.pairingUrl,
223
+ pairingPayload: pairing.pairingPayload
224
+ });
225
+ });
226
+
227
+ app.get("/api/pairings/:code", (req, res) => {
228
+ cleanupExpiredPairings();
229
+
230
+ const code = normalizePairCode(req.params.code);
231
+ if (!code) {
232
+ return res.status(400).json({ ok: false, error: "pairing code is required" });
233
+ }
234
+
235
+ const pairing = findPairingByCode(code);
236
+ if (!pairing) {
237
+ return res.status(404).json({ ok: false, error: "pairing code not found" });
238
+ }
239
+
240
+ const laptop = findLaptopById(pairing.laptopId);
241
+ return res.json({
242
+ ok: true,
243
+ code: pairing.code,
244
+ laptopId: pairing.laptopId,
245
+ deviceId: pairing.deviceId,
246
+ pairingExpiresAt: pairing.expiresAt,
247
+ claimed: Boolean(pairing.claimedAt),
248
+ connected: laptop ? isLaptopConnected(laptop.laptopId) : false,
249
+ hasSnapshot: Boolean(laptop?.latestSnapshot)
250
+ });
251
+ });
252
+
253
+ app.post("/api/pairings/claim", (req, res) => {
254
+ cleanupExpiredPairings();
255
+
256
+ const code = normalizePairCode(req.body?.code || req.body?.pairCode);
257
+ if (!code) {
258
+ return res.status(400).json({ ok: false, error: "code is required" });
259
+ }
260
+
261
+ const pairing = findPairingByCode(code);
262
+ if (!pairing) {
263
+ return res.status(404).json({ ok: false, error: "pairing code not found" });
264
+ }
265
+
266
+ if (pairing.expiresAt < Date.now() && !pairing.claimedAt) {
267
+ return res.status(410).json({ ok: false, error: "pairing code expired" });
268
+ }
269
+
270
+ if (pairing.phoneToken) {
271
+ return res.json({
272
+ phoneToken: pairing.phoneToken,
273
+ deviceId: pairing.deviceId
274
+ });
275
+ }
276
+
277
+ const phoneToken = issuePhoneToken(pairing.deviceId);
278
+ const now = Date.now();
279
+
280
+ mutateState(() => {
281
+ pairing.claimedAt = now;
282
+ pairing.phoneToken = phoneToken;
283
+
284
+ state.phones.push({
285
+ phoneToken,
286
+ deviceId: pairing.deviceId,
287
+ createdAt: now,
288
+ lastUsedAt: now
289
+ });
290
+
291
+ const laptop = findLaptopById(pairing.laptopId);
292
+ if (laptop) {
293
+ laptop.pairedAt = now;
294
+ laptop.updatedAt = now;
295
+ }
296
+
297
+ trimStateCollections();
298
+ });
299
+
300
+ return res.json({
301
+ phoneToken,
302
+ deviceId: pairing.deviceId
303
+ });
304
+ });
305
+
306
+ app.use("/api/devices/:id", requirePhoneToken, requireDeviceAccess);
307
+
308
+ app.get("/api/devices/:id/status", (req, res) => {
309
+ const laptop = req.deviceLaptop;
310
+ const connected = isLaptopConnected(laptop.laptopId);
311
+
312
+ return res.json({
313
+ ok: true,
314
+ deviceId: laptop.deviceId,
315
+ laptopId: laptop.laptopId,
316
+ connected,
317
+ pairedAt: laptop.pairedAt,
318
+ lastConnectedAt: laptop.lastConnectedAt,
319
+ lastDisconnectedAt: laptop.lastDisconnectedAt,
320
+ latestSnapshotAt: laptop.lastSnapshotAt,
321
+ pairingExpiresAt: laptop.pairingExpiresAt,
322
+ wakeConfigured: Boolean(laptop?.wake?.macAddress),
323
+ wakeProxyEnabled: Boolean(RELAY_WAKE_PROXY_URL),
324
+ autoWakeCapable: Boolean(!connected && RELAY_WAKE_PROXY_URL && laptop?.wake?.macAddress),
325
+ lastWakeRequestedAt: laptop.lastWakeRequestedAt || null,
326
+ lastWakeResult: laptop.lastWakeResult || null
327
+ });
328
+ });
329
+
330
+ app.get("/api/devices/:id/bootstrap", async (req, res) => {
331
+ const laptop = req.deviceLaptop;
332
+ const freshFlag = String(req.query?.fresh || "").trim().toLowerCase();
333
+ const requireFresh = freshFlag === "1" || freshFlag === "true";
334
+
335
+ if (!requireFresh && isObject(laptop.latestSnapshot)) {
336
+ return res.json(laptop.latestSnapshot);
337
+ }
338
+
339
+ try {
340
+ const rpc = await sendLaptopRpc(laptop.laptopId, {
341
+ method: "GET",
342
+ path: "/api/bootstrap"
343
+ });
344
+
345
+ if (rpc.ok && isObject(rpc.body)) {
346
+ mutateState(() => {
347
+ laptop.latestSnapshot = rpc.body;
348
+ laptop.lastSnapshotAt = Date.now();
349
+ laptop.updatedAt = Date.now();
350
+ });
351
+ }
352
+
353
+ return relayRpcResponse(res, rpc);
354
+ } catch (error) {
355
+ if (isObject(laptop.latestSnapshot)) {
356
+ return res.json(laptop.latestSnapshot);
357
+ }
358
+ return res.status(resolveRpcErrorStatus(error)).json({
359
+ ok: false,
360
+ error: String(error?.message || error)
361
+ });
362
+ }
363
+ });
364
+
365
+ app.post("/api/devices/:id/actions", async (req, res) => {
366
+ return proxyToLaptopBridge(req, res, {
367
+ method: "POST",
368
+ path: "/api/actions",
369
+ body: req.body || {}
370
+ });
371
+ });
372
+
373
+ app.get("/api/devices/:id/launcher/workspaces", async (req, res) => {
374
+ const pathWithQuery = withQuery("/api/launcher/workspaces", req.query);
375
+ return proxyToLaptopBridge(req, res, {
376
+ method: "GET",
377
+ path: pathWithQuery
378
+ });
379
+ });
380
+
381
+ app.post("/api/devices/:id/launcher/workspaces/create", async (req, res) => {
382
+ return proxyToLaptopBridge(req, res, {
383
+ method: "POST",
384
+ path: "/api/launcher/workspaces/create",
385
+ body: req.body || {},
386
+ autoWake: true,
387
+ wakeIntent: "create_workspace"
388
+ });
389
+ });
390
+
391
+ app.get("/api/devices/:id/launcher/runs", async (req, res) => {
392
+ const pathWithQuery = withQuery("/api/launcher/runs", req.query);
393
+ return proxyToLaptopBridge(req, res, {
394
+ method: "GET",
395
+ path: pathWithQuery
396
+ });
397
+ });
398
+
399
+ app.get("/api/devices/:id/launcher/services", async (req, res) => {
400
+ const pathWithQuery = withQuery("/api/launcher/services", req.query);
401
+ return proxyToLaptopBridge(req, res, {
402
+ method: "GET",
403
+ path: pathWithQuery
404
+ });
405
+ });
406
+
407
+ app.post("/api/devices/:id/launcher/start", async (req, res) => {
408
+ return proxyToLaptopBridge(req, res, {
409
+ method: "POST",
410
+ path: "/api/launcher/start",
411
+ body: req.body || {},
412
+ autoWake: true,
413
+ wakeIntent: "launch_run"
414
+ });
415
+ });
416
+
417
+ app.post("/api/devices/:id/launcher/runs/:runId/stop", async (req, res) => {
418
+ const runId = encodeURIComponent(String(req.params.runId || ""));
419
+ return proxyToLaptopBridge(req, res, {
420
+ method: "POST",
421
+ path: `/api/launcher/runs/${runId}/stop`,
422
+ body: req.body || {}
423
+ });
424
+ });
425
+
426
+ app.post("/api/devices/:id/launcher/services/start", async (req, res) => {
427
+ return proxyToLaptopBridge(req, res, {
428
+ method: "POST",
429
+ path: "/api/launcher/services/start",
430
+ body: req.body || {},
431
+ autoWake: true,
432
+ wakeIntent: "start_background_service"
433
+ });
434
+ });
435
+
436
+ app.post("/api/devices/:id/launcher/services/:serviceId/stop", async (req, res) => {
437
+ const serviceId = encodeURIComponent(String(req.params.serviceId || ""));
438
+ return proxyToLaptopBridge(req, res, {
439
+ method: "POST",
440
+ path: `/api/launcher/services/${serviceId}/stop`,
441
+ body: req.body || {}
442
+ });
443
+ });
444
+
445
+ app.post("/api/devices/:id/settings/update", async (req, res) => {
446
+ return proxyToLaptopBridge(req, res, {
447
+ method: "POST",
448
+ path: "/api/settings/update",
449
+ body: req.body || {},
450
+ autoWake: true,
451
+ wakeIntent: "update_settings"
452
+ });
453
+ });
454
+
455
+ app.post("/api/devices/:id/sessions/:sessionId/messages", async (req, res) => {
456
+ const sessionId = encodeURIComponent(safeText(req.params.sessionId, 200));
457
+ if (!sessionId) {
458
+ return res.status(400).json({ ok: false, error: "sessionId is required" });
459
+ }
460
+
461
+ return proxyToLaptopBridge(req, res, {
462
+ method: "POST",
463
+ path: `/api/sessions/${sessionId}/messages`,
464
+ body: req.body || {},
465
+ autoWake: true,
466
+ wakeIntent: "send_message"
467
+ });
468
+ });
469
+
470
+ app.get("/api/devices/:id/previews", (req, res) => {
471
+ cleanupExpiredPreviews();
472
+ const laptop = req.deviceLaptop;
473
+ const previews = listPreviewsForDevice(laptop.deviceId).map((item) =>
474
+ serializePreview(item, {
475
+ connected: isLaptopConnected(item.laptopId)
476
+ })
477
+ );
478
+
479
+ return res.json({
480
+ ok: true,
481
+ previews
482
+ });
483
+ });
484
+
485
+ app.post("/api/devices/:id/previews", (req, res) => {
486
+ cleanupExpiredPreviews();
487
+ const laptop = req.deviceLaptop;
488
+ const previewTarget = normalizePreviewTarget(req.body?.targetUrl || req.body?.url, req.body?.port);
489
+ if (!previewTarget) {
490
+ return res.status(400).json({
491
+ ok: false,
492
+ error: "invalid preview target (use localhost/127.0.0.1 URL or a valid port)"
493
+ });
494
+ }
495
+
496
+ const requestedTtlSec = toInt(req.body?.expiresInSec, Math.round(PREVIEW_DEFAULT_TTL_MS / 1000));
497
+ const expiresInSec = clamp(requestedTtlSec, 60, Math.round(PREVIEW_MAX_TTL_MS / 1000));
498
+ const now = Date.now();
499
+
500
+ const preview = {
501
+ previewId: createId("preview"),
502
+ accessToken: createToken("pvw"),
503
+ laptopId: laptop.laptopId,
504
+ deviceId: laptop.deviceId,
505
+ label: safeText(req.body?.label, 120) || null,
506
+ target: previewTarget,
507
+ createdAt: now,
508
+ updatedAt: now,
509
+ lastAccessedAt: null,
510
+ expiresAt: now + expiresInSec * 1000,
511
+ createdByPhoneToken: safeText(req.phoneSession?.phoneToken, 500) || null
512
+ };
513
+
514
+ mutateState(() => {
515
+ state.previews.push(preview);
516
+ trimStateCollections();
517
+ });
518
+
519
+ return res.status(201).json({
520
+ ok: true,
521
+ preview: serializePreview(preview, {
522
+ connected: isLaptopConnected(preview.laptopId)
523
+ })
524
+ });
525
+ });
526
+
527
+ app.delete("/api/devices/:id/previews/:previewId", (req, res) => {
528
+ cleanupExpiredPreviews();
529
+ const previewId = safeText(req.params.previewId, 200);
530
+ if (!previewId) {
531
+ return res.status(400).json({ ok: false, error: "previewId is required" });
532
+ }
533
+
534
+ let removed = false;
535
+ mutateState(() => {
536
+ const before = state.previews.length;
537
+ state.previews = state.previews.filter((item) => !(item.previewId === previewId && item.deviceId === req.deviceLaptop.deviceId));
538
+ removed = state.previews.length !== before;
539
+ });
540
+
541
+ if (!removed) {
542
+ return res.status(404).json({ ok: false, error: "preview not found" });
543
+ }
544
+
545
+ return res.json({ ok: true });
546
+ });
547
+
548
+ app.post("/api/devices/:id/wake", async (req, res) => {
549
+ const laptop = req.deviceLaptop;
550
+ const result = await ensureLaptopOnline(laptop, {
551
+ autoWake: true,
552
+ wakeIntent: "manual_wake",
553
+ timeoutMs: clamp(toInt(req.body?.timeoutMs, RELAY_WAKE_TIMEOUT_MS), 2_000, 5 * 60 * 1000)
554
+ });
555
+
556
+ if (!result.ok) {
557
+ return res.status(503).json({
558
+ ok: false,
559
+ error: result.error || "unable to wake device",
560
+ wakeAttempted: result.wakeAttempted
561
+ });
562
+ }
563
+
564
+ return res.json({
565
+ ok: true,
566
+ connected: isLaptopConnected(laptop.laptopId),
567
+ wakeAttempted: result.wakeAttempted
568
+ });
569
+ });
570
+
571
+ app.all(/^\/(?:preview|p)\/([^/]+)(?:\/(.*))?$/, (req, res) => {
572
+ return handlePreviewProxy(req, res);
573
+ });
574
+
575
+ // Support assets requested via absolute root paths (e.g. /styles.css) from preview pages.
576
+ // Many local dev servers emit root-absolute URLs, which would otherwise bypass /p/:token.
577
+ app.all(/^\/(?!api(?:\/|$)|ws(?:\/|$)|pair(?:\/|$)|health$|preview(?:\/|$)|p(?:\/|$)).+/, (req, res, next) => {
578
+ const previewToken = extractPreviewTokenFromCookie(req) || extractPreviewTokenFromReferer(req);
579
+ if (!previewToken) return next();
580
+ return handlePreviewProxy(req, res, {
581
+ forcedToken: previewToken,
582
+ forcedSuffix: req.path || "/"
583
+ });
584
+ });
585
+
586
+ server.on("upgrade", (request, socket, head) => {
587
+ let parsedUrl;
588
+ try {
589
+ parsedUrl = new URL(request.url || "/", `http://${request.headers.host || "localhost"}`);
590
+ } catch {
591
+ socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
592
+ socket.destroy();
593
+ return;
594
+ }
595
+
596
+ if (parsedUrl.pathname !== "/ws/laptop") {
597
+ socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
598
+ socket.destroy();
599
+ return;
600
+ }
601
+
602
+ const token = safeText(parsedUrl.searchParams.get("token"), 4000);
603
+ const resolved = resolveLaptopAuth(token);
604
+ const laptop = resolved?.laptop || null;
605
+ if (!laptop) {
606
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
607
+ socket.destroy();
608
+ return;
609
+ }
610
+
611
+ wss.handleUpgrade(request, socket, head, (ws) => {
612
+ wss.emit("connection", ws, laptop, resolved);
613
+ });
614
+ });
615
+
616
+ wss.on("connection", (ws, laptop, authInfo) => {
617
+ const existing = laptopSockets.get(laptop.laptopId);
618
+ if (existing && existing !== ws && existing.readyState === WebSocket.OPEN) {
619
+ existing.close(4000, "replaced by newer connection");
620
+ }
621
+
622
+ laptopSockets.set(laptop.laptopId, ws);
623
+
624
+ mutateState(() => {
625
+ laptop.lastConnectedAt = Date.now();
626
+ laptop.updatedAt = Date.now();
627
+ });
628
+
629
+ sendWsJson(ws, {
630
+ type: "welcome",
631
+ laptopId: laptop.laptopId,
632
+ deviceId: laptop.deviceId,
633
+ laptopToken: authInfo?.refreshedToken || null,
634
+ relayTime: Date.now()
635
+ });
636
+
637
+ ws.on("message", (chunk, isBinary) => {
638
+ if (isBinary) return;
639
+
640
+ let message;
641
+ try {
642
+ message = JSON.parse(chunk.toString());
643
+ } catch {
644
+ return;
645
+ }
646
+
647
+ if (!isObject(message)) return;
648
+
649
+ if (message.type === "rpc_response" && typeof message.id === "string") {
650
+ settlePendingRpc(laptop.laptopId, message);
651
+ return;
652
+ }
653
+
654
+ if (message.type === "snapshot" && isObject(message.snapshot)) {
655
+ mutateState(() => {
656
+ laptop.latestSnapshot = message.snapshot;
657
+ laptop.lastSnapshotAt = Date.now();
658
+ laptop.updatedAt = Date.now();
659
+ });
660
+ return;
661
+ }
662
+
663
+ if (message.type === "ping") {
664
+ sendWsJson(ws, { type: "pong", ts: Date.now() });
665
+ }
666
+ });
667
+
668
+ ws.on("close", () => {
669
+ if (laptopSockets.get(laptop.laptopId) === ws) {
670
+ laptopSockets.delete(laptop.laptopId);
671
+ }
672
+
673
+ rejectPendingRpcsForLaptop(laptop.laptopId, "laptop disconnected");
674
+
675
+ mutateState(() => {
676
+ laptop.lastDisconnectedAt = Date.now();
677
+ laptop.updatedAt = Date.now();
678
+ });
679
+ });
680
+
681
+ ws.on("error", () => {
682
+ // errors are handled by close/retry paths
683
+ });
684
+ });
685
+
686
+ const cleanupTicker = setInterval(() => {
687
+ cleanupExpiredPairings();
688
+ cleanupExpiredPreviews();
689
+ }, CLEANUP_INTERVAL_MS);
690
+ cleanupTicker.unref();
691
+
692
+ server.listen(RELAY_PORT);
693
+
694
+ process.on("SIGINT", () => gracefulShutdown("SIGINT"));
695
+ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
696
+
697
+ async function proxyToLaptopBridge(req, res, input) {
698
+ const laptop = req.deviceLaptop;
699
+
700
+ const online = await ensureLaptopOnline(laptop, {
701
+ autoWake: Boolean(input?.autoWake),
702
+ wakeIntent: safeText(input?.wakeIntent, 80) || "proxy_request"
703
+ });
704
+ if (!online.ok) {
705
+ return res.status(503).json({
706
+ ok: false,
707
+ error: online.error || "laptop is not connected",
708
+ wakeAttempted: online.wakeAttempted
709
+ });
710
+ }
711
+
712
+ try {
713
+ const rpc = await sendLaptopRpc(laptop.laptopId, input);
714
+ return relayRpcResponse(res, rpc);
715
+ } catch (error) {
716
+ return res.status(resolveRpcErrorStatus(error)).json({
717
+ ok: false,
718
+ error: String(error?.message || error)
719
+ });
720
+ }
721
+ }
722
+
723
+ async function handlePreviewProxy(req, res, options = {}) {
724
+ cleanupExpiredPreviews();
725
+
726
+ const rawToken =
727
+ safeText(options?.forcedToken, 500) ||
728
+ (req.params && typeof req.params === "object" ? req.params.token ?? req.params[0] : "");
729
+ const accessToken = safeText(rawToken, 500);
730
+ if (!accessToken) {
731
+ return res.status(400).type("text/plain").send("preview token is required");
732
+ }
733
+
734
+ const hasForcedSuffix = Boolean(safeText(options?.forcedSuffix, 4000));
735
+ if (!hasForcedSuffix && isPreviewRootPathWithoutTrailingSlash(req.path) && isSafeMethod(req.method)) {
736
+ const query = req.url.includes("?") ? req.url.slice(req.url.indexOf("?")) : "";
737
+ return res.redirect(307, `${req.path}/${query}`);
738
+ }
739
+
740
+ const preview = findPreviewByAccessToken(accessToken);
741
+ if (!preview) {
742
+ return res.status(404).type("text/plain").send("preview link expired or not found");
743
+ }
744
+ setPreviewTokenCookie(res, accessToken, preview.expiresAt);
745
+
746
+ const laptop = findLaptopById(preview.laptopId);
747
+ if (!laptop) {
748
+ return res.status(404).type("text/plain").send("preview device not found");
749
+ }
750
+
751
+ const online = await ensureLaptopOnline(laptop, {
752
+ autoWake: true,
753
+ wakeIntent: "preview_request"
754
+ });
755
+ if (!online.ok) {
756
+ return res.status(503).type("text/plain").send("laptop is offline");
757
+ }
758
+
759
+ const rawSuffix =
760
+ safeText(options?.forcedSuffix, 4000) ||
761
+ (req.params && typeof req.params === "object" ? req.params.rest ?? req.params[1] : "");
762
+ const suffix = safePreviewPathSuffix(rawSuffix);
763
+ const query = req.url.includes("?") ? req.url.slice(req.url.indexOf("?")) : "";
764
+ const proxiedPath = `${suffix}${query}`;
765
+ const rpcPath =
766
+ "/__relay/preview/proxy" +
767
+ withQuery("", {
768
+ target: preview.target,
769
+ path: proxiedPath
770
+ });
771
+
772
+ const headers = sanitizePreviewForwardHeaders(req.headers);
773
+
774
+ try {
775
+ const rpc = await sendLaptopRpc(
776
+ preview.laptopId,
777
+ {
778
+ method: String(req.method || "GET").toUpperCase(),
779
+ path: rpcPath,
780
+ headers,
781
+ body: normalizePreviewForwardBody(req)
782
+ },
783
+ PREVIEW_RPC_TIMEOUT_MS
784
+ );
785
+ const rewrittenRpc = maybeRewritePreviewTextResponse(rpc, accessToken, preview.target, proxiedPath);
786
+
787
+ mutateState(() => {
788
+ preview.lastAccessedAt = Date.now();
789
+ preview.updatedAt = Date.now();
790
+ });
791
+
792
+ return relayRpcResponse(res, rewrittenRpc);
793
+ } catch (error) {
794
+ return res.status(resolveRpcErrorStatus(error)).type("text/plain").send(String(error?.message || error));
795
+ }
796
+ }
797
+
798
+ function setPreviewTokenCookie(res, accessToken, expiresAt) {
799
+ const token = safeText(accessToken, 500);
800
+ if (!token) return;
801
+ const now = Date.now();
802
+ const maxAgeSec = Math.max(60, Math.floor((toInt(expiresAt, now + 60_000) - now) / 1000));
803
+ const cookie = `ac_preview_token=${encodeURIComponent(token)}; Path=/; Max-Age=${maxAgeSec}; SameSite=Lax`;
804
+ res.append("Set-Cookie", cookie);
805
+ }
806
+
807
+ function maybeRewritePreviewTextResponse(rpc, accessToken, previewTarget, requestPath) {
808
+ if (!rpc || typeof rpc !== "object") return rpc;
809
+ if (safeText(rpc.bodyType, 20) !== "text") return rpc;
810
+ if (typeof rpc.body !== "string" || !rpc.body) return rpc;
811
+
812
+ const token = safeText(accessToken, 500);
813
+ if (!token) return rpc;
814
+ const kind = detectPreviewTextKind(rpc.responseHeaders, rpc.body, requestPath);
815
+ if (!kind) return rpc;
816
+
817
+ const prefix = `/p/${encodeURIComponent(token)}`;
818
+ let text = String(rpc.body);
819
+
820
+ // Rewrite absolute localhost URLs to preview-prefixed paths.
821
+ const origins = buildLocalhostOrigins(previewTarget);
822
+ for (const origin of origins) {
823
+ const escaped = escapeRegExp(origin);
824
+ text = text.replace(new RegExp(`${escaped}/`, "gi"), `${prefix}/`);
825
+ }
826
+
827
+ if (kind === "html") {
828
+ text = ensurePreviewBaseTag(text, `${prefix}/`);
829
+
830
+ // Prefix root-relative resource attributes so they keep preview token context.
831
+ text = text.replace(
832
+ /(\b(?:href|src|action|poster)\s*=\s*["'])\/(?!\/|p\/|preview\/)/gi,
833
+ `$1${prefix}/`
834
+ );
835
+ }
836
+
837
+ if (kind === "html" || kind === "css" || kind === "js") {
838
+ // Prefix root-relative URL literals in text assets.
839
+ text = text.replace(/(["'`])\/(?!\/|p\/|preview\/)/g, `$1${prefix}/`);
840
+ text = text.replace(/url\((['"]?)\/(?!\/|p\/|preview\/)/gi, `url($1${prefix}/`);
841
+ }
842
+
843
+ if (kind === "js") {
844
+ text = text.replace(/\bfrom\s+(['"])\/(?!\/|p\/|preview\/)/g, `from $1${prefix}/`);
845
+ text = text.replace(/\bimport\(\s*(['"])\/(?!\/|p\/|preview\/)/g, `import($1${prefix}/`);
846
+ }
847
+
848
+ const nextHeaders = {
849
+ ...(isObject(rpc.responseHeaders) ? rpc.responseHeaders : {})
850
+ };
851
+ const existingContentType = getHeaderCaseInsensitive(nextHeaders, "content-type");
852
+ if (!existingContentType) {
853
+ if (kind === "html") nextHeaders["content-type"] = "text/html; charset=utf-8";
854
+ if (kind === "css") nextHeaders["content-type"] = "text/css; charset=utf-8";
855
+ if (kind === "js") nextHeaders["content-type"] = "application/javascript; charset=utf-8";
856
+ }
857
+
858
+ return { ...rpc, bodyType: "text", body: text, responseHeaders: nextHeaders };
859
+ }
860
+
861
+ function ensurePreviewBaseTag(htmlText, baseHref) {
862
+ let html = String(htmlText || "");
863
+ const base = String(baseHref || "").trim();
864
+ if (!base) return html;
865
+
866
+ if (/<base\b/i.test(html)) {
867
+ return html.replace(/<base\b[^>]*href\s*=\s*["'][^"']*["'][^>]*>/i, `<base href="${base}">`);
868
+ }
869
+
870
+ if (/<head\b[^>]*>/i.test(html)) {
871
+ return html.replace(/<head\b[^>]*>/i, (match) => `${match}\n<base href="${base}">`);
872
+ }
873
+
874
+ return `<base href="${base}">\n${html}`;
875
+ }
876
+
877
+ function isHtmlResponse(headersInput, bodyText) {
878
+ const contentType = getHeaderCaseInsensitive(headersInput, "content-type");
879
+ if (contentType && /text\/html|application\/xhtml\+xml/i.test(contentType)) return true;
880
+ const sample = safeText(String(bodyText || "").slice(0, 300), 300).toLowerCase();
881
+ if (!sample) return false;
882
+ return sample.includes("<html") || sample.includes("<!doctype html");
883
+ }
884
+
885
+ function detectPreviewTextKind(headersInput, bodyText, requestPath) {
886
+ const contentType = getHeaderCaseInsensitive(headersInput, "content-type").toLowerCase();
887
+ if (contentType.includes("text/html") || contentType.includes("application/xhtml+xml")) return "html";
888
+ if (contentType.includes("text/css")) return "css";
889
+ if (contentType.includes("javascript") || contentType.includes("ecmascript")) return "js";
890
+
891
+ const pathOnly = safeText(String(requestPath || "").split("?")[0], 2000).toLowerCase();
892
+ if (pathOnly.endsWith(".html") || pathOnly.endsWith(".htm")) return "html";
893
+ if (pathOnly.endsWith(".css")) return "css";
894
+ if (
895
+ pathOnly.endsWith(".js") ||
896
+ pathOnly.endsWith(".mjs") ||
897
+ pathOnly.endsWith(".cjs") ||
898
+ pathOnly.endsWith(".ts") ||
899
+ pathOnly.endsWith(".tsx")
900
+ ) {
901
+ return "js";
902
+ }
903
+
904
+ if (isHtmlResponse(headersInput, bodyText)) return "html";
905
+ return "";
906
+ }
907
+
908
+ function buildLocalhostOrigins(previewTarget) {
909
+ const target = safeText(previewTarget, 2000);
910
+ if (!target) return [];
911
+ try {
912
+ const parsed = new URL(target);
913
+ const protocol = parsed.protocol === "https:" ? "https" : "http";
914
+ const port = parsed.port ? `:${parsed.port}` : "";
915
+ const origins = new Set([
916
+ `${protocol}://127.0.0.1${port}`,
917
+ `${protocol}://localhost${port}`,
918
+ `${protocol}://[::1]${port}`
919
+ ]);
920
+ return [...origins];
921
+ } catch {
922
+ return [];
923
+ }
924
+ }
925
+
926
+ function escapeRegExp(value) {
927
+ return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
928
+ }
929
+
930
+ function extractPreviewTokenFromReferer(req) {
931
+ const refererRaw = safeText(req.header("referer"), 4000);
932
+ if (!refererRaw) return "";
933
+
934
+ try {
935
+ const parsed = new URL(refererRaw);
936
+ const match = parsed.pathname.match(/^\/(?:preview|p)\/([^/]+)/i);
937
+ if (!match || !match[1]) return "";
938
+ return safeText(decodeURIComponent(match[1]), 500);
939
+ } catch {
940
+ return "";
941
+ }
942
+ }
943
+
944
+ function isPreviewRootPathWithoutTrailingSlash(pathname) {
945
+ const path = safeText(pathname, 2000);
946
+ if (!path) return false;
947
+ return /^\/(?:preview|p)\/[^/]+$/i.test(path);
948
+ }
949
+
950
+ function isSafeMethod(method) {
951
+ const value = String(method || "").toUpperCase();
952
+ return value === "GET" || value === "HEAD";
953
+ }
954
+
955
+ function extractPreviewTokenFromCookie(req) {
956
+ const cookieHeader = safeText(req.header("cookie"), 8000);
957
+ if (!cookieHeader) return "";
958
+ const pairs = cookieHeader.split(";");
959
+ for (const pair of pairs) {
960
+ const [rawKey, ...rest] = pair.split("=");
961
+ if (!rawKey || rest.length === 0) continue;
962
+ const key = rawKey.trim();
963
+ if (key !== "ac_preview_token") continue;
964
+ try {
965
+ return safeText(decodeURIComponent(rest.join("=").trim()), 500);
966
+ } catch {
967
+ return safeText(rest.join("=").trim(), 500);
968
+ }
969
+ }
970
+ return "";
971
+ }
972
+
973
+ function getHeaderCaseInsensitive(headersInput, name) {
974
+ if (!isObject(headersInput)) return "";
975
+ const target = String(name || "").toLowerCase();
976
+ for (const [key, value] of Object.entries(headersInput)) {
977
+ if (String(key || "").toLowerCase() !== target) continue;
978
+ if (typeof value !== "string") continue;
979
+ return value;
980
+ }
981
+ return "";
982
+ }
983
+
984
+ function relayRpcResponse(res, rpc) {
985
+ const status = clamp(toInt(rpc?.status, rpc?.ok ? 200 : 500), 100, 599);
986
+ const bodyType = rpc?.bodyType;
987
+ const hasExplicitContentType = hasHeaderCaseInsensitive(rpc?.responseHeaders, "content-type");
988
+ applyRpcResponseHeaders(res, rpc?.responseHeaders);
989
+
990
+ if (bodyType === "empty") {
991
+ return res.status(status).end();
992
+ }
993
+
994
+ if (bodyType === "base64" || rpc?.bodyEncoding === "base64") {
995
+ const payload = typeof rpc?.body === "string" ? Buffer.from(rpc.body, "base64") : Buffer.alloc(0);
996
+ return res.status(status).send(payload);
997
+ }
998
+
999
+ if (bodyType === "text") {
1000
+ const response = res.status(status);
1001
+ if (!hasExplicitContentType) {
1002
+ response.type("text/plain");
1003
+ }
1004
+ return response.send(String(rpc?.body ?? ""));
1005
+ }
1006
+
1007
+ if (rpc?.body !== undefined) {
1008
+ if (typeof rpc.body === "string") {
1009
+ const response = res.status(status);
1010
+ if (!hasExplicitContentType) {
1011
+ response.type("text/plain");
1012
+ }
1013
+ return response.send(rpc.body);
1014
+ }
1015
+ return res.status(status).json(rpc.body);
1016
+ }
1017
+
1018
+ if (rpc?.error) {
1019
+ return res.status(status).json({ ok: false, error: rpc.error });
1020
+ }
1021
+
1022
+ return res.status(status).json({ ok: Boolean(rpc?.ok) });
1023
+ }
1024
+
1025
+ function hasHeaderCaseInsensitive(headersInput, name) {
1026
+ if (!isObject(headersInput)) return false;
1027
+ const target = String(name || "").toLowerCase();
1028
+ return Object.keys(headersInput).some((key) => String(key || "").toLowerCase() === target);
1029
+ }
1030
+
1031
+ function applyRpcResponseHeaders(res, headersInput) {
1032
+ if (!isObject(headersInput)) return;
1033
+
1034
+ for (const [rawName, rawValue] of Object.entries(headersInput)) {
1035
+ if (typeof rawValue !== "string") continue;
1036
+ const name = String(rawName || "").trim();
1037
+ if (!name || /[\r\n]/.test(name)) continue;
1038
+ if (/[^\t\x20-\x7e]/.test(name)) continue;
1039
+ const lower = name.toLowerCase();
1040
+ if (lower === "content-length" || lower === "transfer-encoding" || lower === "connection") continue;
1041
+
1042
+ const value = rawValue.replace(/[\r\n]+/g, " ").trim();
1043
+ if (!value) continue;
1044
+ res.setHeader(name, value);
1045
+ }
1046
+ }
1047
+
1048
+ function resolveRpcErrorStatus(error) {
1049
+ if (error?.code === "timeout") return 504;
1050
+ if (error?.code === "offline") return 503;
1051
+ return 502;
1052
+ }
1053
+
1054
+ function requirePhoneToken(req, res, next) {
1055
+ const token = extractPhoneToken(req);
1056
+ if (!token) {
1057
+ return res.status(401).json({ ok: false, error: "phone token missing" });
1058
+ }
1059
+
1060
+ const resolved = resolvePhoneSession(token);
1061
+ const phone = resolved?.phone || null;
1062
+ if (!phone) {
1063
+ return res.status(401).json({ ok: false, error: "invalid phone token" });
1064
+ }
1065
+
1066
+ if (resolved?.refreshedToken && resolved.refreshedToken !== token) {
1067
+ res.setHeader(PHONE_TOKEN_HEADER, resolved.refreshedToken);
1068
+ }
1069
+
1070
+ phone.lastUsedAt = Date.now();
1071
+ state.updatedAt = Date.now();
1072
+ schedulePersist();
1073
+
1074
+ req.phoneSession = phone;
1075
+ return next();
1076
+ }
1077
+
1078
+ function requireLaptopToken(req, res, next) {
1079
+ const token = extractLaptopToken(req);
1080
+ if (!token) {
1081
+ return res.status(401).json({ ok: false, error: "laptop token missing" });
1082
+ }
1083
+
1084
+ const resolved = resolveLaptopAuth(token);
1085
+ const laptop = resolved?.laptop || null;
1086
+ if (!laptop) {
1087
+ return res.status(401).json({ ok: false, error: "invalid laptop token" });
1088
+ }
1089
+
1090
+ if (resolved?.refreshedToken && resolved.refreshedToken !== token) {
1091
+ res.setHeader(LAPTOP_TOKEN_HEADER, resolved.refreshedToken);
1092
+ req.refreshedLaptopToken = resolved.refreshedToken;
1093
+ }
1094
+
1095
+ laptop.updatedAt = Date.now();
1096
+ state.updatedAt = Date.now();
1097
+ schedulePersist();
1098
+
1099
+ req.laptopSession = laptop;
1100
+ return next();
1101
+ }
1102
+
1103
+ function requireDeviceAccess(req, res, next) {
1104
+ const deviceId = safeText(req.params.id, 200);
1105
+ const phone = req.phoneSession;
1106
+
1107
+ if (!phone || phone.deviceId !== deviceId) {
1108
+ return res.status(403).json({ ok: false, error: "token cannot access this device" });
1109
+ }
1110
+
1111
+ const laptop = findLaptopByDeviceId(deviceId);
1112
+ if (!laptop) {
1113
+ return res.status(404).json({ ok: false, error: "device not found" });
1114
+ }
1115
+
1116
+ req.deviceLaptop = laptop;
1117
+ return next();
1118
+ }
1119
+
1120
+ function extractPhoneToken(req) {
1121
+ const authHeader = String(req.header("authorization") || "");
1122
+ if (authHeader.toLowerCase().startsWith("bearer ")) {
1123
+ const bearer = authHeader.slice(7).trim();
1124
+ if (bearer) return bearer;
1125
+ }
1126
+
1127
+ const fromHeader = safeText(req.header("x-phone-token"), 400);
1128
+ if (fromHeader) return fromHeader;
1129
+
1130
+ const fromQuery = safeText(req.query?.phoneToken, 400);
1131
+ if (fromQuery) return fromQuery;
1132
+
1133
+ return "";
1134
+ }
1135
+
1136
+ function extractLaptopToken(req) {
1137
+ const authHeader = String(req.header("authorization") || "");
1138
+ if (authHeader.toLowerCase().startsWith("bearer ")) {
1139
+ const bearer = authHeader.slice(7).trim();
1140
+ if (bearer) return bearer;
1141
+ }
1142
+
1143
+ const fromHeader = safeText(req.header("x-laptop-token"), 500);
1144
+ if (fromHeader) return fromHeader;
1145
+
1146
+ const fromBody = safeText(req.body?.laptopToken, 500);
1147
+ if (fromBody) return fromBody;
1148
+
1149
+ const fromQuery = safeText(req.query?.laptopToken, 500);
1150
+ if (fromQuery) return fromQuery;
1151
+
1152
+ return "";
1153
+ }
1154
+
1155
+ async function sendLaptopRpc(laptopId, request, timeoutMs = RPC_TIMEOUT_MS) {
1156
+ const socket = laptopSockets.get(laptopId);
1157
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
1158
+ const error = new Error("laptop is not connected");
1159
+ error.code = "offline";
1160
+ throw error;
1161
+ }
1162
+
1163
+ const id = createId("rpc");
1164
+
1165
+ return new Promise((resolve, reject) => {
1166
+ const timer = setTimeout(() => {
1167
+ pendingRpcs.delete(id);
1168
+ const error = new Error("rpc request timed out");
1169
+ error.code = "timeout";
1170
+ reject(error);
1171
+ }, timeoutMs);
1172
+
1173
+ pendingRpcs.set(id, {
1174
+ id,
1175
+ laptopId,
1176
+ resolve,
1177
+ reject,
1178
+ timer
1179
+ });
1180
+
1181
+ try {
1182
+ sendWsJson(socket, {
1183
+ type: "rpc_request",
1184
+ id,
1185
+ request: {
1186
+ method: String(request?.method || "GET").toUpperCase(),
1187
+ path: String(request?.path || "/"),
1188
+ headers: isObject(request?.headers) ? request.headers : {},
1189
+ body: request?.body
1190
+ }
1191
+ });
1192
+ } catch (error) {
1193
+ clearTimeout(timer);
1194
+ pendingRpcs.delete(id);
1195
+ reject(error);
1196
+ }
1197
+ });
1198
+ }
1199
+
1200
+ async function ensureLaptopOnline(laptop, options = {}) {
1201
+ if (!laptop) {
1202
+ return { ok: false, error: "device not found", wakeAttempted: false };
1203
+ }
1204
+
1205
+ if (isLaptopConnected(laptop.laptopId)) {
1206
+ return { ok: true, wakeAttempted: false };
1207
+ }
1208
+
1209
+ if (!options.autoWake) {
1210
+ return { ok: false, error: "laptop is not connected", wakeAttempted: false };
1211
+ }
1212
+
1213
+ const wakeResult = await triggerWakeAndWait(laptop, {
1214
+ wakeIntent: options.wakeIntent,
1215
+ timeoutMs: options.timeoutMs
1216
+ });
1217
+ if (wakeResult.ok) {
1218
+ return { ok: true, wakeAttempted: true };
1219
+ }
1220
+
1221
+ return {
1222
+ ok: false,
1223
+ error: wakeResult.error || "laptop is not connected",
1224
+ wakeAttempted: true
1225
+ };
1226
+ }
1227
+
1228
+ async function triggerWakeAndWait(laptop, options = {}) {
1229
+ const pending = pendingWakeAttempts.get(laptop.laptopId);
1230
+ if (pending) return pending;
1231
+
1232
+ const attempt = (async () => {
1233
+ const wakeMacAddress = normalizeMacAddress(laptop?.wake?.macAddress);
1234
+ if (!RELAY_WAKE_PROXY_URL) {
1235
+ return { ok: false, error: "wake proxy is not configured" };
1236
+ }
1237
+ if (!wakeMacAddress) {
1238
+ return { ok: false, error: "wake MAC address is not configured for this laptop" };
1239
+ }
1240
+
1241
+ mutateState(() => {
1242
+ laptop.lastWakeRequestedAt = Date.now();
1243
+ laptop.lastWakeResult = "requested";
1244
+ });
1245
+
1246
+ const wakeProxyResponse = await callWakeProxy({
1247
+ laptopId: laptop.laptopId,
1248
+ deviceId: laptop.deviceId,
1249
+ macAddress: wakeMacAddress,
1250
+ intent: safeText(options?.wakeIntent, 80) || "auto_wake",
1251
+ laptopName: safeText(laptop.name, 120) || null
1252
+ });
1253
+
1254
+ if (!wakeProxyResponse.ok) {
1255
+ mutateState(() => {
1256
+ laptop.lastWakeResult = `failed:${wakeProxyResponse.error || "unknown"}`;
1257
+ });
1258
+ return { ok: false, error: wakeProxyResponse.error || "wake proxy request failed" };
1259
+ }
1260
+
1261
+ const timeoutMs = clamp(toInt(options?.timeoutMs, RELAY_WAKE_TIMEOUT_MS), 2_000, 5 * 60 * 1000);
1262
+ const deadline = Date.now() + timeoutMs;
1263
+
1264
+ while (Date.now() < deadline) {
1265
+ if (isLaptopConnected(laptop.laptopId)) {
1266
+ mutateState(() => {
1267
+ laptop.lastWakeResult = "online";
1268
+ });
1269
+ return { ok: true };
1270
+ }
1271
+ await sleep(RELAY_WAKE_POLL_INTERVAL_MS);
1272
+ }
1273
+
1274
+ mutateState(() => {
1275
+ laptop.lastWakeResult = "timeout";
1276
+ });
1277
+ return { ok: false, error: "wake timed out; laptop did not reconnect" };
1278
+ })()
1279
+ .catch((error) => ({ ok: false, error: String(error?.message || error) }))
1280
+ .finally(() => {
1281
+ pendingWakeAttempts.delete(laptop.laptopId);
1282
+ });
1283
+
1284
+ pendingWakeAttempts.set(laptop.laptopId, attempt);
1285
+ return attempt;
1286
+ }
1287
+
1288
+ async function callWakeProxy(payload) {
1289
+ const controller = new AbortController();
1290
+ const timeout = setTimeout(() => controller.abort(), RELAY_WAKE_REQUEST_TIMEOUT_MS);
1291
+
1292
+ try {
1293
+ const headers = {
1294
+ "Content-Type": "application/json",
1295
+ Accept: "application/json"
1296
+ };
1297
+ if (RELAY_WAKE_PROXY_TOKEN) {
1298
+ headers.Authorization = `Bearer ${RELAY_WAKE_PROXY_TOKEN}`;
1299
+ }
1300
+
1301
+ const response = await fetch(`${RELAY_WAKE_PROXY_URL}/api/wake`, {
1302
+ method: "POST",
1303
+ headers,
1304
+ body: JSON.stringify(payload),
1305
+ signal: controller.signal
1306
+ });
1307
+
1308
+ const body = await safeParseJsonResponse(response);
1309
+ if (!response.ok) {
1310
+ return { ok: false, error: safeText(body?.error, 240) || `wake proxy error (${response.status})` };
1311
+ }
1312
+
1313
+ return { ok: true };
1314
+ } catch (error) {
1315
+ return { ok: false, error: String(error?.message || error) };
1316
+ } finally {
1317
+ clearTimeout(timeout);
1318
+ }
1319
+ }
1320
+
1321
+ function settlePendingRpc(laptopId, message) {
1322
+ const pending = pendingRpcs.get(message.id);
1323
+ if (!pending || pending.laptopId !== laptopId) return;
1324
+
1325
+ clearTimeout(pending.timer);
1326
+ pendingRpcs.delete(message.id);
1327
+
1328
+ pending.resolve({
1329
+ ok: Boolean(message.ok),
1330
+ status: toInt(message.status, message.ok ? 200 : 500),
1331
+ bodyType: safeText(message.bodyType, 20) || "json",
1332
+ body: message.body,
1333
+ bodyEncoding: safeText(message.bodyEncoding, 20) || null,
1334
+ responseHeaders: isObject(message.responseHeaders) ? message.responseHeaders : null,
1335
+ error: safeText(message.error, 1000) || null
1336
+ });
1337
+ }
1338
+
1339
+ function rejectPendingRpcsForLaptop(laptopId, reason) {
1340
+ for (const [id, pending] of pendingRpcs.entries()) {
1341
+ if (pending.laptopId !== laptopId) continue;
1342
+ clearTimeout(pending.timer);
1343
+ pendingRpcs.delete(id);
1344
+ const error = new Error(reason);
1345
+ error.code = "offline";
1346
+ pending.reject(error);
1347
+ }
1348
+ }
1349
+
1350
+ function sendWsJson(ws, payload) {
1351
+ if (ws.readyState !== WebSocket.OPEN) return;
1352
+ ws.send(JSON.stringify(payload));
1353
+ }
1354
+
1355
+ function isLaptopConnected(laptopId) {
1356
+ const socket = laptopSockets.get(laptopId);
1357
+ return Boolean(socket && socket.readyState === WebSocket.OPEN);
1358
+ }
1359
+
1360
+ function createPairingForLaptop(laptop) {
1361
+ let code = "";
1362
+ for (let attempt = 0; attempt < 12; attempt += 1) {
1363
+ const candidate = generatePairCode();
1364
+ if (!findPairingByCode(candidate)) {
1365
+ code = candidate;
1366
+ break;
1367
+ }
1368
+ }
1369
+
1370
+ if (!code) {
1371
+ code = `${generatePairCode()}${Math.floor(Math.random() * 10)}`;
1372
+ }
1373
+
1374
+ const now = Date.now();
1375
+ const expiresAt = now + PAIRING_TTL_MS;
1376
+ const pairingPayload = {
1377
+ relayUrl: RELAY_PUBLIC_URL,
1378
+ code,
1379
+ deviceId: laptop.deviceId,
1380
+ laptopId: laptop.laptopId
1381
+ };
1382
+ const pairingUrl = `${RELAY_PUBLIC_URL}/pair?code=${code}`;
1383
+
1384
+ const pairing = {
1385
+ code,
1386
+ laptopId: laptop.laptopId,
1387
+ deviceId: laptop.deviceId,
1388
+ createdAt: now,
1389
+ expiresAt,
1390
+ claimedAt: null,
1391
+ phoneToken: null,
1392
+ pairingUrl,
1393
+ pairingPayload
1394
+ };
1395
+
1396
+ mutateState(() => {
1397
+ state.pairings = state.pairings.filter((item) => item.laptopId !== laptop.laptopId || Boolean(item.claimedAt));
1398
+ state.pairings.push(pairing);
1399
+ laptop.pairCode = code;
1400
+ laptop.pairingExpiresAt = expiresAt;
1401
+ laptop.pairingUrl = pairingUrl;
1402
+ laptop.pairingPayload = pairingPayload;
1403
+ laptop.updatedAt = now;
1404
+ });
1405
+
1406
+ return pairing;
1407
+ }
1408
+
1409
+ function cleanupExpiredPairings() {
1410
+ const now = Date.now();
1411
+ let changed = false;
1412
+
1413
+ const nextPairings = [];
1414
+ for (const pairing of state.pairings) {
1415
+ const isExpired = pairing.expiresAt <= now;
1416
+ const keepClaimed = pairing.claimedAt && now - pairing.claimedAt < 7 * 24 * 60 * 60 * 1000;
1417
+
1418
+ if (!isExpired || keepClaimed) {
1419
+ nextPairings.push(pairing);
1420
+ continue;
1421
+ }
1422
+
1423
+ if (!pairing.claimedAt) {
1424
+ const laptop = findLaptopById(pairing.laptopId);
1425
+ if (laptop && laptop.pairCode === pairing.code) {
1426
+ laptop.pairCode = null;
1427
+ laptop.pairingExpiresAt = null;
1428
+ laptop.pairingUrl = null;
1429
+ laptop.pairingPayload = null;
1430
+ laptop.updatedAt = now;
1431
+ }
1432
+ }
1433
+
1434
+ changed = true;
1435
+ }
1436
+
1437
+ if (!changed) return;
1438
+ state.pairings = nextPairings;
1439
+ state.updatedAt = now;
1440
+ schedulePersist();
1441
+ }
1442
+
1443
+ function cleanupExpiredPreviews() {
1444
+ const now = Date.now();
1445
+ const next = state.previews.filter((item) => item.expiresAt > now);
1446
+ if (next.length === state.previews.length) return;
1447
+
1448
+ state.previews = next;
1449
+ state.updatedAt = now;
1450
+ schedulePersist();
1451
+ }
1452
+
1453
+ function findLaptopById(laptopId) {
1454
+ return state.laptops.find((item) => item.laptopId === laptopId) || null;
1455
+ }
1456
+
1457
+ function findLaptopByToken(token) {
1458
+ if (!token) return null;
1459
+ return state.laptops.find((item) => item.laptopToken === token) || null;
1460
+ }
1461
+
1462
+ function findLaptopByDeviceId(deviceId) {
1463
+ if (!deviceId) return null;
1464
+ return state.laptops.find((item) => item.deviceId === deviceId) || null;
1465
+ }
1466
+
1467
+ function findPairingByCode(code) {
1468
+ if (!code) return null;
1469
+ return state.pairings.find((item) => item.code === code) || null;
1470
+ }
1471
+
1472
+ function findPhoneByToken(token) {
1473
+ if (!token) return null;
1474
+ return state.phones.find((item) => item.phoneToken === token) || null;
1475
+ }
1476
+
1477
+ function resolvePhoneSession(token) {
1478
+ const exact = findPhoneByToken(token);
1479
+ if (exact) {
1480
+ const refreshedToken = issuePhoneToken(exact.deviceId);
1481
+ return {
1482
+ phone: exact,
1483
+ refreshedToken: refreshedToken.startsWith("ptkn1_") ? refreshedToken : null
1484
+ };
1485
+ }
1486
+
1487
+ const claims = parseSignedToken(token, "ptkn1");
1488
+ if (!claims || safeText(claims.kind, 40) !== "phone") return null;
1489
+
1490
+ const deviceId = safeText(claims.deviceId, 200);
1491
+ if (!deviceId) return null;
1492
+
1493
+ const restored = {
1494
+ phoneToken: token,
1495
+ deviceId,
1496
+ createdAt: Date.now(),
1497
+ lastUsedAt: Date.now()
1498
+ };
1499
+
1500
+ mutateState(() => {
1501
+ if (!state.phones.some((item) => item.phoneToken === token)) {
1502
+ state.phones.push(restored);
1503
+ trimStateCollections();
1504
+ }
1505
+ });
1506
+
1507
+ return {
1508
+ phone: findPhoneByToken(token) || restored,
1509
+ refreshedToken: null
1510
+ };
1511
+ }
1512
+
1513
+ function resolveLaptopAuth(token) {
1514
+ const exact = findLaptopByToken(token);
1515
+ if (exact) {
1516
+ const refreshedToken = issueLaptopToken(exact.laptopId, exact.deviceId);
1517
+ return {
1518
+ laptop: exact,
1519
+ refreshedToken: refreshedToken.startsWith("ltkn1_") ? refreshedToken : null
1520
+ };
1521
+ }
1522
+
1523
+ const restored = restoreLaptopFromSignedToken(token);
1524
+ if (!restored) return null;
1525
+
1526
+ return {
1527
+ laptop: restored,
1528
+ refreshedToken: null
1529
+ };
1530
+ }
1531
+
1532
+ function resolveLaptopSession(token) {
1533
+ const resolved = resolveLaptopAuth(token);
1534
+ return resolved?.laptop || null;
1535
+ }
1536
+
1537
+ function restoreLaptopFromSignedToken(token) {
1538
+ const claims = parseSignedToken(token, "ltkn1");
1539
+ if (!claims || safeText(claims.kind, 40) !== "laptop") return null;
1540
+
1541
+ const laptopId = safeText(claims.laptopId, 200);
1542
+ const deviceId = safeText(claims.deviceId, 200);
1543
+ if (!laptopId || !deviceId) return null;
1544
+
1545
+ const existing = findLaptopById(laptopId) || findLaptopByDeviceId(deviceId);
1546
+ if (existing) {
1547
+ return existing;
1548
+ }
1549
+
1550
+ const restored = {
1551
+ laptopId,
1552
+ deviceId,
1553
+ laptopToken: token,
1554
+ name: null,
1555
+ createdAt: Date.now(),
1556
+ updatedAt: Date.now(),
1557
+ pairedAt: null,
1558
+ pairCode: null,
1559
+ pairingExpiresAt: null,
1560
+ pairingUrl: null,
1561
+ pairingPayload: null,
1562
+ lastConnectedAt: null,
1563
+ lastDisconnectedAt: null,
1564
+ lastSnapshotAt: null,
1565
+ latestSnapshot: null,
1566
+ wake: {
1567
+ macAddress: null
1568
+ },
1569
+ lastWakeRequestedAt: null,
1570
+ lastWakeResult: null
1571
+ };
1572
+
1573
+ mutateState(() => {
1574
+ const alreadyThere = findLaptopById(laptopId) || findLaptopByDeviceId(deviceId);
1575
+ if (!alreadyThere) {
1576
+ state.laptops.push(restored);
1577
+ trimStateCollections();
1578
+ }
1579
+ });
1580
+
1581
+ return findLaptopById(laptopId) || restored;
1582
+ }
1583
+
1584
+ function findPreviewByAccessToken(accessToken) {
1585
+ if (!accessToken) return null;
1586
+ return state.previews.find((item) => item.accessToken === accessToken) || null;
1587
+ }
1588
+
1589
+ function listPreviewsForDevice(deviceId) {
1590
+ if (!deviceId) return [];
1591
+ return state.previews
1592
+ .filter((item) => item.deviceId === deviceId && item.expiresAt > Date.now())
1593
+ .sort((a, b) => b.updatedAt - a.updatedAt);
1594
+ }
1595
+
1596
+ function serializePreview(preview, options = {}) {
1597
+ const token = safeText(preview?.accessToken, 500);
1598
+ const publicUrl = token ? `${RELAY_PUBLIC_URL}/p/${encodeURIComponent(token)}/` : "";
1599
+ return {
1600
+ id: safeText(preview?.previewId, 200),
1601
+ deviceId: safeText(preview?.deviceId, 200),
1602
+ laptopId: safeText(preview?.laptopId, 200),
1603
+ label: safeText(preview?.label, 120) || null,
1604
+ target: safeText(preview?.target, 2000),
1605
+ createdAt: toInt(preview?.createdAt, Date.now()),
1606
+ updatedAt: toInt(preview?.updatedAt, Date.now()),
1607
+ lastAccessedAt: preview?.lastAccessedAt ? toInt(preview.lastAccessedAt, null) : null,
1608
+ expiresAt: toInt(preview?.expiresAt, Date.now()),
1609
+ connected: options.connected === true,
1610
+ publicUrl
1611
+ };
1612
+ }
1613
+
1614
+ function normalizePreviewTarget(targetUrlInput, portInput) {
1615
+ let target = safeText(targetUrlInput, 2000);
1616
+ if (!target && portInput !== undefined && portInput !== null && String(portInput).trim()) {
1617
+ const port = toInt(portInput, 0);
1618
+ if (port <= 0 || port > 65535) return "";
1619
+ target = `http://127.0.0.1:${port}`;
1620
+ }
1621
+ if (!target) return "";
1622
+
1623
+ let parsed;
1624
+ try {
1625
+ parsed = new URL(target);
1626
+ } catch {
1627
+ return "";
1628
+ }
1629
+
1630
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "";
1631
+ const host = String(parsed.hostname || "").toLowerCase();
1632
+ if (host !== "localhost" && host !== "127.0.0.1" && host !== "::1" && host !== "0.0.0.0") return "";
1633
+
1634
+ const normalizedHost = host === "localhost" || host === "::1" || host === "0.0.0.0" ? "127.0.0.1" : host;
1635
+ const normalized = new URL(`${parsed.protocol}//${normalizedHost}`);
1636
+ if (parsed.port) {
1637
+ const port = toInt(parsed.port, 0);
1638
+ if (port <= 0 || port > 65535) return "";
1639
+ normalized.port = String(port);
1640
+ }
1641
+
1642
+ return trimTrailingSlash(normalized.toString());
1643
+ }
1644
+
1645
+ function safePreviewPathSuffix(value) {
1646
+ const segment = safeText(value, 4000);
1647
+ if (!segment) return "/";
1648
+ return segment.startsWith("/") ? segment : `/${segment}`;
1649
+ }
1650
+
1651
+ function sanitizePreviewForwardHeaders(headersInput) {
1652
+ if (!isObject(headersInput)) return {};
1653
+ const out = {};
1654
+
1655
+ for (const [nameRaw, valueRaw] of Object.entries(headersInput)) {
1656
+ if (Array.isArray(valueRaw)) continue;
1657
+ if (typeof valueRaw !== "string") continue;
1658
+ const name = String(nameRaw || "").trim();
1659
+ if (!name) continue;
1660
+ const lower = name.toLowerCase();
1661
+ if (
1662
+ lower === "host" ||
1663
+ lower === "content-length" ||
1664
+ lower === "connection" ||
1665
+ lower === "transfer-encoding" ||
1666
+ lower === "upgrade" ||
1667
+ lower === "authorization" ||
1668
+ lower === "x-phone-token" ||
1669
+ lower === "x-laptop-token"
1670
+ ) {
1671
+ continue;
1672
+ }
1673
+
1674
+ out[name] = valueRaw;
1675
+ }
1676
+
1677
+ return out;
1678
+ }
1679
+
1680
+ function normalizePreviewForwardBody(req) {
1681
+ const method = String(req.method || "GET").toUpperCase();
1682
+ if (method === "GET" || method === "HEAD") return undefined;
1683
+
1684
+ if (req.body === undefined || req.body === null) return undefined;
1685
+ if (typeof req.body === "string") return req.body;
1686
+ if (Buffer.isBuffer(req.body)) return req.body.toString("utf8");
1687
+ if (typeof req.body === "object") return req.body;
1688
+ return String(req.body);
1689
+ }
1690
+
1691
+ function withQuery(pathname, queryInput) {
1692
+ const params = new URLSearchParams();
1693
+
1694
+ for (const [key, value] of Object.entries(queryInput || {})) {
1695
+ if (Array.isArray(value)) {
1696
+ for (const item of value) {
1697
+ if (item === undefined || item === null) continue;
1698
+ params.append(key, String(item));
1699
+ }
1700
+ continue;
1701
+ }
1702
+
1703
+ if (value === undefined || value === null) continue;
1704
+ params.set(key, String(value));
1705
+ }
1706
+
1707
+ const query = params.toString();
1708
+ if (!query) return pathname;
1709
+ return `${pathname}?${query}`;
1710
+ }
1711
+
1712
+ function createId(prefix) {
1713
+ return `${prefix}_${randomBytes(8).toString("hex")}`;
1714
+ }
1715
+
1716
+ function createToken(prefix) {
1717
+ return `${prefix}_${randomBytes(20).toString("hex")}`;
1718
+ }
1719
+
1720
+ function issuePhoneToken(deviceId) {
1721
+ const normalizedDeviceId = safeText(deviceId, 200);
1722
+ if (!normalizedDeviceId || !RELAY_TOKEN_SECRET) {
1723
+ return createToken("ptkn");
1724
+ }
1725
+
1726
+ return createSignedToken("ptkn1", {
1727
+ kind: "phone",
1728
+ deviceId: normalizedDeviceId
1729
+ });
1730
+ }
1731
+
1732
+ function issueLaptopToken(laptopId, deviceId) {
1733
+ const normalizedLaptopId = safeText(laptopId, 200);
1734
+ const normalizedDeviceId = safeText(deviceId, 200);
1735
+ if (!normalizedLaptopId || !normalizedDeviceId || !RELAY_TOKEN_SECRET) {
1736
+ return createToken("ltkn");
1737
+ }
1738
+
1739
+ return createSignedToken("ltkn1", {
1740
+ kind: "laptop",
1741
+ laptopId: normalizedLaptopId,
1742
+ deviceId: normalizedDeviceId
1743
+ });
1744
+ }
1745
+
1746
+ function preferredLaptopToken(laptop) {
1747
+ const upgraded = issueLaptopToken(laptop?.laptopId, laptop?.deviceId);
1748
+ return upgraded.startsWith("ltkn1_") ? upgraded : safeText(laptop?.laptopToken, 1000);
1749
+ }
1750
+
1751
+ function createSignedToken(prefix, payload) {
1752
+ const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
1753
+ const signature = createHmac("sha256", RELAY_TOKEN_SECRET).update(`${prefix}.${body}`).digest("base64url");
1754
+ return `${prefix}_${body}.${signature}`;
1755
+ }
1756
+
1757
+ function parseSignedToken(token, expectedPrefix) {
1758
+ if (!RELAY_TOKEN_SECRET) return null;
1759
+
1760
+ const raw = safeText(token, 4000);
1761
+ if (!raw.startsWith(`${expectedPrefix}_`)) return null;
1762
+
1763
+ const composite = raw.slice(expectedPrefix.length + 1);
1764
+ const separatorIndex = composite.lastIndexOf(".");
1765
+ if (separatorIndex <= 0 || separatorIndex >= composite.length - 1) return null;
1766
+
1767
+ const body = composite.slice(0, separatorIndex);
1768
+ const signature = composite.slice(separatorIndex + 1);
1769
+
1770
+ let actual;
1771
+ try {
1772
+ actual = Buffer.from(signature, "base64url");
1773
+ } catch {
1774
+ return null;
1775
+ }
1776
+
1777
+ const expected = createHmac("sha256", RELAY_TOKEN_SECRET).update(`${expectedPrefix}.${body}`).digest();
1778
+ if (actual.length !== expected.length) return null;
1779
+ if (!timingSafeEqual(actual, expected)) return null;
1780
+
1781
+ try {
1782
+ const parsed = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
1783
+ return isObject(parsed) ? parsed : null;
1784
+ } catch {
1785
+ return null;
1786
+ }
1787
+ }
1788
+
1789
+ function generatePairCode() {
1790
+ const alphabet = "23456789ABCDEFGHJKMNPQRSTVWXYZ";
1791
+ let out = "";
1792
+ for (let i = 0; i < 6; i += 1) {
1793
+ out += alphabet[Math.floor(Math.random() * alphabet.length)];
1794
+ }
1795
+ return out;
1796
+ }
1797
+
1798
+ function normalizePairCode(value) {
1799
+ const normalized = String(value || "")
1800
+ .trim()
1801
+ .toUpperCase()
1802
+ .replace(/[^A-Z0-9]/g, "");
1803
+ return normalized || "";
1804
+ }
1805
+
1806
+ function resolveRelayTokenSecret() {
1807
+ const explicit = safeText(process.env.RELAY_TOKEN_SECRET || process.env.AGENT_RELAY_TOKEN_SECRET || "", 1000);
1808
+ if (explicit) return explicit;
1809
+
1810
+ const renderServiceId = safeText(process.env.RENDER_SERVICE_ID || "", 500);
1811
+ if (renderServiceId) return `render:${renderServiceId}`;
1812
+
1813
+ if (/^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?::\d+)?$/i.test(RELAY_PUBLIC_URL)) {
1814
+ return "agent-companion-local-dev-secret";
1815
+ }
1816
+
1817
+ return "";
1818
+ }
1819
+
1820
+ function mutateState(mutator) {
1821
+ mutator();
1822
+ state.updatedAt = Date.now();
1823
+ schedulePersist();
1824
+ }
1825
+
1826
+ function schedulePersist() {
1827
+ if (persistTimer) return;
1828
+ persistTimer = setTimeout(() => {
1829
+ persistTimer = null;
1830
+ persistStateNow();
1831
+ }, 120);
1832
+ }
1833
+
1834
+ function persistStateNow() {
1835
+ try {
1836
+ fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
1837
+ fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
1838
+ } catch (error) {
1839
+ console.error("[relay] failed to persist state:", error);
1840
+ }
1841
+ }
1842
+
1843
+ function loadState() {
1844
+ const fallback = {
1845
+ laptops: [],
1846
+ pairings: [],
1847
+ phones: [],
1848
+ previews: [],
1849
+ updatedAt: Date.now()
1850
+ };
1851
+
1852
+ try {
1853
+ if (!fs.existsSync(STATE_FILE)) {
1854
+ return fallback;
1855
+ }
1856
+
1857
+ const parsed = JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
1858
+ return sanitizeState(parsed);
1859
+ } catch {
1860
+ return fallback;
1861
+ }
1862
+ }
1863
+
1864
+ function sanitizeState(raw) {
1865
+ const fallback = {
1866
+ laptops: [],
1867
+ pairings: [],
1868
+ phones: [],
1869
+ previews: [],
1870
+ updatedAt: Date.now()
1871
+ };
1872
+
1873
+ if (!isObject(raw)) return fallback;
1874
+
1875
+ const laptops = Array.isArray(raw.laptops)
1876
+ ? raw.laptops
1877
+ .filter((item) => isObject(item))
1878
+ .map((item) => ({
1879
+ laptopId: safeText(item.laptopId, 200),
1880
+ deviceId: safeText(item.deviceId, 200),
1881
+ laptopToken: safeText(item.laptopToken, 500),
1882
+ name: safeText(item.name, 120) || null,
1883
+ createdAt: toInt(item.createdAt, Date.now()),
1884
+ updatedAt: toInt(item.updatedAt, Date.now()),
1885
+ pairedAt: item.pairedAt ? toInt(item.pairedAt, null) : null,
1886
+ pairCode: safeText(item.pairCode, 40) || null,
1887
+ pairingExpiresAt: item.pairingExpiresAt ? toInt(item.pairingExpiresAt, null) : null,
1888
+ pairingUrl: safeText(item.pairingUrl, 2000) || null,
1889
+ pairingPayload: isObject(item.pairingPayload) ? item.pairingPayload : null,
1890
+ lastConnectedAt: item.lastConnectedAt ? toInt(item.lastConnectedAt, null) : null,
1891
+ lastDisconnectedAt: item.lastDisconnectedAt ? toInt(item.lastDisconnectedAt, null) : null,
1892
+ lastSnapshotAt: item.lastSnapshotAt ? toInt(item.lastSnapshotAt, null) : null,
1893
+ latestSnapshot: isObject(item.latestSnapshot) ? item.latestSnapshot : null,
1894
+ wake: {
1895
+ macAddress: normalizeMacAddress(item?.wake?.macAddress || item?.wakeMac || item?.macAddress) || null
1896
+ },
1897
+ lastWakeRequestedAt: item.lastWakeRequestedAt ? toInt(item.lastWakeRequestedAt, null) : null,
1898
+ lastWakeResult: safeText(item.lastWakeResult, 120) || null
1899
+ }))
1900
+ .filter((item) => item.laptopId && item.deviceId && item.laptopToken)
1901
+ : [];
1902
+
1903
+ const pairings = Array.isArray(raw.pairings)
1904
+ ? raw.pairings
1905
+ .filter((item) => isObject(item))
1906
+ .map((item) => ({
1907
+ code: normalizePairCode(item.code),
1908
+ laptopId: safeText(item.laptopId, 200),
1909
+ deviceId: safeText(item.deviceId, 200),
1910
+ createdAt: toInt(item.createdAt, Date.now()),
1911
+ expiresAt: toInt(item.expiresAt, Date.now()),
1912
+ claimedAt: item.claimedAt ? toInt(item.claimedAt, null) : null,
1913
+ phoneToken: safeText(item.phoneToken, 500) || null,
1914
+ pairingUrl: safeText(item.pairingUrl, 2000) || null,
1915
+ pairingPayload: isObject(item.pairingPayload) ? item.pairingPayload : null
1916
+ }))
1917
+ .filter((item) => item.code && item.laptopId && item.deviceId)
1918
+ : [];
1919
+
1920
+ const phones = Array.isArray(raw.phones)
1921
+ ? raw.phones
1922
+ .filter((item) => isObject(item))
1923
+ .map((item) => ({
1924
+ phoneToken: safeText(item.phoneToken, 500),
1925
+ deviceId: safeText(item.deviceId, 200),
1926
+ createdAt: toInt(item.createdAt, Date.now()),
1927
+ lastUsedAt: toInt(item.lastUsedAt, Date.now())
1928
+ }))
1929
+ .filter((item) => item.phoneToken && item.deviceId)
1930
+ : [];
1931
+
1932
+ const previews = Array.isArray(raw.previews)
1933
+ ? raw.previews
1934
+ .filter((item) => isObject(item))
1935
+ .map((item) => ({
1936
+ previewId: safeText(item.previewId, 200),
1937
+ accessToken: safeText(item.accessToken, 500),
1938
+ laptopId: safeText(item.laptopId, 200),
1939
+ deviceId: safeText(item.deviceId, 200),
1940
+ label: safeText(item.label, 120) || null,
1941
+ target: normalizePreviewTarget(item.target, null),
1942
+ createdAt: toInt(item.createdAt, Date.now()),
1943
+ updatedAt: toInt(item.updatedAt, Date.now()),
1944
+ lastAccessedAt: item.lastAccessedAt ? toInt(item.lastAccessedAt, null) : null,
1945
+ expiresAt: toInt(item.expiresAt, Date.now()),
1946
+ createdByPhoneToken: safeText(item.createdByPhoneToken, 500) || null
1947
+ }))
1948
+ .filter((item) => item.previewId && item.accessToken && item.laptopId && item.deviceId && item.target)
1949
+ : [];
1950
+
1951
+ return {
1952
+ laptops,
1953
+ pairings,
1954
+ phones,
1955
+ previews,
1956
+ updatedAt: toInt(raw.updatedAt, Date.now())
1957
+ };
1958
+ }
1959
+
1960
+ function trimStateCollections() {
1961
+ if (state.laptops.length > MAX_LAPTOP_RECORDS) {
1962
+ state.laptops = state.laptops
1963
+ .slice()
1964
+ .sort((a, b) => b.createdAt - a.createdAt)
1965
+ .slice(0, MAX_LAPTOP_RECORDS);
1966
+ }
1967
+
1968
+ if (state.phones.length > MAX_PHONE_TOKENS) {
1969
+ state.phones = state.phones
1970
+ .slice()
1971
+ .sort((a, b) => b.createdAt - a.createdAt)
1972
+ .slice(0, MAX_PHONE_TOKENS);
1973
+ }
1974
+
1975
+ if (state.previews.length > MAX_PREVIEW_RECORDS) {
1976
+ state.previews = state.previews
1977
+ .slice()
1978
+ .sort((a, b) => b.updatedAt - a.updatedAt)
1979
+ .slice(0, MAX_PREVIEW_RECORDS);
1980
+ }
1981
+ }
1982
+
1983
+ function gracefulShutdown(signal) {
1984
+ if (shuttingDown) return;
1985
+ shuttingDown = true;
1986
+
1987
+ console.log(`[relay] received ${signal}, shutting down`);
1988
+
1989
+ try {
1990
+ persistStateNow();
1991
+ } catch {
1992
+ // ignore
1993
+ }
1994
+
1995
+ for (const ws of laptopSockets.values()) {
1996
+ try {
1997
+ ws.close(1001, "relay shutting down");
1998
+ } catch {
1999
+ // ignore
2000
+ }
2001
+ }
2002
+
2003
+ server.close(() => process.exit(0));
2004
+ setTimeout(() => process.exit(0), 800).unref();
2005
+ }
2006
+
2007
+ function isObject(value) {
2008
+ return value !== null && typeof value === "object" && !Array.isArray(value);
2009
+ }
2010
+
2011
+ function toInt(value, fallback = 0) {
2012
+ const parsed = Number.parseInt(String(value), 10);
2013
+ return Number.isFinite(parsed) ? parsed : fallback;
2014
+ }
2015
+
2016
+ function clamp(value, min, max) {
2017
+ return Math.max(min, Math.min(max, value));
2018
+ }
2019
+
2020
+ function safeText(value, maxLength) {
2021
+ if (typeof value !== "string") return "";
2022
+ const trimmed = value.trim();
2023
+ if (!trimmed) return "";
2024
+ if (trimmed.length <= maxLength) return trimmed;
2025
+ return trimmed.slice(0, maxLength);
2026
+ }
2027
+
2028
+ function trimTrailingSlash(value) {
2029
+ const trimmed = String(value || "").trim();
2030
+ if (!trimmed) return "";
2031
+ return trimmed.replace(/\/+$/, "");
2032
+ }
2033
+
2034
+ function normalizeMacAddress(value) {
2035
+ const raw = String(value || "")
2036
+ .trim()
2037
+ .toUpperCase()
2038
+ .replace(/[^0-9A-F]/g, "");
2039
+ if (raw.length !== 12) return "";
2040
+ const chunks = raw.match(/.{1,2}/g);
2041
+ return chunks ? chunks.join(":") : "";
2042
+ }
2043
+
2044
+ async function safeParseJsonResponse(response) {
2045
+ try {
2046
+ return await response.json();
2047
+ } catch {
2048
+ return null;
2049
+ }
2050
+ }
2051
+
2052
+ function sleep(ms) {
2053
+ return new Promise((resolve) => {
2054
+ setTimeout(resolve, ms);
2055
+ });
2056
+ }