@tapflowio/relay 0.27.1 → 0.28.1
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 +54 -0
- package/dist/RelayServer.d.ts.map +1 -1
- package/dist/RelayServer.js +384 -37
- package/dist/RelayServer.js.map +1 -1
- package/dist/SessionManager.d.ts +63 -2
- package/dist/SessionManager.d.ts.map +1 -1
- package/dist/SessionManager.js +123 -1
- package/dist/SessionManager.js.map +1 -1
- package/dist/api/apps.d.ts +2 -1
- package/dist/api/apps.d.ts.map +1 -1
- package/dist/api/apps.js +20 -7
- package/dist/api/apps.js.map +1 -1
- package/dist/api/builds.d.ts +2 -1
- package/dist/api/builds.d.ts.map +1 -1
- package/dist/api/builds.js +13 -39
- package/dist/api/builds.js.map +1 -1
- package/dist/api/profile.d.ts.map +1 -1
- package/dist/api/profile.js +3 -1
- package/dist/api/profile.js.map +1 -1
- package/dist/api/runVideos.d.ts +85 -0
- package/dist/api/runVideos.d.ts.map +1 -0
- package/dist/api/runVideos.js +494 -0
- package/dist/api/runVideos.js.map +1 -0
- package/dist/api/runs.d.ts +77 -0
- package/dist/api/runs.d.ts.map +1 -0
- package/dist/api/runs.js +579 -0
- package/dist/api/runs.js.map +1 -0
- package/dist/api/settings.d.ts +1 -1
- package/dist/api/settings.d.ts.map +1 -1
- package/dist/api/settings.js +43 -3
- package/dist/api/settings.js.map +1 -1
- package/dist/db.d.ts +2 -0
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +4 -0
- package/dist/db.js.map +1 -1
- package/dist/lib/buildDeletion.d.ts +21 -0
- package/dist/lib/buildDeletion.d.ts.map +1 -0
- package/dist/lib/buildDeletion.js +58 -0
- package/dist/lib/buildDeletion.js.map +1 -0
- package/dist/lib/watchActions.d.ts +10 -0
- package/dist/lib/watchActions.d.ts.map +1 -0
- package/dist/lib/watchActions.js +27 -0
- package/dist/lib/watchActions.js.map +1 -0
- package/dist/middleware/auth.d.ts +1 -0
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +1 -1
- package/dist/middleware/auth.js.map +1 -1
- package/dist/migrations/015_flow_runs.sql +59 -0
- package/dist/migrations/016_flow_run_videos.sql +51 -0
- package/dist/router.d.ts +5 -0
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +24 -0
- package/dist/router.js.map +1 -1
- package/package.json +9 -9
- package/public/assets/AISessionWatch-C2h4NZ_I.js +31 -0
- package/public/assets/AISessionWatch-C2h4NZ_I.js.br +0 -0
- package/public/assets/AISessionWatch-C2h4NZ_I.js.gz +0 -0
- package/public/assets/AndroidDeviceScreen-C55msKfh.js +35 -0
- package/public/assets/AndroidDeviceScreen-C55msKfh.js.br +0 -0
- package/public/assets/AndroidDeviceScreen-C55msKfh.js.gz +0 -0
- package/public/assets/AppCenter-S1y-8_7l.js +26 -0
- package/public/assets/AppCenter-S1y-8_7l.js.br +0 -0
- package/public/assets/AppCenter-S1y-8_7l.js.gz +0 -0
- package/public/assets/Default-C-l1O-g9.js +6 -0
- package/public/assets/Default-C-l1O-g9.js.br +0 -0
- package/public/assets/Default-C-l1O-g9.js.gz +0 -0
- package/public/assets/Invite-BCndVxaO.js +1 -0
- package/public/assets/Invite-BCndVxaO.js.br +0 -0
- package/public/assets/Invite-BCndVxaO.js.gz +0 -0
- package/public/assets/MacResources-41cFDgE1.js +1 -0
- package/public/assets/MacResources-41cFDgE1.js.br +0 -0
- package/public/assets/MacResources-41cFDgE1.js.gz +0 -0
- package/public/assets/{NotFound-wHOnT2Fl.js → NotFound-DVq_rCKc.js} +1 -1
- package/public/assets/NotFound-DVq_rCKc.js.br +0 -0
- package/public/assets/NotFound-DVq_rCKc.js.gz +0 -0
- package/public/assets/QASession-B67RsIPm.js +81 -0
- package/public/assets/QASession-B67RsIPm.js.br +0 -0
- package/public/assets/QASession-B67RsIPm.js.gz +0 -0
- package/public/assets/ResetPassword-8rj7ASt1.js +1 -0
- package/public/assets/ResetPassword-8rj7ASt1.js.br +0 -0
- package/public/assets/ResetPassword-8rj7ASt1.js.gz +0 -0
- package/public/assets/RunDetail-BIgq9gJ0.js +16 -0
- package/public/assets/RunDetail-BIgq9gJ0.js.br +0 -0
- package/public/assets/RunDetail-BIgq9gJ0.js.gz +0 -0
- package/public/assets/Runs-DpK5aoBr.js +6 -0
- package/public/assets/Runs-DpK5aoBr.js.br +0 -0
- package/public/assets/Runs-DpK5aoBr.js.gz +0 -0
- package/public/assets/Setup-DZrhA_7b.js +1 -0
- package/public/assets/Setup-DZrhA_7b.js.br +0 -0
- package/public/assets/Setup-DZrhA_7b.js.gz +0 -0
- package/public/assets/Team-CJThCjd3.js +6 -0
- package/public/assets/Team-CJThCjd3.js.br +0 -0
- package/public/assets/Team-CJThCjd3.js.gz +0 -0
- package/public/assets/Tokens-DGURtHWi.js +1 -0
- package/public/assets/Tokens-DGURtHWi.js.br +0 -0
- package/public/assets/Tokens-DGURtHWi.js.gz +0 -0
- package/public/assets/WatchedDevice-CpaKHa1x.js +1 -0
- package/public/assets/WatchedDevice-CpaKHa1x.js.br +0 -0
- package/public/assets/WatchedDevice-CpaKHa1x.js.gz +0 -0
- package/public/assets/aiSession-BW09Jhqz.js +26 -0
- package/public/assets/aiSession-BW09Jhqz.js.br +0 -0
- package/public/assets/aiSession-BW09Jhqz.js.gz +0 -0
- package/public/assets/alert-dialog-t5vlOabq.js +1 -0
- package/public/assets/alert-dialog-t5vlOabq.js.br +0 -0
- package/public/assets/alert-dialog-t5vlOabq.js.gz +0 -0
- package/public/assets/{badge-DNaS0Mu3.js → badge-DrDCTAQQ.js} +1 -1
- package/public/assets/badge-DrDCTAQQ.js.br +0 -0
- package/public/assets/badge-DrDCTAQQ.js.gz +0 -0
- package/public/assets/build-format-ofd2iIzf.js +1 -0
- package/public/assets/build-format-ofd2iIzf.js.br +0 -0
- package/public/assets/build-format-ofd2iIzf.js.gz +0 -0
- package/public/assets/dialog-BOFZkCES.js +1 -0
- package/public/assets/dialog-BOFZkCES.js.br +0 -0
- package/public/assets/dialog-BOFZkCES.js.gz +0 -0
- package/public/assets/field-hint-B9fPUgi7.js +1 -0
- package/public/assets/field-hint-B9fPUgi7.js.br +0 -0
- package/public/assets/field-hint-B9fPUgi7.js.gz +0 -0
- package/public/assets/index-Bixhy5MB.js +248 -0
- package/public/assets/index-Bixhy5MB.js.br +0 -0
- package/public/assets/index-Bixhy5MB.js.gz +0 -0
- package/public/assets/index-D17hSi50.js +1 -0
- package/public/assets/index-D17hSi50.js.br +0 -0
- package/public/assets/index-D17hSi50.js.gz +0 -0
- package/public/assets/index-h2hiZ2OM.css +1 -0
- package/public/assets/index-h2hiZ2OM.css.br +0 -0
- package/public/assets/index-h2hiZ2OM.css.gz +0 -0
- package/public/assets/list-view-BpOY1e-d.js +1 -0
- package/public/assets/list-view-BpOY1e-d.js.br +0 -0
- package/public/assets/list-view-BpOY1e-d.js.gz +0 -0
- package/public/assets/{pencil-BN18R4oa.js → pencil-ZlrAsWuU.js} +2 -2
- package/public/assets/pencil-ZlrAsWuU.js.br +0 -0
- package/public/assets/pencil-ZlrAsWuU.js.gz +0 -0
- package/public/assets/play-4CsGZdQU.js +6 -0
- package/public/assets/play-4CsGZdQU.js.br +0 -0
- package/public/assets/play-4CsGZdQU.js.gz +0 -0
- package/public/assets/resource-health-oWJ8hy9X.js +1 -0
- package/public/assets/resource-health-oWJ8hy9X.js.br +0 -0
- package/public/assets/resource-health-oWJ8hy9X.js.gz +0 -0
- package/public/assets/runs-CInQb6v1.js +1 -0
- package/public/assets/runs-CInQb6v1.js.br +0 -0
- package/public/assets/runs-CInQb6v1.js.gz +0 -0
- package/public/assets/search-input-G_QpBvPM.js +6 -0
- package/public/assets/search-input-G_QpBvPM.js.br +0 -0
- package/public/assets/search-input-G_QpBvPM.js.gz +0 -0
- package/public/assets/select-DDzv2YfY.js +11 -0
- package/public/assets/select-DDzv2YfY.js.br +0 -0
- package/public/assets/select-DDzv2YfY.js.gz +0 -0
- package/public/assets/slider-DE7lQdK4.js +1 -0
- package/public/assets/slider-DE7lQdK4.js.br +0 -0
- package/public/assets/slider-DE7lQdK4.js.gz +0 -0
- package/public/assets/tabs-Co1N3x_X.js +1 -0
- package/public/assets/tabs-Co1N3x_X.js.br +0 -0
- package/public/assets/tabs-Co1N3x_X.js.gz +0 -0
- package/public/assets/tech-label-BMvuJmUf.js +1 -0
- package/public/assets/tech-label-BMvuJmUf.js.br +0 -0
- package/public/assets/tech-label-BMvuJmUf.js.gz +0 -0
- package/public/assets/{trash-D1l1Fm-Z.js → trash-Cwj2dbgB.js} +3 -3
- package/public/assets/trash-Cwj2dbgB.js.br +0 -0
- package/public/assets/trash-Cwj2dbgB.js.gz +0 -0
- package/public/assets/{useFocusAfterSwap-BY3N9f9v.js → useFocusAfterSwap-BdItkQCu.js} +1 -1
- package/public/assets/useFocusAfterSwap-BdItkQCu.js.br +0 -0
- package/public/assets/useFocusAfterSwap-BdItkQCu.js.gz +0 -0
- package/public/assets/useRelay-CES3I1Mt.js +1 -0
- package/public/assets/useRelay-CES3I1Mt.js.br +0 -0
- package/public/assets/useRelay-CES3I1Mt.js.gz +0 -0
- package/public/assets/useShownAfter-By1Yxysz.js +1 -0
- package/public/assets/useShownAfter-By1Yxysz.js.br +0 -0
- package/public/assets/useShownAfter-By1Yxysz.js.gz +0 -0
- package/public/assets/{vendor-charts-BykzUV2D.js → vendor-charts-BE7R_8yA.js} +2 -2
- package/public/assets/vendor-charts-BE7R_8yA.js.br +0 -0
- package/public/assets/vendor-charts-BE7R_8yA.js.gz +0 -0
- package/public/assets/{vendor-react-BlPSzrEe.js → vendor-react-DTSdA8m-.js} +4 -4
- package/public/assets/vendor-react-DTSdA8m-.js.br +0 -0
- package/public/assets/{vendor-react-BlPSzrEe.js.gz → vendor-react-DTSdA8m-.js.gz} +0 -0
- package/public/assets/{vendor-router-B_lVHrKl.js → vendor-router-B1pQim9Z.js} +3 -3
- package/public/assets/vendor-router-B1pQim9Z.js.br +0 -0
- package/public/assets/vendor-router-B1pQim9Z.js.gz +0 -0
- package/public/assets/video-swsaHrfi.js +6 -0
- package/public/assets/video-swsaHrfi.js.br +0 -0
- package/public/assets/video-swsaHrfi.js.gz +0 -0
- package/public/index.html +30 -4
- package/public/index.html.br +0 -0
- package/public/index.html.gz +0 -0
- package/public/og-image.jpg +0 -0
- package/public/assets/AppCenter-CeLjUbnR.js +0 -26
- package/public/assets/AppCenter-CeLjUbnR.js.br +0 -0
- package/public/assets/AppCenter-CeLjUbnR.js.gz +0 -0
- package/public/assets/Default-UB0Pa3lN.js +0 -1
- package/public/assets/Default-UB0Pa3lN.js.br +0 -0
- package/public/assets/Default-UB0Pa3lN.js.gz +0 -0
- package/public/assets/Invite-Dv3omu3x.js +0 -1
- package/public/assets/Invite-Dv3omu3x.js.br +0 -0
- package/public/assets/Invite-Dv3omu3x.js.gz +0 -0
- package/public/assets/MacResources-CY4-OdGt.js +0 -1
- package/public/assets/MacResources-CY4-OdGt.js.br +0 -0
- package/public/assets/MacResources-CY4-OdGt.js.gz +0 -0
- package/public/assets/NotFound-wHOnT2Fl.js.br +0 -0
- package/public/assets/NotFound-wHOnT2Fl.js.gz +0 -0
- package/public/assets/QASession-H5eRSwZv.js +0 -150
- package/public/assets/QASession-H5eRSwZv.js.br +0 -0
- package/public/assets/QASession-H5eRSwZv.js.gz +0 -0
- package/public/assets/ResetPassword-nFTigYt2.js +0 -1
- package/public/assets/ResetPassword-nFTigYt2.js.br +0 -0
- package/public/assets/ResetPassword-nFTigYt2.js.gz +0 -0
- package/public/assets/Setup-UmLtF2PF.js +0 -1
- package/public/assets/Setup-UmLtF2PF.js.br +0 -0
- package/public/assets/Setup-UmLtF2PF.js.gz +0 -0
- package/public/assets/Team-DW40kt3H.js +0 -6
- package/public/assets/Team-DW40kt3H.js.br +0 -0
- package/public/assets/Team-DW40kt3H.js.gz +0 -0
- package/public/assets/Tokens-B1a4ChJK.js +0 -1
- package/public/assets/Tokens-B1a4ChJK.js.br +0 -0
- package/public/assets/Tokens-B1a4ChJK.js.gz +0 -0
- package/public/assets/alert-dialog-aoqYdAwx.js +0 -1
- package/public/assets/alert-dialog-aoqYdAwx.js.br +0 -0
- package/public/assets/alert-dialog-aoqYdAwx.js.gz +0 -0
- package/public/assets/badge-DNaS0Mu3.js.br +0 -0
- package/public/assets/badge-DNaS0Mu3.js.gz +0 -0
- package/public/assets/build-format-DF5aFcag.js +0 -11
- package/public/assets/build-format-DF5aFcag.js.br +0 -0
- package/public/assets/build-format-DF5aFcag.js.gz +0 -0
- package/public/assets/dialog-DE2WUMuX.js +0 -11
- package/public/assets/dialog-DE2WUMuX.js.br +0 -0
- package/public/assets/dialog-DE2WUMuX.js.gz +0 -0
- package/public/assets/field-hint-Csb40Gzp.js +0 -1
- package/public/assets/field-hint-Csb40Gzp.js.br +0 -2
- package/public/assets/field-hint-Csb40Gzp.js.gz +0 -0
- package/public/assets/index-BbCkTtjO.css +0 -1
- package/public/assets/index-BbCkTtjO.css.br +0 -0
- package/public/assets/index-BbCkTtjO.css.gz +0 -0
- package/public/assets/index-DSW9h8XX.js +0 -218
- package/public/assets/index-DSW9h8XX.js.br +0 -0
- package/public/assets/index-DSW9h8XX.js.gz +0 -0
- package/public/assets/list-view-C0w97ltJ.js +0 -1
- package/public/assets/list-view-C0w97ltJ.js.br +0 -0
- package/public/assets/list-view-C0w97ltJ.js.gz +0 -0
- package/public/assets/pencil-BN18R4oa.js.br +0 -0
- package/public/assets/pencil-BN18R4oa.js.gz +0 -0
- package/public/assets/resource-health-Bi20EUDs.js +0 -1
- package/public/assets/resource-health-Bi20EUDs.js.br +0 -0
- package/public/assets/resource-health-Bi20EUDs.js.gz +0 -0
- package/public/assets/trash-D1l1Fm-Z.js.br +0 -0
- package/public/assets/trash-D1l1Fm-Z.js.gz +0 -0
- package/public/assets/useFocusAfterSwap-BY3N9f9v.js.br +0 -0
- package/public/assets/useFocusAfterSwap-BY3N9f9v.js.gz +0 -0
- package/public/assets/vendor-charts-BykzUV2D.js.br +0 -0
- package/public/assets/vendor-charts-BykzUV2D.js.gz +0 -0
- package/public/assets/vendor-react-BlPSzrEe.js.br +0 -0
- package/public/assets/vendor-router-B_lVHrKl.js.br +0 -0
- package/public/assets/vendor-router-B_lVHrKl.js.gz +0 -0
package/dist/RelayServer.js
CHANGED
|
@@ -5,11 +5,12 @@ import os from 'os';
|
|
|
5
5
|
import path from 'path';
|
|
6
6
|
import { randomUUID } from 'crypto';
|
|
7
7
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
8
|
-
import { SessionManager } from './SessionManager.js';
|
|
8
|
+
import { SessionManager, isAiClientKind } from './SessionManager.js';
|
|
9
|
+
import { gestureAction, keyForWatchers } from './lib/watchActions.js';
|
|
9
10
|
import { directionOf, parseInbound } from '@tapflowio/protocol/validate';
|
|
10
11
|
import { Router, json } from './router.js';
|
|
11
12
|
import { requireViewAuth, requireAuth, getAuth, verifyPat, touchPat, findPat, findUser } from './middleware/auth.js';
|
|
12
|
-
import { AGENT_SCOPE, WS_ACCESS_CHANGED_REASON, WS_AGENT_OWNER_REASON, WS_SCOPE_REASON, classifyConnection, isAffectedBy, revalidatePrincipal } from './lib/connectionAuth.js';
|
|
13
|
+
import { AGENT_SCOPE, VIEW_SCOPE, WS_ACCESS_CHANGED_REASON, WS_AGENT_OWNER_REASON, WS_SCOPE_REASON, classifyConnection, isAffectedBy, revalidatePrincipal } from './lib/connectionAuth.js';
|
|
13
14
|
import { isTunnelIngress, markTunnelIngress, proxiedWithoutForwardedFor, resolveClientAddress, resolveRequestClient } from './lib/clientAddress.js';
|
|
14
15
|
import { BuildTicketStore } from './lib/buildTickets.js';
|
|
15
16
|
import { resolveBuildFile } from './lib/buildFiles.js';
|
|
@@ -89,6 +90,8 @@ import { handleListTokens, handleCreateToken, handleRevokeToken } from './api/to
|
|
|
89
90
|
import { handleGetSettings, handleUpdateSettings } from './api/settings.js';
|
|
90
91
|
import { handleUpdateProfile } from './api/profile.js';
|
|
91
92
|
import { handleUploadRecording, handleListRecordings, handleDownloadRecording, purgeExpiredRecordings } from './api/recordings.js';
|
|
93
|
+
import { handleEndVideo, handleOpenVideo, handleVideoChunk, handleVideoData, handleVideoIndex, purgeRunVideos, releaseStaleVideoReservations, runVideosDirFor, videoSummaries } from './api/runVideos.js';
|
|
94
|
+
import { abandonRunsOf, handleCreateRun, handleFinishRun, handleGetFlowScreenshot, handleGetRun, handleListRuns, handleReportFlow, handleUploadFlowScreenshot, purgeExpiredRuns, runScreenshotsDirFor } from './api/runs.js';
|
|
92
95
|
import { handleListAgents, handleGetAgentResources } from './api/agents.js';
|
|
93
96
|
const MIME_TYPES = {
|
|
94
97
|
'.html': 'text/html',
|
|
@@ -210,6 +213,8 @@ export class RelayServer {
|
|
|
210
213
|
resourceBuffers = new Map();
|
|
211
214
|
logBuffer = [];
|
|
212
215
|
recordingsDir = '';
|
|
216
|
+
runScreenshotsDir = '';
|
|
217
|
+
runVideosDir = '';
|
|
213
218
|
purgeRecordingsTimer = null;
|
|
214
219
|
purgeOldResourcesTimer = null;
|
|
215
220
|
purgeBuildsTimer = null;
|
|
@@ -261,6 +266,19 @@ export class RelayServer {
|
|
|
261
266
|
droppers = new Map();
|
|
262
267
|
// Per-session throttled "request an IDR from the agent" callbacks (drop recovery).
|
|
263
268
|
idrRequesters = new Map();
|
|
269
|
+
/**
|
|
270
|
+
* **One keyframe-aware sender per watcher, never the session's.** The sender's `dropping` state lives in
|
|
271
|
+
* its closure, so sharing the holder's would let a slow watcher put the holder into drop-to-keyframe
|
|
272
|
+
* too — every P-frame to the holder discarded until the next IDR because someone else's link is slow.
|
|
273
|
+
* Keyed by socket, then session, because one socket can watch several sessions.
|
|
274
|
+
*/
|
|
275
|
+
watcherDroppers = new Map();
|
|
276
|
+
/**
|
|
277
|
+
* Whether this connection may watch: a cookie, or a remote PAT with `view` — decided once at the
|
|
278
|
+
* handshake, from the credential. **Not** an unauthenticated loopback socket: a page in any browser on
|
|
279
|
+
* the relay's Mac reaches loopback without a credential, and watching shows a device's screen.
|
|
280
|
+
*/
|
|
281
|
+
mayWatch = new WeakMap();
|
|
264
282
|
/** Unlike `idrRequesters` these hold a timer, which is why the value is not a bare closure — see
|
|
265
283
|
* `forgetSessionState`. */
|
|
266
284
|
networkStateRequesters = new Map();
|
|
@@ -378,7 +396,16 @@ export class RelayServer {
|
|
|
378
396
|
if (options.agentGraceMs === undefined && graceRaw && !graceUsable) {
|
|
379
397
|
logger.warn(`TAPFLOW_AGENT_GRACE_MS="${graceRaw}" is not a usable number of milliseconds — using ${DEFAULT_AGENT_GRACE_MS}`);
|
|
380
398
|
}
|
|
381
|
-
this.sessions = new SessionManager({
|
|
399
|
+
this.sessions = new SessionManager({
|
|
400
|
+
idleTimeoutMs: options.idleTimeoutMs,
|
|
401
|
+
// Before the sockets are forgotten, so each one is told why its picture stopped.
|
|
402
|
+
onWatchersEvicted: (sessionId, sockets, reason) => {
|
|
403
|
+
for (const w of sockets) {
|
|
404
|
+
this.forgetWatcherDropper(w, sessionId);
|
|
405
|
+
this.sendTo(w, { type: 'watch:ended', sessionId, reason });
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
});
|
|
382
409
|
this.publicDir = options.publicDir ?? path.join(import.meta.dirname, '../public');
|
|
383
410
|
this.uploadsDir = options.uploadsDir ?? path.join(import.meta.dirname, '../uploads');
|
|
384
411
|
this.router = new Router();
|
|
@@ -431,7 +458,7 @@ export class RelayServer {
|
|
|
431
458
|
this.router.get('/api/v1/apps', handleListApps);
|
|
432
459
|
this.router.post('/api/v1/apps', handleCreateApp);
|
|
433
460
|
this.router.patch('/api/v1/apps/:id', handleUpdateApp);
|
|
434
|
-
this.router.delete('/api/v1/apps/:id', handleDeleteApp);
|
|
461
|
+
this.router.delete('/api/v1/apps/:id', (req, res, params) => handleDeleteApp(req, res, params, this.buildFileDirs()));
|
|
435
462
|
// builds
|
|
436
463
|
this.router.get('/api/v1/builds', handleListBuilds);
|
|
437
464
|
this.router.get('/api/v1/builds/:id', handleGetBuild);
|
|
@@ -465,7 +492,7 @@ export class RelayServer {
|
|
|
465
492
|
this.router.post('/api/v1/tokens', handleCreateToken);
|
|
466
493
|
this.router.delete('/api/v1/tokens/:id', (req, res, params) => handleRevokeToken(req, res, params, this.onAuthChanged));
|
|
467
494
|
// settings
|
|
468
|
-
this.router.get('/api/v1/settings', handleGetSettings);
|
|
495
|
+
this.router.get('/api/v1/settings', (req, res) => handleGetSettings(req, res, this.runVideosDir));
|
|
469
496
|
this.router.patch('/api/v1/settings', (req, res) => handleUpdateSettings(req, res, u));
|
|
470
497
|
this.router.patch('/api/v1/profile', (req, res) => handleUpdateProfile(req, res, u));
|
|
471
498
|
// recordings
|
|
@@ -473,6 +500,22 @@ export class RelayServer {
|
|
|
473
500
|
this.router.post('/api/v1/recordings/upload', (req, res) => handleUploadRecording(req, res, this.recordingsDir));
|
|
474
501
|
this.router.get('/api/v1/recordings', (req, res) => handleListRecordings(req, res));
|
|
475
502
|
this.router.get('/api/v1/recordings/:filename', (req, res) => handleDownloadRecording(req, res, this.recordingsDir));
|
|
503
|
+
// flow run records — written by `tapflow flow run`, read by the runs page
|
|
504
|
+
this.runScreenshotsDir = runScreenshotsDirFor(u);
|
|
505
|
+
this.router.post('/api/v1/runs', handleCreateRun);
|
|
506
|
+
this.router.get('/api/v1/runs', (req, res) => handleListRuns(req, res, this.runHolders));
|
|
507
|
+
this.router.get('/api/v1/runs/:id', (req, res, params) => handleGetRun(req, res, params, this.runHolders, videoSummaries));
|
|
508
|
+
this.router.post('/api/v1/runs/:id/finish', handleFinishRun);
|
|
509
|
+
this.router.post('/api/v1/runs/:id/flows/:idx', handleReportFlow);
|
|
510
|
+
this.router.post('/api/v1/runs/:id/flows/:idx/screenshot', (req, res, params) => handleUploadFlowScreenshot(req, res, params, this.runScreenshotsDir));
|
|
511
|
+
this.router.get('/api/v1/runs/:id/flows/:idx/screenshot', (req, res, params) => handleGetFlowScreenshot(req, res, params, this.runScreenshotsDir));
|
|
512
|
+
// a flow's video (A4) — uploaded by the runner while the flow runs, kept by the retention policy
|
|
513
|
+
this.runVideosDir = runVideosDirFor(u);
|
|
514
|
+
this.router.post('/api/v1/runs/:id/flows/:idx/video/open', (req, res, params) => handleOpenVideo(req, res, params, this.runVideosDir));
|
|
515
|
+
this.router.post('/api/v1/runs/:id/flows/:idx/video/chunks/:seq', (req, res, params) => handleVideoChunk(req, res, params, this.runVideosDir, this.runHolders));
|
|
516
|
+
this.router.post('/api/v1/runs/:id/flows/:idx/video/end', handleEndVideo);
|
|
517
|
+
this.router.get('/api/v1/runs/:id/flows/:idx/video/index', (req, res, params) => handleVideoIndex(req, res, params, this.runHolders));
|
|
518
|
+
this.router.get('/api/v1/runs/:id/flows/:idx/video', (req, res, params) => handleVideoData(req, res, params, this.runHolders, this.runVideosDir));
|
|
476
519
|
// logs — the relay host only. The buffer holds the addresses of refused connections, nothing
|
|
477
520
|
// remote reads it (`tapflow logs` is its only consumer), and every line is also in the relay's own
|
|
478
521
|
// output. Same locality rule and proxy list as `auth/init` and the WebSocket handshake, so the tunnel
|
|
@@ -532,8 +575,17 @@ export class RelayServer {
|
|
|
532
575
|
purgeOldResources();
|
|
533
576
|
this.purgeOldResourcesTimer = setInterval(purgeOldResources, 24 * 60 * 60 * 1000);
|
|
534
577
|
this.purgeOldResourcesTimer.unref();
|
|
535
|
-
|
|
536
|
-
|
|
578
|
+
// Runs ride the builds' daily tick rather than a timer of their own: a build's runs go with it, and the
|
|
579
|
+
// runs left over (no build) expire on the same clock.
|
|
580
|
+
const purgeBuildsAndRuns = () => {
|
|
581
|
+
purgeExpiredBuilds(this.buildFileDirs());
|
|
582
|
+
purgeExpiredRuns(this.runScreenshotsDir, this.runVideosDir, this.runHolders);
|
|
583
|
+
purgeRunVideos(this.runVideosDir, this.runHolders);
|
|
584
|
+
};
|
|
585
|
+
// Before the first purge or write: no write in flight survived the last process.
|
|
586
|
+
releaseStaleVideoReservations();
|
|
587
|
+
purgeBuildsAndRuns();
|
|
588
|
+
this.purgeBuildsTimer = setInterval(purgeBuildsAndRuns, 24 * 60 * 60 * 1000);
|
|
537
589
|
this.purgeBuildsTimer.unref();
|
|
538
590
|
this.flushResourcesTimer = setInterval(() => this.flushResourceBuffers(), 60_000);
|
|
539
591
|
this.flushResourcesTimer.unref();
|
|
@@ -817,6 +869,32 @@ export class RelayServer {
|
|
|
817
869
|
* negative beats 30 with one every cycle**, because a false negative here does not merely delay: it
|
|
818
870
|
* hands a live tester's session to whoever asked next.
|
|
819
871
|
*/
|
|
872
|
+
buildFileDirs() {
|
|
873
|
+
return { uploadsDir: this.uploadsDir, recordingsDir: this.recordingsDir, runScreenshotsDir: this.runScreenshotsDir, runVideosDir: this.runVideosDir };
|
|
874
|
+
}
|
|
875
|
+
/** Open sockets speaking for any of these owner keys. */
|
|
876
|
+
openSocketsOf(keys) {
|
|
877
|
+
return [...this.wss.clients].filter((ws) => ws.readyState === WebSocket.OPEN && keys.includes(this.ownerKey.get(ws)?.key ?? ''));
|
|
878
|
+
}
|
|
879
|
+
runHolders = {
|
|
880
|
+
connectedKeys: () => {
|
|
881
|
+
const keys = new Set();
|
|
882
|
+
for (const ws of this.wss.clients) {
|
|
883
|
+
const key = ws.readyState === WebSocket.OPEN ? this.ownerKey.get(ws)?.key : undefined;
|
|
884
|
+
if (key)
|
|
885
|
+
keys.add(key);
|
|
886
|
+
}
|
|
887
|
+
return keys;
|
|
888
|
+
},
|
|
889
|
+
heldSession: (keys) => {
|
|
890
|
+
for (const ws of this.openSocketsOf(keys)) {
|
|
891
|
+
const held = this.sessions.getByBrowserSocket(ws);
|
|
892
|
+
if (held.length > 0)
|
|
893
|
+
return held[0].id;
|
|
894
|
+
}
|
|
895
|
+
return null;
|
|
896
|
+
},
|
|
897
|
+
};
|
|
820
898
|
isAlive(ws) {
|
|
821
899
|
const at = this.lastPongAt.get(ws);
|
|
822
900
|
// **No entry means just connected, not dead.** `handleConnection` seeds one, but a socket is in
|
|
@@ -859,6 +937,7 @@ export class RelayServer {
|
|
|
859
937
|
*/
|
|
860
938
|
forgetSessionState(sessionId) {
|
|
861
939
|
this.dropHandlers.delete(sessionId);
|
|
940
|
+
this.dropHandlers.delete(`${sessionId} watchers`);
|
|
862
941
|
this.audioDropHandlers.delete(sessionId);
|
|
863
942
|
this.droppers.delete(sessionId);
|
|
864
943
|
this.idrRequesters.delete(sessionId);
|
|
@@ -1028,6 +1107,10 @@ export class RelayServer {
|
|
|
1028
1107
|
if (pat)
|
|
1029
1108
|
touchPat(pat.patId);
|
|
1030
1109
|
userId = cookie?.userId ?? pat?.userId;
|
|
1110
|
+
// A loopback cookie is not re-judged later — `principals` below records remote sockets only — so a
|
|
1111
|
+
// watch on loopback lasts until the socket closes even if that user is removed. The same is true of
|
|
1112
|
+
// everything else such a socket does; watching adds nothing to it.
|
|
1113
|
+
this.mayWatch.set(ws, cookie !== null || (!isLocal && pat !== null && pat.scopes.includes(VIEW_SCOPE)));
|
|
1031
1114
|
if (!isLocal) {
|
|
1032
1115
|
if (cookie)
|
|
1033
1116
|
this.principals.set(ws, { via: 'cookie', userId: cookie.userId, jwtExp: cookie.exp, pwv: cookie.pwv });
|
|
@@ -1070,43 +1153,73 @@ export class RelayServer {
|
|
|
1070
1153
|
if (this.revokedSockets.has(ws))
|
|
1071
1154
|
return;
|
|
1072
1155
|
if (isBinary) {
|
|
1073
|
-
// Binary frames arrive on the dedicated stream WS, route to the session's browser
|
|
1156
|
+
// Binary frames arrive on the dedicated stream WS, route to the session's browser and watchers.
|
|
1074
1157
|
const session = this.sessions.getByStreamSocket(ws);
|
|
1075
|
-
if (session
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1158
|
+
if (!session)
|
|
1159
|
+
return;
|
|
1160
|
+
// **Not gated on the holder.** Watchers keep receiving frames while the holder is away inside its
|
|
1161
|
+
// reconnect grace, which is when `browserSocket` is null.
|
|
1162
|
+
const watchers = this.sessions.watchersOf(session.id);
|
|
1163
|
+
// A holder that said it does not read the stream (`stream: false`) is sent nothing: the MCP server and a
|
|
1164
|
+
// runner that is not recording discard every frame, and an iOS session is ~34 Mbit/s of JPEG to do it with.
|
|
1165
|
+
const holder = session.holderStream ? session.browserSocket : null;
|
|
1166
|
+
if (!holder && watchers.length === 0)
|
|
1167
|
+
return;
|
|
1168
|
+
const frameBuf = data;
|
|
1169
|
+
// Audio rides the same socket (codec-tagged). Route it through a sender that YIELDS to
|
|
1170
|
+
// video — it drops audio unless the socket is near-empty, so audio never inflates
|
|
1171
|
+
// bufferedAmount enough to trip the video backpressure path. Must branch before the
|
|
1172
|
+
// video dropper, which would (wrongly) treat audio as a droppable P-frame.
|
|
1173
|
+
//
|
|
1174
|
+
// **The holder's alone.** A watch page has no audio control, and audio a teammate did not ask
|
|
1175
|
+
// to hear should not start playing in their tab.
|
|
1176
|
+
if (hasEnvelope(frameBuf) && readEnvelopeFlags(frameBuf).codec === CODEC_AUDIO) {
|
|
1177
|
+
if (!holder)
|
|
1089
1178
|
return;
|
|
1179
|
+
patchRelayedAt(frameBuf, Date.now());
|
|
1180
|
+
let onAudioDrop = this.audioDropHandlers.get(session.id);
|
|
1181
|
+
if (!onAudioDrop) {
|
|
1182
|
+
onAudioDrop = createRateLimitedDropWarn(logger, `${session.id} audio`);
|
|
1183
|
+
this.audioDropHandlers.set(session.id, onAudioDrop);
|
|
1090
1184
|
}
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1185
|
+
sendAudioYieldingToVideo(holder, frameBuf, onAudioDrop);
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
let onDrop = this.dropHandlers.get(session.id);
|
|
1189
|
+
if (!onDrop) {
|
|
1190
|
+
onDrop = createRateLimitedDropWarn(logger, session.id);
|
|
1191
|
+
this.dropHandlers.set(session.id, onDrop);
|
|
1192
|
+
}
|
|
1193
|
+
const requestIdr = this.idrRequester(session.id);
|
|
1194
|
+
// JPEG and H.264 IDRs are resync points; only P-frames must wait for a keyframe after a drop.
|
|
1195
|
+
// Patched once: every recipient is sent the same buffer, so it carries one relay timestamp.
|
|
1196
|
+
let isKeyframe = true;
|
|
1197
|
+
if (hasEnvelope(frameBuf)) {
|
|
1198
|
+
patchRelayedAt(frameBuf, Date.now());
|
|
1199
|
+
const flags = readEnvelopeFlags(frameBuf);
|
|
1200
|
+
isKeyframe = flags.codec === CODEC_JPEG || flags.keyframe;
|
|
1201
|
+
}
|
|
1202
|
+
if (holder) {
|
|
1096
1203
|
let dropper = this.droppers.get(session.id);
|
|
1097
1204
|
if (!dropper) {
|
|
1098
1205
|
dropper = createKeyframeAwareSender();
|
|
1099
1206
|
this.droppers.set(session.id, dropper);
|
|
1100
1207
|
}
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1208
|
+
dropper.send(holder, frameBuf, this.backpressureBytes, isKeyframe, onDrop, requestIdr);
|
|
1209
|
+
}
|
|
1210
|
+
// **No IDR request on a watcher's drop**, and its own drop warning. An IDR changes the encoder's
|
|
1211
|
+
// output for everyone, so a watcher on a slow link asking for one on every drain would cost the
|
|
1212
|
+
// holder up to two forced keyframes a second — the slow-watcher-hurts-holder case the separate
|
|
1213
|
+
// sender exists to prevent, by another route. A watcher resyncs on the next periodic keyframe.
|
|
1214
|
+
if (watchers.length > 0) {
|
|
1215
|
+
let onWatcherDrop = this.dropHandlers.get(`${session.id} watchers`);
|
|
1216
|
+
if (!onWatcherDrop) {
|
|
1217
|
+
onWatcherDrop = createRateLimitedDropWarn(logger, `${session.id} watchers`);
|
|
1218
|
+
this.dropHandlers.set(`${session.id} watchers`, onWatcherDrop);
|
|
1219
|
+
}
|
|
1220
|
+
for (const w of watchers) {
|
|
1221
|
+
this.watcherDropper(w, session.id).send(w, frameBuf, this.backpressureBytes, isKeyframe, onWatcherDrop);
|
|
1108
1222
|
}
|
|
1109
|
-
dropper.send(session.browserSocket, frameBuf, this.backpressureBytes, isKeyframe, onDrop, requestIdr);
|
|
1110
1223
|
}
|
|
1111
1224
|
return;
|
|
1112
1225
|
}
|
|
@@ -1190,6 +1303,9 @@ export class RelayServer {
|
|
|
1190
1303
|
const streamSession = this.sessions.getByStreamSocket(ws);
|
|
1191
1304
|
if (streamSession)
|
|
1192
1305
|
this.sessions.clearStreamSocket(streamSession.id);
|
|
1306
|
+
// A watcher leaving changes nothing for the session it watched — no release, no timer, no IDR.
|
|
1307
|
+
this.sessions.removeWatcherSocket(ws);
|
|
1308
|
+
this.watcherDroppers.delete(ws);
|
|
1193
1309
|
// Browser socket disconnected → release **every** session it held, each with its own idle timer.
|
|
1194
1310
|
//
|
|
1195
1311
|
// `getByBrowserSocket` used to answer with one session because the index held one, and a socket
|
|
@@ -1204,6 +1320,20 @@ export class RelayServer {
|
|
|
1204
1320
|
return;
|
|
1205
1321
|
for (const held of this.sessions.getByBrowserSocket(ws)) {
|
|
1206
1322
|
this.sessions.clearBrowser(held.id, () => this.idleShutdown(held.id));
|
|
1323
|
+
this.tellWatchers(held.id, { type: 'watch:holder-left', sessionId: held.id });
|
|
1324
|
+
}
|
|
1325
|
+
// A run is its runner's socket: the flow runner does not reconnect, so the last socket with its key
|
|
1326
|
+
// closing is the end of that run, finished or not. Not done while stopping (above) — a relay going
|
|
1327
|
+
// down has not seen the runner go, and the runner may still finish the run against the next one.
|
|
1328
|
+
//
|
|
1329
|
+
// Caught because this is an event listener: a database fault here (a full disk) would be uncaught and end
|
|
1330
|
+
// the relay — the rule `handleConnection` states for its own reads. A run missed here is closed by
|
|
1331
|
+
// `settleOrphanedRuns` on the next read.
|
|
1332
|
+
try {
|
|
1333
|
+
abandonRunsOf(this.ownerOf(ws), this.runHolders);
|
|
1334
|
+
}
|
|
1335
|
+
catch (e) {
|
|
1336
|
+
logger.error('could not close the runs of a disconnected runner:', e);
|
|
1207
1337
|
}
|
|
1208
1338
|
});
|
|
1209
1339
|
}
|
|
@@ -1372,6 +1502,26 @@ export class RelayServer {
|
|
|
1372
1502
|
case 'session:start':
|
|
1373
1503
|
this.handleSessionStart(ws, msg);
|
|
1374
1504
|
break;
|
|
1505
|
+
case 'session:activity': {
|
|
1506
|
+
// A holder's name for what it is about to do (A2), for its watchers only. Unanswered either way, so an
|
|
1507
|
+
// unowned one is simply dropped; the schema already capped its length. **Its content is trusted, not
|
|
1508
|
+
// checked**: the rule that it carries nothing typed binds the sender (`activityLabel` is the only producer),
|
|
1509
|
+
// and a holder that broke it could show no more than it already can on the screen its watchers see.
|
|
1510
|
+
const session = this.sessions.get(msg.sessionId);
|
|
1511
|
+
const label = msg.label.trim();
|
|
1512
|
+
if (session && label && this.ownsSession(ws, session))
|
|
1513
|
+
this.tellWatchersAction(session.id, { kind: 'label', label });
|
|
1514
|
+
break;
|
|
1515
|
+
}
|
|
1516
|
+
case 'watch:start':
|
|
1517
|
+
this.handleWatchStart(ws, msg);
|
|
1518
|
+
break;
|
|
1519
|
+
// Unanswered, like `session:leave`: nobody waits on it, and closing the socket does the same.
|
|
1520
|
+
case 'watch:stop': {
|
|
1521
|
+
if (this.sessions.removeWatcher(msg.sessionId, ws))
|
|
1522
|
+
this.forgetWatcherDropper(ws, msg.sessionId);
|
|
1523
|
+
break;
|
|
1524
|
+
}
|
|
1375
1525
|
// ── the two session commands, gated but not answered ───────────────────────────────────────
|
|
1376
1526
|
//
|
|
1377
1527
|
// Both destroy state a viewer depends on, and until L5c both acted on the strength of the session
|
|
@@ -1405,6 +1555,10 @@ export class RelayServer {
|
|
|
1405
1555
|
if (this.ownsSession(ws, this.sessions.get(msg.sessionId))) {
|
|
1406
1556
|
this.sessions.clearBrowser(msg.sessionId);
|
|
1407
1557
|
this.forgetSessionState(msg.sessionId);
|
|
1558
|
+
// **A leave is the end, not a blip.** The holder said it is done — a flow run finishing, an MCP
|
|
1559
|
+
// `disconnect_device` — so the watch ends. `watch:holder-left` is for a socket that dropped, which
|
|
1560
|
+
// may come back; sent here it left a watcher of every finished CI run waiting forever.
|
|
1561
|
+
this.sessions.endWatches(msg.sessionId, 'session-ended');
|
|
1408
1562
|
}
|
|
1409
1563
|
break;
|
|
1410
1564
|
}
|
|
@@ -1440,6 +1594,7 @@ export class RelayServer {
|
|
|
1440
1594
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
1441
1595
|
session.browserSocket.send(JSON.stringify(raw));
|
|
1442
1596
|
}
|
|
1597
|
+
this.forwardToWatchers(session.id, raw);
|
|
1443
1598
|
break;
|
|
1444
1599
|
}
|
|
1445
1600
|
case 'device:postures': {
|
|
@@ -1454,6 +1609,7 @@ export class RelayServer {
|
|
|
1454
1609
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
1455
1610
|
session.browserSocket.send(JSON.stringify(raw));
|
|
1456
1611
|
}
|
|
1612
|
+
this.forwardToWatchers(session.id, raw);
|
|
1457
1613
|
break;
|
|
1458
1614
|
}
|
|
1459
1615
|
case 'session:deviceInfo': {
|
|
@@ -1465,6 +1621,7 @@ export class RelayServer {
|
|
|
1465
1621
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
1466
1622
|
session.browserSocket.send(JSON.stringify(raw));
|
|
1467
1623
|
}
|
|
1624
|
+
this.forwardToWatchers(session.id, raw);
|
|
1468
1625
|
break;
|
|
1469
1626
|
}
|
|
1470
1627
|
case 'device:booting': {
|
|
@@ -1476,6 +1633,7 @@ export class RelayServer {
|
|
|
1476
1633
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
1477
1634
|
session.browserSocket.send(JSON.stringify(raw));
|
|
1478
1635
|
}
|
|
1636
|
+
this.forwardToWatchers(session.id, raw);
|
|
1479
1637
|
break;
|
|
1480
1638
|
}
|
|
1481
1639
|
case 'device:boot-error': {
|
|
@@ -1529,6 +1687,7 @@ export class RelayServer {
|
|
|
1529
1687
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
1530
1688
|
session.browserSocket.send(JSON.stringify(raw));
|
|
1531
1689
|
}
|
|
1690
|
+
this.forwardToWatchers(session.id, raw);
|
|
1532
1691
|
break;
|
|
1533
1692
|
}
|
|
1534
1693
|
case 'app:install-done':
|
|
@@ -1613,6 +1772,7 @@ export class RelayServer {
|
|
|
1613
1772
|
// presence check the old line carried is gone because the schema requires the payload — and
|
|
1614
1773
|
// `external` is deliberately not declared on `DeviceBoot`, since the browser never sends it.
|
|
1615
1774
|
msg.payload['external'] = this.wsExternal.get(ws) ?? false;
|
|
1775
|
+
msg.payload['automation'] = isAiClientKind(boot.session.holderKind);
|
|
1616
1776
|
// Tracked by the socket it actually goes to (#885): a rebind invalidates only the boots
|
|
1617
1777
|
// tied to the replaced socket, so a boot dispatched after the rebind stays answerable.
|
|
1618
1778
|
this.rememberBoot(msg.sessionId, msg.requestId, boot.session.agentSocket);
|
|
@@ -1635,6 +1795,18 @@ export class RelayServer {
|
|
|
1635
1795
|
// command the relay never answers. `mcp-server`'s `shutdownDevice` waits 30s on `device:shutdown-done`
|
|
1636
1796
|
// and then reports `Request timed out` with no cause, which is the silence, not a diagnosis.
|
|
1637
1797
|
const session = this.sessions.get(msg.sessionId);
|
|
1798
|
+
// A watcher's gate comes first: `mayShutDown` passes anyone while the session is unheld, and the
|
|
1799
|
+
// holder's reconnect grace is exactly that — so a watch page closing at that moment would power
|
|
1800
|
+
// off the device an agent is in the middle of testing.
|
|
1801
|
+
if (session && this.sessions.isWatching(ws, session.id)) {
|
|
1802
|
+
this.sendTo(ws, {
|
|
1803
|
+
type: 'device:shutdown-error',
|
|
1804
|
+
sessionId: msg.sessionId,
|
|
1805
|
+
...(msg.requestId === undefined ? {} : { requestId: msg.requestId }),
|
|
1806
|
+
message: 'This connection is watching the session and cannot shut its device down.',
|
|
1807
|
+
});
|
|
1808
|
+
break;
|
|
1809
|
+
}
|
|
1638
1810
|
if (session && !this.mayShutDown(ws, session)) {
|
|
1639
1811
|
this.sendTo(ws, {
|
|
1640
1812
|
type: 'device:shutdown-error',
|
|
@@ -1693,6 +1865,8 @@ export class RelayServer {
|
|
|
1693
1865
|
const target = this.dispatchTarget(ws, msg.sessionId);
|
|
1694
1866
|
if (target.ok) {
|
|
1695
1867
|
target.session.agentSocket.send(JSON.stringify(msg));
|
|
1868
|
+
// No URL: a deep link can carry a token, and watchers are the whole team.
|
|
1869
|
+
this.tellWatchersAction(msg.sessionId, { kind: 'open-url' });
|
|
1696
1870
|
}
|
|
1697
1871
|
else {
|
|
1698
1872
|
this.sendTo(ws, {
|
|
@@ -1710,6 +1884,7 @@ export class RelayServer {
|
|
|
1710
1884
|
const target = this.dispatchTarget(ws, msg.sessionId);
|
|
1711
1885
|
if (target.ok) {
|
|
1712
1886
|
target.session.agentSocket.send(JSON.stringify(msg));
|
|
1887
|
+
this.tellWatchersAction(msg.sessionId, { kind: 'clear-state' });
|
|
1713
1888
|
}
|
|
1714
1889
|
else {
|
|
1715
1890
|
this.sendTo(ws, {
|
|
@@ -2008,6 +2183,7 @@ export class RelayServer {
|
|
|
2008
2183
|
for (const s of sessions) {
|
|
2009
2184
|
if (s.browserSocket)
|
|
2010
2185
|
this.sendTo(s.browserSocket, { type: 'session:agent-away', sessionId: s.id });
|
|
2186
|
+
this.tellWatchers(s.id, { type: 'session:agent-away', sessionId: s.id });
|
|
2011
2187
|
}
|
|
2012
2188
|
const timer = setTimeout(() => {
|
|
2013
2189
|
this.agentHolds.delete(ws);
|
|
@@ -2173,6 +2349,7 @@ export class RelayServer {
|
|
|
2173
2349
|
if (s?.browserSocket) {
|
|
2174
2350
|
this.sendTo(s.browserSocket, { type: 'session:rebound', sessionId, capabilities: msg.capabilities ?? [] });
|
|
2175
2351
|
}
|
|
2352
|
+
this.tellWatchers(sessionId, { type: 'session:rebound', sessionId, capabilities: msg.capabilities ?? [] });
|
|
2176
2353
|
}
|
|
2177
2354
|
// After the rebound frames, so the client records `needsReboot` before the error below
|
|
2178
2355
|
// arrives and classifies it with the cause attached. Only boots tied to the replaced
|
|
@@ -2203,6 +2380,17 @@ export class RelayServer {
|
|
|
2203
2380
|
this.sendTo(ws, { type: 'error', sessionId: msg.sessionId, message: 'Session not found', reason: 'session-not-found' });
|
|
2204
2381
|
return;
|
|
2205
2382
|
}
|
|
2383
|
+
// **A watcher may not take the session it watches.** Inside the holder's reconnect grace the session
|
|
2384
|
+
// is unheld, so without this a watch page that re-sent `session:start` — a reused viewer path, a
|
|
2385
|
+
// reconnect — would become the holder, and the agent's own re-join would then be refused as busy.
|
|
2386
|
+
// `session-busy` because that is what the caller must act on: someone else's session, in use.
|
|
2387
|
+
if (this.sessions.isWatching(ws, session.id)) {
|
|
2388
|
+
this.sendTo(ws, {
|
|
2389
|
+
type: 'error', sessionId: msg.sessionId, reason: 'session-busy',
|
|
2390
|
+
message: 'This connection is watching the session. Stop watching to join it.',
|
|
2391
|
+
});
|
|
2392
|
+
return;
|
|
2393
|
+
}
|
|
2206
2394
|
// Occupancy first, because it is the more specific answer and both can be true at once. A tester
|
|
2207
2395
|
// joining a device someone else has open on a loaded Mac was being told "this Mac is overloaded,
|
|
2208
2396
|
// pick another" — advice for a problem they cannot act on, while the actual reason went unreported.
|
|
@@ -2234,7 +2422,9 @@ export class RelayServer {
|
|
|
2234
2422
|
}
|
|
2235
2423
|
}
|
|
2236
2424
|
try {
|
|
2237
|
-
const joined = this.sessions.join(msg.sessionId, ws, this.ownerRecord(ws), (h) => this.isAlive(h)
|
|
2425
|
+
const joined = this.sessions.join(msg.sessionId, ws, this.ownerRecord(ws), (h) => this.isAlive(h),
|
|
2426
|
+
// Recorded by the join itself, so it holds before the agent-away return below and across a rebind.
|
|
2427
|
+
{ kind: msg.clientKind, label: this.holderLabel(ws), ...(msg.stream === false ? { stream: false } : {}) });
|
|
2238
2428
|
if (!joined.ok) {
|
|
2239
2429
|
// Both of these are answered above, so arriving here means the state moved between that check and
|
|
2240
2430
|
// this call. They are **values now rather than throws** (#515), and that is what fixes the defect:
|
|
@@ -2265,6 +2455,7 @@ export class RelayServer {
|
|
|
2265
2455
|
// deliberately instead of inferring anything from a timeout.
|
|
2266
2456
|
this.sendTo(ws, {
|
|
2267
2457
|
type: 'session:joined', sessionId: msg.sessionId, capabilities: session.agentCapabilities ?? [],
|
|
2458
|
+
...(isAiClientKind(msg.clientKind) ? this.watchUrlField(msg.sessionId) : {}),
|
|
2268
2459
|
});
|
|
2269
2460
|
if (agentAway) {
|
|
2270
2461
|
// Joining into a held session. Refusing instead would be worse than it sounds: the viewer
|
|
@@ -2309,7 +2500,10 @@ export class RelayServer {
|
|
|
2309
2500
|
// the P-frames around it, so the amplification lands on the tester who is actually watching. The
|
|
2310
2501
|
// backpressure path already throttles this exact message per session; sharing that requester is
|
|
2311
2502
|
// what keeps the two callers from having two policies.
|
|
2312
|
-
|
|
2503
|
+
// Not for a holder that takes no frames: the keyframe would go to watchers alone, and it would spend the
|
|
2504
|
+
// throttle window a watcher's own `watch:start` request needs.
|
|
2505
|
+
if (session.holderStream)
|
|
2506
|
+
this.idrRequester(session.id)();
|
|
2313
2507
|
// And ask what the device's network is doing (#614). `NetworkState` is agent-produced and is not
|
|
2314
2508
|
// among the three replayed above, so without this a viewer that reconnects has no way to learn
|
|
2315
2509
|
// whether the device is offline and its control renders in a guessed position.
|
|
@@ -2333,6 +2527,150 @@ export class RelayServer {
|
|
|
2333
2527
|
this.networkStateRequester(session.id)();
|
|
2334
2528
|
}
|
|
2335
2529
|
}
|
|
2530
|
+
/**
|
|
2531
|
+
* Admit `ws` as a read-only watcher, or say why not. The order of the refusals is the order a viewer
|
|
2532
|
+
* can act on them: a connection that may never watch is told so before it learns anything about the
|
|
2533
|
+
* session.
|
|
2534
|
+
*/
|
|
2535
|
+
handleWatchStart(ws, msg) {
|
|
2536
|
+
const refuse = (reason, message) => this.sendTo(ws, { type: 'watch:refused', sessionId: msg.sessionId, reason, message });
|
|
2537
|
+
if (!this.mayWatch.get(ws))
|
|
2538
|
+
return refuse('not-permitted', 'Sign in to watch a session.');
|
|
2539
|
+
const session = this.sessions.get(msg.sessionId);
|
|
2540
|
+
if (!session)
|
|
2541
|
+
return refuse('session-not-found', 'Session not found');
|
|
2542
|
+
if (session.owner === null || !isAiClientKind(session.holderKind)) {
|
|
2543
|
+
return refuse('not-watchable', 'Only a session an AI client is driving can be watched.');
|
|
2544
|
+
}
|
|
2545
|
+
// The holder already receives everything. Watching too would deliver each frame twice to one socket,
|
|
2546
|
+
// and the watcher gates would then refuse the holder's own shutdown and re-join.
|
|
2547
|
+
if (this.ownsSession(ws, session))
|
|
2548
|
+
return refuse('not-watchable', 'This connection already holds the session.');
|
|
2549
|
+
if (this.sessions.addWatcher(session.id, ws, session.owner) === 'full') {
|
|
2550
|
+
return refuse('watchers-full', 'This session already has as many watchers as it allows.');
|
|
2551
|
+
}
|
|
2552
|
+
this.sendTo(ws, { type: 'watch:started', sessionId: session.id });
|
|
2553
|
+
// The same replay a re-joining viewer gets, for the same reason: a watch can begin mid-session.
|
|
2554
|
+
if (session.agentSocket.readyState !== WebSocket.OPEN) {
|
|
2555
|
+
this.sendTo(ws, { type: 'session:agent-away', sessionId: session.id });
|
|
2556
|
+
return;
|
|
2557
|
+
}
|
|
2558
|
+
if (session.chromeData)
|
|
2559
|
+
this.sendTo(ws, { type: 'session:chrome', sessionId: session.id, payload: session.chromeData });
|
|
2560
|
+
if (session.deviceInfo)
|
|
2561
|
+
this.sendTo(ws, { type: 'session:deviceInfo', sessionId: session.id, payload: session.deviceInfo });
|
|
2562
|
+
if (session.postures)
|
|
2563
|
+
this.sendTo(ws, { type: 'device:postures', sessionId: session.id, payload: session.postures });
|
|
2564
|
+
if (session.readySent) {
|
|
2565
|
+
this.sendTo(ws, { type: 'device:ready', payload: { deviceId: session.deviceId } });
|
|
2566
|
+
// Through the session's throttled requester, as the re-join does: a watcher arriving mid-GOP needs
|
|
2567
|
+
// a keyframe, and a socket starting watches repeatedly must not turn into a stream of IDRs.
|
|
2568
|
+
this.idrRequester(session.id)();
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2571
|
+
/** The keyframe-aware sender for one watcher of one session — see `watcherDroppers`. */
|
|
2572
|
+
watcherDropper(ws, sessionId) {
|
|
2573
|
+
let bySession = this.watcherDroppers.get(ws);
|
|
2574
|
+
if (!bySession) {
|
|
2575
|
+
bySession = new Map();
|
|
2576
|
+
this.watcherDroppers.set(ws, bySession);
|
|
2577
|
+
}
|
|
2578
|
+
let dropper = bySession.get(sessionId);
|
|
2579
|
+
if (!dropper) {
|
|
2580
|
+
dropper = createKeyframeAwareSender();
|
|
2581
|
+
bySession.set(sessionId, dropper);
|
|
2582
|
+
}
|
|
2583
|
+
return dropper;
|
|
2584
|
+
}
|
|
2585
|
+
forgetWatcherDropper(ws, sessionId) {
|
|
2586
|
+
const bySession = this.watcherDroppers.get(ws);
|
|
2587
|
+
bySession?.delete(sessionId);
|
|
2588
|
+
if (bySession?.size === 0)
|
|
2589
|
+
this.watcherDroppers.delete(ws);
|
|
2590
|
+
}
|
|
2591
|
+
/**
|
|
2592
|
+
* Forward an agent's frame to the session's watchers. **Only the messages that describe the device**
|
|
2593
|
+
* call this — chrome, device info, postures, booting, ready — never a reply to something the holder
|
|
2594
|
+
* asked. An ack, a clipboard payload or a network state is the holder's, and a watcher shown one would
|
|
2595
|
+
* learn what the agent is doing on a channel it was never admitted to.
|
|
2596
|
+
*/
|
|
2597
|
+
forwardToWatchers(sessionId, raw) {
|
|
2598
|
+
const watchers = this.sessions.watchersOf(sessionId);
|
|
2599
|
+
if (watchers.length === 0)
|
|
2600
|
+
return;
|
|
2601
|
+
const frame = JSON.stringify(raw);
|
|
2602
|
+
for (const w of watchers)
|
|
2603
|
+
if (w.readyState === WebSocket.OPEN)
|
|
2604
|
+
w.send(frame);
|
|
2605
|
+
}
|
|
2606
|
+
/**
|
|
2607
|
+
* What the holder just did, as its watchers are told it (A2): only for an input the relay passed on to the agent,
|
|
2608
|
+
* so a refused one is never shown. **Built, never forwarded**: the raw `input:type` carries the text typed, and a
|
|
2609
|
+
* watcher gets a count.
|
|
2610
|
+
*/
|
|
2611
|
+
tellWatchersInput(session, msg) {
|
|
2612
|
+
let action = null;
|
|
2613
|
+
switch (msg.type) {
|
|
2614
|
+
case 'input:touch:end': {
|
|
2615
|
+
const g = session.gesture;
|
|
2616
|
+
session.gesture = null;
|
|
2617
|
+
action = gestureAction(g?.start ?? null, msg.payload ?? g?.last ?? null);
|
|
2618
|
+
break;
|
|
2619
|
+
}
|
|
2620
|
+
case 'input:pinch:end':
|
|
2621
|
+
action = { kind: 'pinch' };
|
|
2622
|
+
break;
|
|
2623
|
+
case 'input:key':
|
|
2624
|
+
action = { kind: 'key', key: keyForWatchers(msg.payload.code) };
|
|
2625
|
+
break;
|
|
2626
|
+
// One press is a down and an up from a client that sends both; told once, on the up.
|
|
2627
|
+
case 'input:button':
|
|
2628
|
+
action = msg.payload.phase === 'down' ? null : { kind: 'button', button: msg.payload.name };
|
|
2629
|
+
break;
|
|
2630
|
+
case 'input:type':
|
|
2631
|
+
action = { kind: 'type', chars: [...msg.payload.text].length };
|
|
2632
|
+
break;
|
|
2633
|
+
}
|
|
2634
|
+
if (action)
|
|
2635
|
+
this.tellWatchersAction(session.id, action);
|
|
2636
|
+
}
|
|
2637
|
+
tellWatchersAction(sessionId, action) {
|
|
2638
|
+
const watchers = this.sessions.watchersOf(sessionId);
|
|
2639
|
+
if (watchers.length === 0)
|
|
2640
|
+
return;
|
|
2641
|
+
for (const w of watchers)
|
|
2642
|
+
this.sendTo(w, { type: 'watch:action', sessionId, at: Date.now(), action });
|
|
2643
|
+
}
|
|
2644
|
+
/** The relay-originated half of what watchers hear: holder left, agent away, agent rebound. */
|
|
2645
|
+
tellWatchers(sessionId, msg) {
|
|
2646
|
+
for (const w of this.sessions.watchersOf(sessionId))
|
|
2647
|
+
this.sendTo(w, msg);
|
|
2648
|
+
}
|
|
2649
|
+
/** Who to show behind a holder: the signed-in user's email, or `local` for a loopback client with no
|
|
2650
|
+
* credential. A label for people only — nothing reads it back. */
|
|
2651
|
+
holderLabel(ws) {
|
|
2652
|
+
const user = this.userOf(ws);
|
|
2653
|
+
const id = Number(user);
|
|
2654
|
+
if (!Number.isInteger(id))
|
|
2655
|
+
return 'local';
|
|
2656
|
+
try {
|
|
2657
|
+
const row = getDb().prepare('SELECT email FROM users WHERE id = ?').get(id);
|
|
2658
|
+
return row?.email ?? 'unknown';
|
|
2659
|
+
}
|
|
2660
|
+
catch {
|
|
2661
|
+
return 'unknown';
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
/**
|
|
2665
|
+
* The dashboard page that watches a session — **only at an address a teammate's browser can open**, the
|
|
2666
|
+
* gate invite links take (`forTeammates`). With no tunnel and no `relay.url` the only base this relay has
|
|
2667
|
+
* is localhost, which is wrong for a client on another machine; the field is left out and the client
|
|
2668
|
+
* builds the link from the address it dialled, which the person running it can always open.
|
|
2669
|
+
*/
|
|
2670
|
+
watchUrlField(sessionId) {
|
|
2671
|
+
const base = forTeammates(resolvePublicBaseUrl(config, this.options.tunnel));
|
|
2672
|
+
return base === null ? {} : { watchUrl: `${base}/automation/sessions/${encodeURIComponent(sessionId)}` };
|
|
2673
|
+
}
|
|
2336
2674
|
/** Shut the device down because nobody is watching it any more. The idle timer's payload, hoisted out
|
|
2337
2675
|
* of the close handler so the release loop there reads as one line per session. */
|
|
2338
2676
|
idleShutdown(sessionId) {
|
|
@@ -2482,6 +2820,12 @@ export class RelayServer {
|
|
|
2482
2820
|
}
|
|
2483
2821
|
if (session?.agentSocket.readyState === WebSocket.OPEN) {
|
|
2484
2822
|
session.agentSocket.send(JSON.stringify(msg));
|
|
2823
|
+
// Where a touch began and last was, for its end to be told to watchers (A2). Moves are not sent on: one
|
|
2824
|
+
// action per gesture, at its end.
|
|
2825
|
+
if (msg.type === 'input:touch:start')
|
|
2826
|
+
session.gesture = { start: msg.payload, last: msg.payload };
|
|
2827
|
+
else if (msg.type === 'input:touch:move' && session.gesture)
|
|
2828
|
+
session.gesture.last = msg.payload;
|
|
2485
2829
|
}
|
|
2486
2830
|
}
|
|
2487
2831
|
/** The five inputs an ack answers, so the five that carry a required correlator.
|
|
@@ -2509,6 +2853,7 @@ export class RelayServer {
|
|
|
2509
2853
|
}
|
|
2510
2854
|
if (session?.agentSocket.readyState === WebSocket.OPEN) {
|
|
2511
2855
|
session.agentSocket.send(JSON.stringify(msg));
|
|
2856
|
+
this.tellWatchersInput(session, msg);
|
|
2512
2857
|
return;
|
|
2513
2858
|
}
|
|
2514
2859
|
// A request that cannot be dispatched. Two situations reach here and only one is the agent's fault:
|
|
@@ -2676,6 +3021,7 @@ export class RelayServer {
|
|
|
2676
3021
|
buildBytes: bytes,
|
|
2677
3022
|
},
|
|
2678
3023
|
});
|
|
3024
|
+
this.tellWatchersAction(sessionId, { kind: 'install' });
|
|
2679
3025
|
}
|
|
2680
3026
|
/**
|
|
2681
3027
|
* Says once, per agent, that this agent cannot fetch builds over HTTP.
|
|
@@ -2732,6 +3078,7 @@ export class RelayServer {
|
|
|
2732
3078
|
requestId,
|
|
2733
3079
|
payload: { bundleId: build.bundle_id },
|
|
2734
3080
|
});
|
|
3081
|
+
this.tellWatchersAction(sessionId, { kind: 'launch' });
|
|
2735
3082
|
}
|
|
2736
3083
|
async handleGetScreenshot(req, res, params) {
|
|
2737
3084
|
if (!requireViewAuth(req, res))
|