@tapflowio/relay 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/RelayServer.d.ts +50 -0
- package/dist/RelayServer.d.ts.map +1 -1
- package/dist/RelayServer.js +359 -84
- package/dist/RelayServer.js.map +1 -1
- package/dist/SessionManager.d.ts +43 -6
- package/dist/SessionManager.d.ts.map +1 -1
- package/dist/SessionManager.js +94 -8
- package/dist/SessionManager.js.map +1 -1
- package/dist/types.d.ts +5 -10
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -3
- package/public/assets/{AppCenter-BRmG2FEm.js → AppCenter-B3FO67aw.js} +1 -1
- package/public/assets/AppCenter-B3FO67aw.js.br +0 -0
- package/public/assets/{Default-DTr0wqds.js → Default-BlNUv5dw.js} +1 -1
- package/public/assets/Default-BlNUv5dw.js.br +0 -0
- package/public/assets/{Invite-DKsNh2GB.js → Invite-CKYFmy7S.js} +1 -1
- package/public/assets/Invite-CKYFmy7S.js.br +0 -0
- package/public/assets/{MacResources-CLDdcibL.js → MacResources-BK4VQnFc.js} +1 -1
- package/public/assets/MacResources-BK4VQnFc.js.br +0 -0
- package/public/assets/{NotFound-BtKq2uv6.js → NotFound-BMmi1paB.js} +1 -1
- package/public/assets/NotFound-BMmi1paB.js.br +0 -0
- package/public/assets/QASession-CVTlqW3d.js +125 -0
- package/public/assets/QASession-CVTlqW3d.js.br +0 -0
- package/public/assets/{ResetPassword-gp6hVQrf.js → ResetPassword-VPPBWzSI.js} +1 -1
- package/public/assets/ResetPassword-VPPBWzSI.js.br +0 -0
- package/public/assets/{Setup-DiG4Nbs7.js → Setup-CRdnCl41.js} +1 -1
- package/public/assets/Setup-CRdnCl41.js.br +0 -0
- package/public/assets/{Team-D1iwg2vL.js → Team-CBsjuUwh.js} +1 -1
- package/public/assets/Team-CBsjuUwh.js.br +0 -0
- package/public/assets/{Tokens-DGtE4Xcy.js → Tokens-CU9HB9WD.js} +1 -1
- package/public/assets/Tokens-CU9HB9WD.js.br +0 -0
- package/public/assets/alert-dialog-DWqGDQTB.js +1 -0
- package/public/assets/alert-dialog-DWqGDQTB.js.br +0 -0
- package/public/assets/{badge-CavXTq2x.js → badge-OToVq1dK.js} +1 -1
- package/public/assets/badge-OToVq1dK.js.br +0 -0
- package/public/assets/{build-format-BZV6gt41.js → build-format-B78hbKY9.js} +1 -1
- package/public/assets/build-format-B78hbKY9.js.br +3 -0
- package/public/assets/dialog-BxC9v2BL.js +11 -0
- package/public/assets/dialog-BxC9v2BL.js.br +0 -0
- package/public/assets/index-DXjMx9hQ.js +274 -0
- package/public/assets/index-DXjMx9hQ.js.br +0 -0
- package/public/assets/index-uGJGo6ac.css +1 -0
- package/public/assets/index-uGJGo6ac.css.br +0 -0
- package/public/assets/{pencil-BGrOEXS6.js → pencil-Cq64I9LV.js} +1 -1
- package/public/assets/pencil-Cq64I9LV.js.br +0 -0
- package/public/assets/{table-CqOA0Bb1.js → table-CbATN5Pi.js} +1 -1
- package/public/assets/table-CbATN5Pi.js.br +0 -0
- package/public/assets/tabs-BVumgDA4.js +1 -0
- package/public/assets/tabs-BVumgDA4.js.br +0 -0
- package/public/assets/{trash-2-CwlFrbEF.js → trash-2-CszwJHEh.js} +1 -1
- package/public/assets/trash-2-CszwJHEh.js.br +0 -0
- package/public/index.html +2 -2
- package/public/index.html.br +0 -0
- package/public/assets/AppCenter-BRmG2FEm.js.br +0 -0
- package/public/assets/Default-DTr0wqds.js.br +0 -0
- package/public/assets/Invite-DKsNh2GB.js.br +0 -0
- package/public/assets/MacResources-CLDdcibL.js.br +0 -0
- package/public/assets/NotFound-BtKq2uv6.js.br +0 -0
- package/public/assets/QASession-B_5aJ67l.js +0 -130
- package/public/assets/QASession-B_5aJ67l.js.br +0 -0
- package/public/assets/ResetPassword-gp6hVQrf.js.br +0 -0
- package/public/assets/Setup-DiG4Nbs7.js.br +0 -0
- package/public/assets/Team-D1iwg2vL.js.br +0 -0
- package/public/assets/Tokens-DGtE4Xcy.js.br +0 -0
- package/public/assets/alert-dialog-CXwkFlF3.js +0 -1
- package/public/assets/alert-dialog-CXwkFlF3.js.br +0 -0
- package/public/assets/badge-CavXTq2x.js.br +0 -0
- package/public/assets/build-format-BZV6gt41.js.br +0 -0
- package/public/assets/dialog-JbKwqqS0.js +0 -11
- package/public/assets/dialog-JbKwqqS0.js.br +0 -0
- package/public/assets/index-CBnxiD7-.js +0 -274
- package/public/assets/index-CBnxiD7-.js.br +0 -0
- package/public/assets/index-Ctx3_cYz.css +0 -1
- package/public/assets/index-Ctx3_cYz.css.br +0 -0
- package/public/assets/pencil-BGrOEXS6.js.br +0 -0
- package/public/assets/table-CqOA0Bb1.js.br +0 -0
- package/public/assets/tabs-BVBbhCFG.js +0 -1
- package/public/assets/tabs-BVBbhCFG.js.br +0 -0
- package/public/assets/trash-2-CwlFrbEF.js.br +0 -0
package/dist/RelayServer.js
CHANGED
|
@@ -41,6 +41,11 @@ export function isExternalAddress(addr) {
|
|
|
41
41
|
const IDR_REQUEST_THROTTLE_MS = 500;
|
|
42
42
|
// Ping every socket each interval; a missed pong window (~2× this) terminates the dead socket.
|
|
43
43
|
const HEARTBEAT_MS = 30_000;
|
|
44
|
+
// How long a session outlives its agent's socket, waiting for that agent to come back (#426).
|
|
45
|
+
// An agent registers ~1s after its process starts, so this covers an automatic respawn several
|
|
46
|
+
// times over and about half of a hand-typed restart. What bounds it is the other direction: the
|
|
47
|
+
// device stays claimed by a session nobody can use until the window closes.
|
|
48
|
+
const DEFAULT_AGENT_GRACE_MS = 15_000;
|
|
44
49
|
import { handleVerifyReset, handleDoReset, handleSendMemberReset } from './api/passwordReset.js';
|
|
45
50
|
import { handleListBuilds, handleGetBuild, handleUpdateBuild, handleUploadBuild, handleScheduleBuildDeletion, handleCancelBuildDeletion, purgeExpiredBuilds } from './api/builds.js';
|
|
46
51
|
import { handleListApps, handleCreateApp, handleUpdateApp, handleDeleteApp } from './api/apps.js';
|
|
@@ -116,11 +121,22 @@ export class RelayServer {
|
|
|
116
121
|
// Per-session throttled "request an IDR from the agent" callbacks (drop recovery).
|
|
117
122
|
idrRequesters = new Map();
|
|
118
123
|
wsRoles = new Map();
|
|
124
|
+
// Agent sockets whose sessions are being held open, and the timer that gives up on each.
|
|
125
|
+
// Keyed by the dead socket, never by session id: a rebind moves sessions off that socket, so an
|
|
126
|
+
// expiry that fires late has nothing left to evict. That is the invariant — releasing the hold
|
|
127
|
+
// on the way back is hygiene on top of it, not a second thing holding the property up. The late
|
|
128
|
+
// expiry is not a complete no-op either: it still drops the socket's resource entry.
|
|
129
|
+
agentHolds = new Map();
|
|
130
|
+
// Set by `stop()`. Its own `terminate()` loop fires a close for every socket, and a hold armed
|
|
131
|
+
// from there would outlive the server it belongs to — the exact hazard the clearing above it is
|
|
132
|
+
// for, arriving a few lines later.
|
|
133
|
+
stopping = false;
|
|
119
134
|
// True when the connection's remote IP is public (not loopback / private LAN) — the agent uses
|
|
120
135
|
// this to downscale harder for bandwidth on external viewers.
|
|
121
136
|
wsExternal = new Map();
|
|
122
137
|
backpressureBytes;
|
|
123
138
|
screenshotTimeoutMs;
|
|
139
|
+
agentGraceMs;
|
|
124
140
|
corsAllowed;
|
|
125
141
|
// One-shot warning when XFF arrives on a loopback socket but TAPFLOW_TRUSTED_PROXIES is unset.
|
|
126
142
|
warnedProxyMisconfig = false;
|
|
@@ -135,6 +151,24 @@ export class RelayServer {
|
|
|
135
151
|
// itself may take up to 10s before it errors out.
|
|
136
152
|
this.uiTreeTimeoutMs = options.uiTreeTimeoutMs ?? 15_000;
|
|
137
153
|
this.corsAllowed = new Set(options.corsOrigins ?? []);
|
|
154
|
+
// Constructor option first, then env. It cannot be env-only: `IDLE_TIMEOUT_MS` is read at module
|
|
155
|
+
// load, which is why no test can set it, and the tests that need a different window here run in
|
|
156
|
+
// the same process as the default.
|
|
157
|
+
// Validated, not just parsed, and blank treated as unset rather than as a number. Three ways
|
|
158
|
+
// this silently switches the feature off if read naively: `parseInt('15s')` is 15 — and the
|
|
159
|
+
// documented default reads "15000 (15 s)", so that is the typo the docs invite; a non-numeric
|
|
160
|
+
// value is NaN, which `setTimeout` rounds to ~1ms; and `Number('')` is 0, not NaN, so an empty
|
|
161
|
+
// `.env` line would pass a `>= 0` check and give a zero-length window.
|
|
162
|
+
const graceRaw = process.env['TAPFLOW_AGENT_GRACE_MS']?.trim();
|
|
163
|
+
const graceEnv = graceRaw ? Number(graceRaw) : NaN;
|
|
164
|
+
const graceUsable = Number.isFinite(graceEnv) && graceEnv >= 0;
|
|
165
|
+
this.agentGraceMs = options.agentGraceMs ?? (graceUsable ? graceEnv : DEFAULT_AGENT_GRACE_MS);
|
|
166
|
+
// Say so rather than only documenting it. Both times this parsing was wrong the symptom was
|
|
167
|
+
// the same — the hold switched off and nothing mentioned it — and somebody who types `15s` is
|
|
168
|
+
// reading their terminal, not the configuration table.
|
|
169
|
+
if (options.agentGraceMs === undefined && graceRaw && !graceUsable) {
|
|
170
|
+
logger.warn(`TAPFLOW_AGENT_GRACE_MS="${graceRaw}" is not a usable number of milliseconds — using ${DEFAULT_AGENT_GRACE_MS}`);
|
|
171
|
+
}
|
|
138
172
|
this.sessions = new SessionManager({ idleTimeoutMs: options.idleTimeoutMs });
|
|
139
173
|
this.publicDir = options.publicDir ?? path.join(import.meta.dirname, '../public');
|
|
140
174
|
this.uploadsDir = options.uploadsDir ?? path.join(import.meta.dirname, '../uploads');
|
|
@@ -288,6 +322,12 @@ export class RelayServer {
|
|
|
288
322
|
clearInterval(this.heartbeatTimer);
|
|
289
323
|
this.heartbeatTimer = null;
|
|
290
324
|
}
|
|
325
|
+
// `unref()` would not do: the test runner's process outlives the server, so an un-cleared hold
|
|
326
|
+
// still fires — against a server that has already stopped.
|
|
327
|
+
this.stopping = true;
|
|
328
|
+
for (const timer of this.agentHolds.values())
|
|
329
|
+
clearTimeout(timer);
|
|
330
|
+
this.agentHolds.clear();
|
|
291
331
|
return new Promise((resolve, reject) => {
|
|
292
332
|
this.wss.clients.forEach((ws) => ws.terminate());
|
|
293
333
|
this.wss.close(() => {
|
|
@@ -326,7 +366,7 @@ export class RelayServer {
|
|
|
326
366
|
lastAt = now;
|
|
327
367
|
const session = this.sessions.get(sessionId);
|
|
328
368
|
if (session?.agentSocket.readyState === WebSocket.OPEN) {
|
|
329
|
-
session.agentSocket
|
|
369
|
+
this.sendTo(session.agentSocket, { type: 'stream:request-idr', sessionId });
|
|
330
370
|
}
|
|
331
371
|
};
|
|
332
372
|
}
|
|
@@ -423,19 +463,35 @@ export class RelayServer {
|
|
|
423
463
|
}
|
|
424
464
|
return;
|
|
425
465
|
}
|
|
466
|
+
let parsed;
|
|
426
467
|
try {
|
|
427
|
-
|
|
428
|
-
this.route(ws, msg);
|
|
468
|
+
parsed = JSON.parse(data.toString());
|
|
429
469
|
}
|
|
430
470
|
catch {
|
|
431
|
-
//
|
|
471
|
+
return; // genuinely malformed — there is no type to answer on
|
|
472
|
+
}
|
|
473
|
+
// `JSON.parse` returns bare `null`, numbers and strings without throwing, and `route` reads
|
|
474
|
+
// `.type` off whatever it is handed. Rejecting non-objects here is what lets the catch below
|
|
475
|
+
// name the message safely.
|
|
476
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
477
|
+
return;
|
|
478
|
+
const msg = parsed;
|
|
479
|
+
try {
|
|
480
|
+
this.route(ws, msg);
|
|
481
|
+
}
|
|
482
|
+
catch (e) {
|
|
483
|
+
// A throw inside a handler used to land in the same catch as a parse failure and vanish.
|
|
484
|
+
// Anything reaching here is a bug in routing, not a bad message, and the caller is left
|
|
485
|
+
// waiting either way — so at least say so once instead of dropping it silently.
|
|
486
|
+
logger.error(`route failed for ${String(msg.type)}:`, e);
|
|
432
487
|
}
|
|
433
488
|
});
|
|
434
489
|
ws.on('close', () => {
|
|
435
490
|
this.wsRoles.delete(ws);
|
|
436
491
|
this.wsExternal.delete(ws);
|
|
437
|
-
// Agent main socket disconnected →
|
|
438
|
-
|
|
492
|
+
// Agent main socket disconnected → hold its sessions open for a moment in case the agent is
|
|
493
|
+
// coming back (#426), rather than ending them where they stand.
|
|
494
|
+
if (this.holdAgentSocket(ws))
|
|
439
495
|
return;
|
|
440
496
|
// Stream socket disconnected → clear the streamSocket reference
|
|
441
497
|
const streamSession = this.sessions.getByStreamSocket(ws);
|
|
@@ -449,11 +505,11 @@ export class RelayServer {
|
|
|
449
505
|
this.sessions.clearBrowser(browserSession.id, () => {
|
|
450
506
|
const session = this.sessions.get(browserSession.id);
|
|
451
507
|
if (session?.agentSocket.readyState === WebSocket.OPEN) {
|
|
452
|
-
session.agentSocket
|
|
508
|
+
this.sendTo(session.agentSocket, {
|
|
453
509
|
type: 'device:shutdown',
|
|
454
510
|
sessionId: session.id,
|
|
455
511
|
payload: { deviceId: session.deviceId },
|
|
456
|
-
})
|
|
512
|
+
});
|
|
457
513
|
}
|
|
458
514
|
});
|
|
459
515
|
}
|
|
@@ -500,7 +556,7 @@ export class RelayServer {
|
|
|
500
556
|
this.handleUITreeError(msg);
|
|
501
557
|
break;
|
|
502
558
|
case 'agents:list': {
|
|
503
|
-
|
|
559
|
+
this.sendTo(ws, { type: 'agents:listed', sessions: this.sessions.list() });
|
|
504
560
|
break;
|
|
505
561
|
}
|
|
506
562
|
// ── Session / Stream lifecycle ─────────────────────────────────────────
|
|
@@ -531,7 +587,7 @@ export class RelayServer {
|
|
|
531
587
|
const session = this.sessions.get(msg.sessionId);
|
|
532
588
|
if (session) {
|
|
533
589
|
this.sessions.setStreamSocket(session.id, ws);
|
|
534
|
-
|
|
590
|
+
this.sendTo(ws, { type: 'stream:registered' });
|
|
535
591
|
}
|
|
536
592
|
break;
|
|
537
593
|
}
|
|
@@ -540,6 +596,10 @@ export class RelayServer {
|
|
|
540
596
|
const session = this.sessions.get(msg.sessionId);
|
|
541
597
|
if (!session)
|
|
542
598
|
break;
|
|
599
|
+
// Cast, not a check: nothing validates inbound messages, so the relay takes the agent's
|
|
600
|
+
// word for the shape. It only stores and forwards this, so a wrong shape surfaces in the
|
|
601
|
+
// viewer rather than here — but this is where a runtime validator belongs when one exists
|
|
602
|
+
// (see packages/protocol/AGENTS.md).
|
|
543
603
|
this.sessions.setChromeData(session.id, msg.payload);
|
|
544
604
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
545
605
|
session.browserSocket.send(JSON.stringify(msg));
|
|
@@ -579,6 +639,7 @@ export class RelayServer {
|
|
|
579
639
|
if (!session)
|
|
580
640
|
break;
|
|
581
641
|
this.sessions.updateDeviceStatus(session.id, 'shutdown');
|
|
642
|
+
this.sessions.setReadySent(session.id, false);
|
|
582
643
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
583
644
|
session.browserSocket.send(JSON.stringify(msg));
|
|
584
645
|
}
|
|
@@ -589,6 +650,7 @@ export class RelayServer {
|
|
|
589
650
|
if (!session)
|
|
590
651
|
break;
|
|
591
652
|
this.sessions.updateDeviceStatus(session.id, 'booted');
|
|
653
|
+
this.sessions.setReadySent(session.id, true);
|
|
592
654
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
593
655
|
session.browserSocket.send(JSON.stringify(msg));
|
|
594
656
|
}
|
|
@@ -631,6 +693,20 @@ export class RelayServer {
|
|
|
631
693
|
case 'device:boot':
|
|
632
694
|
case 'device:shutdown': {
|
|
633
695
|
const session = this.sessions.get(msg.sessionId);
|
|
696
|
+
if (session?.agentSocket.readyState !== WebSocket.OPEN && msg.type === 'device:boot') {
|
|
697
|
+
// A boot the agent never receives leaves the viewer on "Waiting for first frame…" with
|
|
698
|
+
// nothing said. `device:shutdown` gets no such reply: nothing waits on it, and inventing
|
|
699
|
+
// a `device:shutdown-error` would grow the contract for a message no one reads.
|
|
700
|
+
//
|
|
701
|
+
// The two are worth telling apart: `bootDevice` is the first call an MCP caller makes, so
|
|
702
|
+
// reporting a stale session id as a dead Mac sends the reader after the wrong problem.
|
|
703
|
+
this.sendTo(ws, {
|
|
704
|
+
type: 'device:boot-error',
|
|
705
|
+
sessionId: msg.sessionId,
|
|
706
|
+
message: session ? 'agent offline' : 'Session not found',
|
|
707
|
+
});
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
634
710
|
if (session?.agentSocket.readyState === WebSocket.OPEN) {
|
|
635
711
|
// Tag the boot with whether the viewer is external (public IP) so the agent can pick the
|
|
636
712
|
// downscale tier. The browser already reports secureContext in the payload.
|
|
@@ -653,7 +729,7 @@ export class RelayServer {
|
|
|
653
729
|
session.agentSocket.send(JSON.stringify(msg));
|
|
654
730
|
}
|
|
655
731
|
else {
|
|
656
|
-
|
|
732
|
+
this.sendTo(ws, { type: 'open-url:error', sessionId: msg.sessionId, message: 'agent offline' });
|
|
657
733
|
}
|
|
658
734
|
break;
|
|
659
735
|
}
|
|
@@ -663,7 +739,7 @@ export class RelayServer {
|
|
|
663
739
|
session.agentSocket.send(JSON.stringify(msg));
|
|
664
740
|
}
|
|
665
741
|
else {
|
|
666
|
-
|
|
742
|
+
this.sendTo(ws, { type: 'app:clear-state-error', sessionId: msg.sessionId, message: 'agent offline' });
|
|
667
743
|
}
|
|
668
744
|
break;
|
|
669
745
|
}
|
|
@@ -686,7 +762,7 @@ export class RelayServer {
|
|
|
686
762
|
// Agent offline or session evicted: a terminal input can't be dispatched.
|
|
687
763
|
// Reply to the sender (the MCP/browser socket) so it fails truthfully
|
|
688
764
|
// instead of falling through to its optimistic 2s timeout.
|
|
689
|
-
|
|
765
|
+
this.sendTo(ws, { type: 'input:error', sessionId: msg.sessionId, message: 'agent offline' });
|
|
690
766
|
}
|
|
691
767
|
break;
|
|
692
768
|
}
|
|
@@ -700,9 +776,9 @@ export class RelayServer {
|
|
|
700
776
|
session.agentSocket.send(JSON.stringify(msg));
|
|
701
777
|
}
|
|
702
778
|
else if (ws.readyState === WebSocket.OPEN) {
|
|
703
|
-
|
|
779
|
+
this.sendTo(ws, {
|
|
704
780
|
type: 'clipboard:error', sessionId: msg.sessionId, requestId: msg.requestId, message: 'agent offline',
|
|
705
|
-
})
|
|
781
|
+
});
|
|
706
782
|
}
|
|
707
783
|
break;
|
|
708
784
|
}
|
|
@@ -723,28 +799,124 @@ export class RelayServer {
|
|
|
723
799
|
// Removes an agent socket's sessions + resources and rejects its in-flight
|
|
724
800
|
// screenshot / ui-tree requests. Shared by socket close and re-register
|
|
725
801
|
// eviction. Returns true if `ws` had agent sessions.
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
802
|
+
/**
|
|
803
|
+
* Every message the relay *originates* goes through here. The union checks the shape where the
|
|
804
|
+
* literal is written — a mistyped `type`, a missing field or one that does not belong are all
|
|
805
|
+
* compile errors — and the readyState guard replaces the check that was repeated (and sometimes
|
|
806
|
+
* omitted) at each call site.
|
|
807
|
+
*
|
|
808
|
+
* Messages the relay merely *forwards* do not use this: they are re-serialised unchanged, so
|
|
809
|
+
* they keep their inbound type and there is nothing new to check.
|
|
810
|
+
*/
|
|
811
|
+
sendTo(socket, msg) {
|
|
812
|
+
if (socket.readyState !== WebSocket.OPEN)
|
|
813
|
+
return;
|
|
814
|
+
socket.send(JSON.stringify(msg));
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Settle every in-flight screenshot / UI-tree request belonging to these sessions. Both callers
|
|
818
|
+
* are moments where the agent that was going to answer them no longer exists — it disconnected,
|
|
819
|
+
* or it restarted and its session moved to the new process. Left alone these reject on their own
|
|
820
|
+
* timeout, minutes later, having told the caller nothing in between.
|
|
821
|
+
*/
|
|
822
|
+
rejectPending(sessionIds, reason) {
|
|
823
|
+
for (const pendings of [this.pendingScreenshots, this.pendingUITrees]) {
|
|
824
|
+
for (const [reqId, pending] of pendings.entries()) {
|
|
825
|
+
if (!sessionIds.has(pending.sessionId))
|
|
826
|
+
continue;
|
|
733
827
|
clearTimeout(pending.timer);
|
|
734
|
-
|
|
735
|
-
pending.reject(new Error(
|
|
828
|
+
pendings.delete(reqId);
|
|
829
|
+
pending.reject(new Error(reason));
|
|
736
830
|
}
|
|
737
831
|
}
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* The agent's socket went away. Keep its sessions and wait for that agent to come back, instead
|
|
835
|
+
* of ending them here — a restarting agent registers about a second later, and until #426 stage 3
|
|
836
|
+
* the close always won that race, so a restart cost the tester their place.
|
|
837
|
+
*
|
|
838
|
+
* The sessions stay exactly where they are. They are NOT moved to a holding structure of their
|
|
839
|
+
* own: `getAgentSocketsByIdentity` finds the returning agent's previous socket by walking
|
|
840
|
+
* `sessions` and reading `agentSocket`, so a session parked anywhere else is a session that can
|
|
841
|
+
* never be reclaimed.
|
|
842
|
+
*
|
|
843
|
+
* @returns whether this was an agent socket, matching `evictAgentSocket`'s contract — the close
|
|
844
|
+
* handler uses it to decide whether to keep looking.
|
|
845
|
+
*/
|
|
846
|
+
holdAgentSocket(ws) {
|
|
847
|
+
// Shutting down: nothing is coming back, and arming a timer here would leave it running after
|
|
848
|
+
// `stop()` resolved.
|
|
849
|
+
if (this.stopping)
|
|
850
|
+
return this.evictAgentSocket(ws);
|
|
851
|
+
const sessions = this.sessions.getAllByAgentSocket(ws);
|
|
852
|
+
if (sessions.length === 0) {
|
|
853
|
+
// No sessions to hold, but the socket may still own a resource entry — `evictAgentSocket`
|
|
854
|
+
// returns before dropping it in exactly this case.
|
|
855
|
+
this.sessions.removeResources(ws);
|
|
856
|
+
return false;
|
|
857
|
+
}
|
|
858
|
+
// Not held for the window: these were addressed to a process that is gone, and a returning
|
|
859
|
+
// agent is a new one that never saw them. Waiting would only delay the same failure.
|
|
860
|
+
this.rejectPending(new Set(sessions.map((s) => s.id)), 'Agent disconnected');
|
|
861
|
+
for (const s of sessions) {
|
|
862
|
+
if (s.browserSocket)
|
|
863
|
+
this.sendTo(s.browserSocket, { type: 'session:agent-away', sessionId: s.id });
|
|
864
|
+
}
|
|
865
|
+
const timer = setTimeout(() => {
|
|
866
|
+
this.agentHolds.delete(ws);
|
|
867
|
+
// Whatever is still on this socket never got reclaimed. A rebind moves sessions off it, so
|
|
868
|
+
// after a successful one this evicts nothing.
|
|
869
|
+
if (this.evictAgentSocket(ws))
|
|
870
|
+
logger.info(`agent did not come back within ${this.agentGraceMs}ms — session(s) ended`);
|
|
871
|
+
// Not inside that branch: `evictAgentSocket` returns before dropping resources when the
|
|
872
|
+
// socket has no sessions left, and the sessions can leave by routes other than a rebind —
|
|
873
|
+
// `session:end` among them. This is the last moment anything holds a reference to the dead
|
|
874
|
+
// socket, so it is the last chance to drop its entry.
|
|
875
|
+
this.sessions.removeResources(ws);
|
|
876
|
+
}, this.agentGraceMs);
|
|
877
|
+
this.agentHolds.set(ws, timer);
|
|
878
|
+
logger.info(`agent socket lost — holding ${sessions.length} session(s) for ${this.agentGraceMs}ms`);
|
|
879
|
+
return true;
|
|
880
|
+
}
|
|
881
|
+
/** Stop waiting on a socket, whether or not anything was reclaimed from it. */
|
|
882
|
+
releaseHold(ws) {
|
|
883
|
+
const timer = this.agentHolds.get(ws);
|
|
884
|
+
if (!timer)
|
|
885
|
+
return;
|
|
886
|
+
clearTimeout(timer);
|
|
887
|
+
this.agentHolds.delete(ws);
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* @param cause `'disconnect'` — the socket closed. `'replaced'` — the same agent re-registered
|
|
891
|
+
* and this is its previous socket. Only affects the log line: a restart otherwise reads as a
|
|
892
|
+
* crash followed by a recovery, which is not what happened.
|
|
893
|
+
*/
|
|
894
|
+
evictAgentSocket(ws, cause = 'disconnect') {
|
|
895
|
+
const agentSessions = this.sessions.getAllByAgentSocket(ws);
|
|
896
|
+
if (agentSessions.length === 0)
|
|
897
|
+
return false;
|
|
898
|
+
this.rejectPending(new Set(agentSessions.map((s) => s.id)), 'Agent disconnected');
|
|
899
|
+
// Tell whoever is attached before the session stops existing — after `remove()` the socket
|
|
900
|
+
// reference is gone. Without this the browser keeps a live socket addressed to a sessionId the
|
|
901
|
+
// relay no longer knows, so everything it sends is dropped as unknown and nothing streams back:
|
|
902
|
+
// the tab sits on "Waiting for first frame..." with no explanation (#426).
|
|
903
|
+
//
|
|
904
|
+
// `sendTo` skips a socket that is not OPEN, and a session nobody has joined has no
|
|
905
|
+
// `browserSocket` at all. An attached MCP client does have one and will receive this; its
|
|
906
|
+
// dispatcher drops messages no waiter matches, so that is harmless.
|
|
907
|
+
for (const s of agentSessions) {
|
|
908
|
+
if (s.browserSocket) {
|
|
909
|
+
this.sendTo(s.browserSocket, {
|
|
910
|
+
type: 'session:terminated', sessionId: s.id, reason: 'agent-disconnected',
|
|
911
|
+
});
|
|
743
912
|
}
|
|
744
913
|
}
|
|
745
914
|
for (const s of agentSessions)
|
|
746
915
|
this.sessions.remove(s.id);
|
|
747
916
|
this.sessions.removeResources(ws);
|
|
917
|
+
logger.info(cause === 'replaced'
|
|
918
|
+
? `agent re-registered — ${agentSessions.length} previous session(s) replaced`
|
|
919
|
+
: `agent disconnected — ${agentSessions.length} session(s) ended`);
|
|
748
920
|
return true;
|
|
749
921
|
}
|
|
750
922
|
handleAgentRegister(ws, msg) {
|
|
@@ -754,34 +926,109 @@ export class RelayServer {
|
|
|
754
926
|
// its socket before creating the new ones. Identity is agentId (unique per Mac) when present,
|
|
755
927
|
// else agentName. (Heartbeat backstop for never-reconnecting agents: #313.)
|
|
756
928
|
const identity = msg.agentId ?? msg.agentName;
|
|
929
|
+
// Deduplicate first. Everything below is keyed by device id, so a payload naming one device
|
|
930
|
+
// twice would collapse to a single entry in `registeredSessions` while `create()` had already
|
|
931
|
+
// made two sessions — leaving one the agent is never told about. That is the same orphan the
|
|
932
|
+
// rebind exists to prevent, arriving by a different door.
|
|
933
|
+
const devices = [...new Map((msg.devices ?? []).map((d) => [d.id, d])).values()];
|
|
934
|
+
const agent = {
|
|
935
|
+
agentId: msg.agentId, agentName: msg.agentName,
|
|
936
|
+
agentPlatform: msg.platform, agentCapabilities: msg.capabilities,
|
|
937
|
+
};
|
|
938
|
+
// deviceId → the session id kept across the restart. Also the guard that a device is rebound at
|
|
939
|
+
// most once (#426): before rebinding existed, `create()` ran after every old session had been
|
|
940
|
+
// evicted, so one device could not be behind two sessions. It can now, and `list()` does not
|
|
941
|
+
// deduplicate — the second card would name a session the agent has never heard of, which is the
|
|
942
|
+
// symptom this whole change is fixing.
|
|
943
|
+
const rebound = new Map();
|
|
757
944
|
if (identity) {
|
|
758
945
|
for (const old of this.sessions.getAgentSocketsByIdentity(identity, msg.platform)) {
|
|
759
946
|
if (old === ws)
|
|
760
947
|
continue;
|
|
948
|
+
// Before any decision about what to rebind: the eviction below settles this socket either
|
|
949
|
+
// way, so the timer has nothing left to do. Releasing here rather than after a successful
|
|
950
|
+
// rebind also covers the case where nothing is rebound at all — a restart that reports a
|
|
951
|
+
// completely different device list still leaves a timer keyed to a dead socket.
|
|
952
|
+
this.releaseHold(old);
|
|
953
|
+
for (const s of this.sessions.getAllByAgentSocket(old)) {
|
|
954
|
+
if (rebound.has(s.deviceId))
|
|
955
|
+
continue;
|
|
956
|
+
const device = devices.find((d) => d.id === s.deviceId);
|
|
957
|
+
// The device is gone from this agent's list — unplugged, deleted, renamed away. Leave it
|
|
958
|
+
// for the eviction below, which tells the browser the session ended.
|
|
959
|
+
if (!device)
|
|
960
|
+
continue;
|
|
961
|
+
this.sessions.rebind(s.id, ws, device, agent);
|
|
962
|
+
rebound.set(s.deviceId, s.id);
|
|
963
|
+
}
|
|
761
964
|
// Evict before terminate: the old socket's close fires async, by which point its sessions are
|
|
762
965
|
// gone and its in-flight screenshots would be undiscoverable — reject them here instead.
|
|
763
|
-
this.
|
|
966
|
+
// The rebound sessions have already moved off `old`, so this no longer covers them.
|
|
967
|
+
this.evictAgentSocket(old, 'replaced');
|
|
764
968
|
old.terminate();
|
|
765
969
|
}
|
|
766
970
|
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
971
|
+
// Their in-flight requests are addressed to a process that is gone, and the eviction above can
|
|
972
|
+
// no longer see them. Nothing else would ever settle these.
|
|
973
|
+
if (rebound.size > 0)
|
|
974
|
+
this.rejectPending(new Set(rebound.values()), 'Agent restarted');
|
|
975
|
+
// A device can be back under an agent this session cannot be rebound to — identity is
|
|
976
|
+
// `agentId ?? agentName`, and the upgrade that prompted the restart is often the one that
|
|
977
|
+
// starts sending an agentId. The device is demonstrably present, so holding its old session
|
|
978
|
+
// any longer strands that viewer while someone else picks the very same simulator. End it now
|
|
979
|
+
// and say so, which is what would have happened before the hold existed.
|
|
980
|
+
for (const d of devices) {
|
|
981
|
+
if (rebound.has(d.id))
|
|
982
|
+
continue;
|
|
983
|
+
for (const s of this.sessions.getAllByDeviceId(d.id)) {
|
|
984
|
+
if (s.agentSocket.readyState === WebSocket.OPEN)
|
|
985
|
+
continue;
|
|
986
|
+
if (s.browserSocket) {
|
|
987
|
+
this.sendTo(s.browserSocket, { type: 'session:terminated', sessionId: s.id, reason: 'agent-disconnected' });
|
|
988
|
+
}
|
|
989
|
+
this.sessions.remove(s.id);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
// Only devices without a surviving session get a new one. Passing all of them here is what
|
|
993
|
+
// would produce the duplicate card described above.
|
|
994
|
+
const fresh = devices.filter((d) => !rebound.has(d.id));
|
|
995
|
+
const freshIds = this.sessions.create(ws, fresh, msg.agentName, msg.platform, msg.agentId, msg.capabilities);
|
|
996
|
+
const byDeviceId = new Map(rebound);
|
|
997
|
+
fresh.forEach((d, i) => byDeviceId.set(d.id, freshIds[i]));
|
|
998
|
+
// Keyed by deviceId, not by position. The old form paired `msg.devices[i]` with `sessionIds[i]`,
|
|
999
|
+
// which only held while every device got a session — now that some are rebound, the arrays have
|
|
1000
|
+
// different lengths and index alignment would hand the agent someone else's session id.
|
|
1001
|
+
const registeredSessions = devices.map((d) => ({ deviceId: d.id, sessionId: byDeviceId.get(d.id) }));
|
|
1002
|
+
this.sendTo(ws, { type: 'agent:registered', registeredSessions });
|
|
1003
|
+
// After the state is final: the browser answers this with `device:boot` on the same session id.
|
|
1004
|
+
for (const sessionId of rebound.values()) {
|
|
1005
|
+
const s = this.sessions.get(sessionId);
|
|
1006
|
+
if (s?.browserSocket) {
|
|
1007
|
+
this.sendTo(s.browserSocket, { type: 'session:rebound', sessionId, capabilities: msg.capabilities ?? [] });
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
if (rebound.size > 0)
|
|
1011
|
+
logger.info(`agent restarted — ${rebound.size} session(s) kept across the restart`);
|
|
1012
|
+
// The startup banner prints "Waiting for agents..." once and then the relay says nothing either
|
|
1013
|
+
// way, so a terminal gives no signal about whether an agent is attached. One line per
|
|
1014
|
+
// transition, matching the disconnect line in evictAgentSocket.
|
|
1015
|
+
logger.info(`agent connected: ${msg.agentName ?? msg.agentId ?? 'unknown'} (${msg.platform ?? 'unknown'}) — ${registeredSessions.length} device(s)`);
|
|
773
1016
|
}
|
|
774
1017
|
handleSessionStart(ws, msg) {
|
|
775
1018
|
const session = this.sessions.get(msg.sessionId);
|
|
776
1019
|
if (!session) {
|
|
777
|
-
|
|
1020
|
+
this.sendTo(ws, { type: 'error', message: 'Session not found' });
|
|
778
1021
|
return;
|
|
779
1022
|
}
|
|
780
|
-
|
|
1023
|
+
// Read before the resource gate, and answered before it too. The gate would otherwise read the
|
|
1024
|
+
// dead socket's last sample — and an overloaded Mac is a common reason to restart an agent, so
|
|
1025
|
+
// the tester would be told the Mac is exhausted at the exact moment it is recovering.
|
|
1026
|
+
const agentAway = session.agentSocket.readyState !== WebSocket.OPEN;
|
|
1027
|
+
const resources = agentAway ? undefined : this.sessions.getResources(session.agentSocket);
|
|
781
1028
|
if (resources) {
|
|
782
1029
|
const memPercent = (resources.memUsedMB / resources.memTotalMB) * 100;
|
|
783
1030
|
if (resources.cpuPercent > RESOURCE_THRESHOLD || memPercent > RESOURCE_THRESHOLD) {
|
|
784
|
-
|
|
1031
|
+
this.sendTo(ws, { type: 'error', message: 'Agent resources exhausted' });
|
|
785
1032
|
return;
|
|
786
1033
|
}
|
|
787
1034
|
}
|
|
@@ -789,76 +1036,104 @@ export class RelayServer {
|
|
|
789
1036
|
this.sessions.join(msg.sessionId, ws);
|
|
790
1037
|
}
|
|
791
1038
|
catch {
|
|
792
|
-
|
|
1039
|
+
this.sendTo(ws, { type: 'error', message: 'Session busy' });
|
|
793
1040
|
return;
|
|
794
1041
|
}
|
|
795
1042
|
// Include the agent's capabilities so the viewer knows up front what is implemented on
|
|
796
1043
|
// the other end — an agent that predates a feature omits it, and the dashboard degrades
|
|
797
1044
|
// deliberately instead of inferring anything from a timeout.
|
|
798
|
-
|
|
1045
|
+
this.sendTo(ws, {
|
|
799
1046
|
type: 'session:joined', sessionId: msg.sessionId, capabilities: session.agentCapabilities ?? [],
|
|
800
|
-
})
|
|
1047
|
+
});
|
|
1048
|
+
if (agentAway) {
|
|
1049
|
+
// Joining into a held session. Refusing instead would be worse than it sounds: the viewer
|
|
1050
|
+
// sends `session:start` exactly once per reconnect and ignores a plain `error`, so a browser
|
|
1051
|
+
// blip inside the window would leave a tab that no later `session:rebound` can reach — it is
|
|
1052
|
+
// addressed to `browserSocket`, which the refusal never set. Saying what is happening keeps
|
|
1053
|
+
// the tab inside the contract instead.
|
|
1054
|
+
//
|
|
1055
|
+
// Everything below describes the agent that went away, so it stops here. The viewer will get
|
|
1056
|
+
// it all again from the boot it sends on `session:rebound`.
|
|
1057
|
+
this.sendTo(ws, { type: 'session:agent-away', sessionId: msg.sessionId });
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
801
1060
|
if (session.chromeData) {
|
|
802
|
-
|
|
1061
|
+
this.sendTo(ws, { type: 'session:chrome', payload: session.chromeData });
|
|
803
1062
|
}
|
|
804
1063
|
if (session.deviceInfo) {
|
|
805
|
-
|
|
1064
|
+
this.sendTo(ws, { type: 'session:deviceInfo', payload: session.deviceInfo });
|
|
806
1065
|
}
|
|
807
|
-
// Replay device:ready if
|
|
808
|
-
|
|
809
|
-
|
|
1066
|
+
// Replay device:ready only if this session actually announced one (browser WS blip reconnect).
|
|
1067
|
+
// Not `deviceStatus`: that starts from the agent's `simctl list` snapshot, so a session for a
|
|
1068
|
+
// simulator that was already running would fire this before the agent had done anything —
|
|
1069
|
+
// telling the viewer a stream exists when none does (#440).
|
|
1070
|
+
if (session.readySent) {
|
|
1071
|
+
this.sendTo(ws, { type: 'device:ready', payload: { deviceId: session.deviceId } });
|
|
810
1072
|
// (Re)joining a live stream: ask the agent for an IDR so this viewer gets a decodable
|
|
811
1073
|
// keyframe immediately, instead of waiting for the next periodic one — and so it isn't
|
|
812
1074
|
// left blank when the encoder is static-skipping an unchanged screen. Agents that don't
|
|
813
1075
|
// support on-demand IDR ignore the message.
|
|
814
1076
|
if (session.agentSocket.readyState === WebSocket.OPEN) {
|
|
815
|
-
session.agentSocket
|
|
1077
|
+
this.sendTo(session.agentSocket, { type: 'stream:request-idr', sessionId: session.id });
|
|
816
1078
|
}
|
|
817
1079
|
}
|
|
818
1080
|
}
|
|
1081
|
+
/** Relay looks up file_path from DB and enriches it for the agent.
|
|
1082
|
+
*
|
|
1083
|
+
* Every exit carries the request's `sessionId`, including the failures. A dashboard viewer holds
|
|
1084
|
+
* one session per socket, so an uncorrelated error still lands somewhere sensible — but an MCP
|
|
1085
|
+
* caller waits for the reply matching its own sessionId, so anything else is indistinguishable
|
|
1086
|
+
* from silence and it waits out the deadline (#445). `Session not found` is app-specific for the
|
|
1087
|
+
* same reason: a generic `error` cannot be correlated by construction. */
|
|
819
1088
|
handleBrowserAppInstall(ws, msg) {
|
|
820
|
-
|
|
821
|
-
const
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
return;
|
|
825
|
-
|
|
1089
|
+
const sessionId = msg.sessionId;
|
|
1090
|
+
const fail = (message) => this.sendTo(ws, { type: 'app:install-error', sessionId, message });
|
|
1091
|
+
const session = this.sessions.get(sessionId);
|
|
1092
|
+
if (!session)
|
|
1093
|
+
return fail('Session not found');
|
|
1094
|
+
// `JSON.parse` does not honour `RelayMessage`, so buildId is whatever the sender put there.
|
|
1095
|
+
// better-sqlite3 binds a missing value as NULL but *throws* on an object or array — and that
|
|
1096
|
+
// exception is swallowed by the message-loop catch, which is the silence this PR exists to
|
|
1097
|
+
// remove. Checked here rather than trusted; general inbound validation is #444.
|
|
1098
|
+
if (!Number.isInteger(msg.buildId))
|
|
1099
|
+
return fail('Build not found');
|
|
826
1100
|
const build = getDb()
|
|
827
1101
|
.prepare('SELECT file_path, bundle_id FROM builds WHERE id = ?')
|
|
828
1102
|
.get(msg.buildId);
|
|
829
|
-
if (!build)
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
}
|
|
1103
|
+
if (!build)
|
|
1104
|
+
return fail('Build not found');
|
|
1105
|
+
// Answer now rather than letting the caller time out — the same shape as `open-url` above.
|
|
1106
|
+
if (session.agentSocket.readyState !== WebSocket.OPEN)
|
|
1107
|
+
return fail('agent offline');
|
|
1108
|
+
this.sendTo(session.agentSocket, {
|
|
1109
|
+
type: 'app:install',
|
|
1110
|
+
sessionId,
|
|
1111
|
+
payload: { filePath: build.file_path, bundleId: build.bundle_id },
|
|
1112
|
+
});
|
|
840
1113
|
}
|
|
1114
|
+
/** Relay looks up bundle_id from DB. Same correlation rules as `handleBrowserAppInstall`. */
|
|
841
1115
|
handleBrowserAppLaunch(ws, msg) {
|
|
842
|
-
|
|
843
|
-
const
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
return;
|
|
847
|
-
|
|
1116
|
+
const sessionId = msg.sessionId;
|
|
1117
|
+
const fail = (message) => this.sendTo(ws, { type: 'app:launch-error', sessionId, message });
|
|
1118
|
+
const session = this.sessions.get(sessionId);
|
|
1119
|
+
if (!session)
|
|
1120
|
+
return fail('Session not found');
|
|
1121
|
+
// See handleBrowserAppInstall — an object here throws inside the driver and the exception is
|
|
1122
|
+
// swallowed upstream.
|
|
1123
|
+
if (!Number.isInteger(msg.buildId))
|
|
1124
|
+
return fail('Bundle ID not available for this build');
|
|
848
1125
|
const build = getDb()
|
|
849
1126
|
.prepare('SELECT bundle_id FROM builds WHERE id = ?')
|
|
850
1127
|
.get(msg.buildId);
|
|
851
|
-
if (!build?.bundle_id)
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
}));
|
|
861
|
-
}
|
|
1128
|
+
if (!build?.bundle_id)
|
|
1129
|
+
return fail('Bundle ID not available for this build');
|
|
1130
|
+
if (session.agentSocket.readyState !== WebSocket.OPEN)
|
|
1131
|
+
return fail('agent offline');
|
|
1132
|
+
this.sendTo(session.agentSocket, {
|
|
1133
|
+
type: 'app:launch',
|
|
1134
|
+
sessionId,
|
|
1135
|
+
payload: { bundleId: build.bundle_id },
|
|
1136
|
+
});
|
|
862
1137
|
}
|
|
863
1138
|
async handleGetScreenshot(req, res, params) {
|
|
864
1139
|
if (!requireViewAuth(req, res))
|
|
@@ -904,7 +1179,7 @@ export class RelayServer {
|
|
|
904
1179
|
},
|
|
905
1180
|
timer,
|
|
906
1181
|
});
|
|
907
|
-
session.agentSocket
|
|
1182
|
+
this.sendTo(session.agentSocket, { type: 'screenshot:request', sessionId, requestId, format });
|
|
908
1183
|
});
|
|
909
1184
|
}
|
|
910
1185
|
async handleGetUITree(req, res, params) {
|
|
@@ -947,7 +1222,7 @@ export class RelayServer {
|
|
|
947
1222
|
},
|
|
948
1223
|
timer,
|
|
949
1224
|
});
|
|
950
|
-
session.agentSocket
|
|
1225
|
+
this.sendTo(session.agentSocket, { type: 'ui:tree:request', sessionId, requestId });
|
|
951
1226
|
});
|
|
952
1227
|
}
|
|
953
1228
|
handleUITreeResponse(msg) {
|