@tapflowio/relay 0.27.0 → 0.28.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 +74 -0
- package/dist/RelayServer.d.ts.map +1 -1
- package/dist/RelayServer.js +482 -43
- 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 +23 -45
- package/dist/api/builds.js.map +1 -1
- package/dist/api/comments.d.ts.map +1 -1
- package/dist/api/comments.js +5 -7
- package/dist/api/comments.js.map +1 -1
- package/dist/api/invitations.d.ts.map +1 -1
- package/dist/api/invitations.js +3 -0
- package/dist/api/invitations.js.map +1 -1
- package/dist/api/profile.d.ts.map +1 -1
- package/dist/api/profile.js +28 -9
- package/dist/api/profile.js.map +1 -1
- package/dist/api/recordings.d.ts.map +1 -1
- package/dist/api/recordings.js +5 -6
- package/dist/api/recordings.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 +67 -10
- 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/clientAddress.d.ts +12 -0
- package/dist/lib/clientAddress.d.ts.map +1 -1
- package/dist/lib/clientAddress.js +22 -0
- package/dist/lib/clientAddress.js.map +1 -1
- package/dist/lib/uploads.d.ts +13 -0
- package/dist/lib/uploads.d.ts.map +1 -1
- package/dist/lib/uploads.js +41 -0
- package/dist/lib/uploads.js.map +1 -1
- 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-Bxew762e.js +36 -0
- package/public/assets/AISessionWatch-Bxew762e.js.br +0 -0
- package/public/assets/AISessionWatch-Bxew762e.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-93hnrrdy.js +26 -0
- package/public/assets/AppCenter-93hnrrdy.js.br +0 -0
- package/public/assets/AppCenter-93hnrrdy.js.gz +0 -0
- package/public/assets/Default-BhgcUKBm.js +6 -0
- package/public/assets/Default-BhgcUKBm.js.br +0 -0
- package/public/assets/Default-BhgcUKBm.js.gz +0 -0
- package/public/assets/Invite-DQBwLaOl.js +1 -0
- package/public/assets/Invite-DQBwLaOl.js.br +0 -0
- package/public/assets/Invite-DQBwLaOl.js.gz +0 -0
- package/public/assets/MacResources-D4LMGxgV.js +1 -0
- package/public/assets/MacResources-D4LMGxgV.js.br +0 -0
- package/public/assets/MacResources-D4LMGxgV.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-Dnuq0yrN.js +81 -0
- package/public/assets/QASession-Dnuq0yrN.js.br +0 -0
- package/public/assets/QASession-Dnuq0yrN.js.gz +0 -0
- package/public/assets/ResetPassword-inTbbral.js +1 -0
- package/public/assets/ResetPassword-inTbbral.js.br +0 -0
- package/public/assets/ResetPassword-inTbbral.js.gz +0 -0
- package/public/assets/RunDetail-B3dw8_U0.js +16 -0
- package/public/assets/RunDetail-B3dw8_U0.js.br +0 -0
- package/public/assets/RunDetail-B3dw8_U0.js.gz +0 -0
- package/public/assets/Runs-DqZl9Y6M.js +6 -0
- package/public/assets/Runs-DqZl9Y6M.js.br +0 -0
- package/public/assets/Runs-DqZl9Y6M.js.gz +0 -0
- package/public/assets/Setup-CPa_UWpa.js +1 -0
- package/public/assets/Setup-CPa_UWpa.js.br +0 -0
- package/public/assets/Setup-CPa_UWpa.js.gz +0 -0
- package/public/assets/Team-DZie8W7R.js +6 -0
- package/public/assets/Team-DZie8W7R.js.br +0 -0
- package/public/assets/Team-DZie8W7R.js.gz +0 -0
- package/public/assets/Tokens-u3-uRAwd.js +1 -0
- package/public/assets/Tokens-u3-uRAwd.js.br +0 -0
- package/public/assets/Tokens-u3-uRAwd.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-BsmDnyV1.js +31 -0
- package/public/assets/aiSession-BsmDnyV1.js.br +0 -0
- package/public/assets/aiSession-BsmDnyV1.js.gz +0 -0
- package/public/assets/alert-dialog-Bq_Ln5bp.js +1 -0
- package/public/assets/alert-dialog-Bq_Ln5bp.js.br +0 -0
- package/public/assets/alert-dialog-Bq_Ln5bp.js.gz +0 -0
- package/public/assets/{badge-B3vaBVIQ.js → badge-D-bt97WR.js} +1 -1
- package/public/assets/badge-D-bt97WR.js.br +0 -0
- package/public/assets/badge-D-bt97WR.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-CtzPkg0O.js +1 -0
- package/public/assets/dialog-CtzPkg0O.js.br +0 -0
- package/public/assets/dialog-CtzPkg0O.js.gz +0 -0
- package/public/assets/field-hint-B774BIWK.js +1 -0
- package/public/assets/field-hint-B774BIWK.js.br +1 -0
- package/public/assets/field-hint-B774BIWK.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-EPyP3g9A.css +1 -0
- package/public/assets/index-EPyP3g9A.css.br +0 -0
- package/public/assets/index-EPyP3g9A.css.gz +0 -0
- package/public/assets/index-cgl1yEKu.js +223 -0
- package/public/assets/index-cgl1yEKu.js.br +0 -0
- package/public/assets/index-cgl1yEKu.js.gz +0 -0
- package/public/assets/list-view-BcjDCJy5.js +1 -0
- package/public/assets/list-view-BcjDCJy5.js.br +0 -0
- package/public/assets/list-view-BcjDCJy5.js.gz +0 -0
- package/public/assets/{pencil-CycAOoC_.js → pencil-1XSs32nb.js} +2 -2
- package/public/assets/pencil-1XSs32nb.js.br +0 -0
- package/public/assets/pencil-1XSs32nb.js.gz +0 -0
- package/public/assets/play-DCvrorIC.js +6 -0
- package/public/assets/play-DCvrorIC.js.br +0 -0
- package/public/assets/play-DCvrorIC.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-CFWgLcKA.js +6 -0
- package/public/assets/search-input-CFWgLcKA.js.br +0 -0
- package/public/assets/search-input-CFWgLcKA.js.gz +0 -0
- package/public/assets/select-5ZtzWRYG.js +11 -0
- package/public/assets/select-5ZtzWRYG.js.br +0 -0
- package/public/assets/select-5ZtzWRYG.js.gz +0 -0
- package/public/assets/slider-DHuMDXcP.js +1 -0
- package/public/assets/slider-DHuMDXcP.js.br +0 -0
- package/public/assets/slider-DHuMDXcP.js.gz +0 -0
- package/public/assets/tabs-CmgV_sSI.js +1 -0
- package/public/assets/tabs-CmgV_sSI.js.br +0 -0
- package/public/assets/tabs-CmgV_sSI.js.gz +0 -0
- package/public/assets/tech-label-qLodPeUI.js +1 -0
- package/public/assets/tech-label-qLodPeUI.js.br +0 -0
- package/public/assets/tech-label-qLodPeUI.js.gz +0 -0
- package/public/assets/{trash-BXQnOI5U.js → trash-CXjB9oU1.js} +3 -3
- package/public/assets/trash-CXjB9oU1.js.br +0 -0
- package/public/assets/trash-CXjB9oU1.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-CK2q3-0B.js +6 -0
- package/public/assets/video-CK2q3-0B.js.br +0 -0
- package/public/assets/video-CK2q3-0B.js.gz +0 -0
- package/public/index.html +18 -4
- package/public/index.html.br +0 -0
- package/public/index.html.gz +0 -0
- package/public/assets/AppCenter-BMdYtQbV.js +0 -26
- package/public/assets/AppCenter-BMdYtQbV.js.br +0 -0
- package/public/assets/AppCenter-BMdYtQbV.js.gz +0 -0
- package/public/assets/Default-0DHHVGaN.js +0 -1
- package/public/assets/Default-0DHHVGaN.js.br +0 -0
- package/public/assets/Default-0DHHVGaN.js.gz +0 -0
- package/public/assets/Invite-C0ZB_HiU.js +0 -1
- package/public/assets/Invite-C0ZB_HiU.js.br +0 -0
- package/public/assets/Invite-C0ZB_HiU.js.gz +0 -0
- package/public/assets/MacResources-DmY5pRV2.js +0 -1
- package/public/assets/MacResources-DmY5pRV2.js.br +0 -0
- package/public/assets/MacResources-DmY5pRV2.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-2QogWEX7.js +0 -150
- package/public/assets/QASession-2QogWEX7.js.br +0 -0
- package/public/assets/QASession-2QogWEX7.js.gz +0 -0
- package/public/assets/ResetPassword-jNRuertx.js +0 -1
- package/public/assets/ResetPassword-jNRuertx.js.br +0 -0
- package/public/assets/ResetPassword-jNRuertx.js.gz +0 -0
- package/public/assets/Setup-CF_FN9Dt.js +0 -1
- package/public/assets/Setup-CF_FN9Dt.js.br +0 -0
- package/public/assets/Setup-CF_FN9Dt.js.gz +0 -0
- package/public/assets/Team-BbiyOGau.js +0 -6
- package/public/assets/Team-BbiyOGau.js.br +0 -0
- package/public/assets/Team-BbiyOGau.js.gz +0 -0
- package/public/assets/Tokens-CZo4sPTx.js +0 -1
- package/public/assets/Tokens-CZo4sPTx.js.br +0 -0
- package/public/assets/Tokens-CZo4sPTx.js.gz +0 -0
- package/public/assets/alert-dialog-DFz8G_zu.js +0 -1
- package/public/assets/alert-dialog-DFz8G_zu.js.br +0 -0
- package/public/assets/alert-dialog-DFz8G_zu.js.gz +0 -0
- package/public/assets/badge-B3vaBVIQ.js.br +0 -0
- package/public/assets/badge-B3vaBVIQ.js.gz +0 -0
- package/public/assets/build-format-dbQPU3A4.js +0 -11
- package/public/assets/build-format-dbQPU3A4.js.br +0 -0
- package/public/assets/build-format-dbQPU3A4.js.gz +0 -0
- package/public/assets/dialog-DeBPk-I3.js +0 -11
- package/public/assets/dialog-DeBPk-I3.js.br +0 -0
- package/public/assets/dialog-DeBPk-I3.js.gz +0 -0
- package/public/assets/field-hint-CMKmbVpy.js +0 -1
- package/public/assets/field-hint-CMKmbVpy.js.br +0 -0
- package/public/assets/field-hint-CMKmbVpy.js.gz +0 -0
- package/public/assets/index-B4w4Lh3e.js +0 -218
- package/public/assets/index-B4w4Lh3e.js.br +0 -0
- package/public/assets/index-B4w4Lh3e.js.gz +0 -0
- package/public/assets/index-CDWu0GQL.css +0 -1
- package/public/assets/index-CDWu0GQL.css.br +0 -0
- package/public/assets/index-CDWu0GQL.css.gz +0 -0
- package/public/assets/list-view-BvINA5t4.js +0 -1
- package/public/assets/list-view-BvINA5t4.js.br +0 -0
- package/public/assets/list-view-BvINA5t4.js.gz +0 -0
- package/public/assets/pencil-CycAOoC_.js.br +0 -0
- package/public/assets/pencil-CycAOoC_.js.gz +0 -0
- package/public/assets/resource-health-C1qSG3MK.js +0 -1
- package/public/assets/resource-health-C1qSG3MK.js.br +0 -0
- package/public/assets/resource-health-C1qSG3MK.js.gz +0 -0
- package/public/assets/trash-BXQnOI5U.js.br +0 -0
- package/public/assets/trash-BXQnOI5U.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,12 +5,13 @@ 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 { isTunnelIngress, markTunnelIngress, resolveClientAddress, resolveRequestClient } from './lib/clientAddress.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';
|
|
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';
|
|
16
17
|
import { resolveCorsHeaders } from './lib/cors.js';
|
|
@@ -23,6 +24,7 @@ import { getDb } from './db.js';
|
|
|
23
24
|
import { handleLogin, handleLogout, handleMe, handleChangePassword, handleInit, handleAuthStatus } from './api/auth.js';
|
|
24
25
|
import { handleVerify, handleAccept } from './api/invitations.js';
|
|
25
26
|
import { createLogger, hasCapability } from '@tapflowio/agent-core';
|
|
27
|
+
import { acquireLoopStallWatch } from '@tapflowio/agent-core/utils';
|
|
26
28
|
import { createKeyframeAwareSender, createRateLimitedDropWarn, sendAudioYieldingToVideo, DEFAULT_BACKPRESSURE_BYTES, hasEnvelope, patchRelayedAt, readEnvelopeFlags, CODEC_JPEG, CODEC_AUDIO, } from '@tapflowio/agent-core/utils';
|
|
27
29
|
const logger = createLogger('relay');
|
|
28
30
|
// True when a remote IP is public — i.e. not loopback, private LAN, or link-local. Used to pick the
|
|
@@ -88,6 +90,8 @@ import { handleListTokens, handleCreateToken, handleRevokeToken } from './api/to
|
|
|
88
90
|
import { handleGetSettings, handleUpdateSettings } from './api/settings.js';
|
|
89
91
|
import { handleUpdateProfile } from './api/profile.js';
|
|
90
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';
|
|
91
95
|
import { handleListAgents, handleGetAgentResources } from './api/agents.js';
|
|
92
96
|
const MIME_TYPES = {
|
|
93
97
|
'.html': 'text/html',
|
|
@@ -209,11 +213,22 @@ export class RelayServer {
|
|
|
209
213
|
resourceBuffers = new Map();
|
|
210
214
|
logBuffer = [];
|
|
211
215
|
recordingsDir = '';
|
|
216
|
+
runScreenshotsDir = '';
|
|
217
|
+
runVideosDir = '';
|
|
212
218
|
purgeRecordingsTimer = null;
|
|
213
219
|
purgeOldResourcesTimer = null;
|
|
214
220
|
purgeBuildsTimer = null;
|
|
215
221
|
flushResourcesTimer = null;
|
|
216
222
|
heartbeatTimer = null;
|
|
223
|
+
/** When the last heartbeat sweep ran, by the same clock `isAlive` judges with. */
|
|
224
|
+
lastSweepAt = 0;
|
|
225
|
+
/** Names agents give at registration, kept apart from the session map so an agent with no sessions
|
|
226
|
+
* can still be named when its socket goes. */
|
|
227
|
+
agentNames = new WeakMap();
|
|
228
|
+
releaseStallWatch = null;
|
|
229
|
+
/** Agent sockets whose close was already explained — ended by a sweep, or replaced by the same agent
|
|
230
|
+
* registering again — so the close handler does not log them as a fresh drop. */
|
|
231
|
+
explainedCloses = new WeakMap();
|
|
217
232
|
/**
|
|
218
233
|
* When each socket last answered a ping. WeakMap → no manual cleanup on close (GC handles it).
|
|
219
234
|
*
|
|
@@ -251,6 +266,19 @@ export class RelayServer {
|
|
|
251
266
|
droppers = new Map();
|
|
252
267
|
// Per-session throttled "request an IDR from the agent" callbacks (drop recovery).
|
|
253
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();
|
|
254
282
|
/** Unlike `idrRequesters` these hold a timer, which is why the value is not a bare closure — see
|
|
255
283
|
* `forgetSessionState`. */
|
|
256
284
|
networkStateRequesters = new Map();
|
|
@@ -297,6 +325,7 @@ export class RelayServer {
|
|
|
297
325
|
corsAllowed;
|
|
298
326
|
// One-shot warning when XFF arrives on a loopback socket but TAPFLOW_TRUSTED_PROXIES is unset.
|
|
299
327
|
warnedProxyMisconfig = false;
|
|
328
|
+
warnedTrustedProxyWithoutXff = false;
|
|
300
329
|
/** Agent identities already warned about a missing `build-download`. See `warnLegacyInstaller`. */
|
|
301
330
|
warnedLegacyInstaller = new Set();
|
|
302
331
|
buildTickets = new BuildTicketStore();
|
|
@@ -367,7 +396,16 @@ export class RelayServer {
|
|
|
367
396
|
if (options.agentGraceMs === undefined && graceRaw && !graceUsable) {
|
|
368
397
|
logger.warn(`TAPFLOW_AGENT_GRACE_MS="${graceRaw}" is not a usable number of milliseconds — using ${DEFAULT_AGENT_GRACE_MS}`);
|
|
369
398
|
}
|
|
370
|
-
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
|
+
});
|
|
371
409
|
this.publicDir = options.publicDir ?? path.join(import.meta.dirname, '../public');
|
|
372
410
|
this.uploadsDir = options.uploadsDir ?? path.join(import.meta.dirname, '../uploads');
|
|
373
411
|
this.router = new Router();
|
|
@@ -420,7 +458,7 @@ export class RelayServer {
|
|
|
420
458
|
this.router.get('/api/v1/apps', handleListApps);
|
|
421
459
|
this.router.post('/api/v1/apps', handleCreateApp);
|
|
422
460
|
this.router.patch('/api/v1/apps/:id', handleUpdateApp);
|
|
423
|
-
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()));
|
|
424
462
|
// builds
|
|
425
463
|
this.router.get('/api/v1/builds', handleListBuilds);
|
|
426
464
|
this.router.get('/api/v1/builds/:id', handleGetBuild);
|
|
@@ -454,7 +492,7 @@ export class RelayServer {
|
|
|
454
492
|
this.router.post('/api/v1/tokens', handleCreateToken);
|
|
455
493
|
this.router.delete('/api/v1/tokens/:id', (req, res, params) => handleRevokeToken(req, res, params, this.onAuthChanged));
|
|
456
494
|
// settings
|
|
457
|
-
this.router.get('/api/v1/settings', handleGetSettings);
|
|
495
|
+
this.router.get('/api/v1/settings', (req, res) => handleGetSettings(req, res, this.runVideosDir));
|
|
458
496
|
this.router.patch('/api/v1/settings', (req, res) => handleUpdateSettings(req, res, u));
|
|
459
497
|
this.router.patch('/api/v1/profile', (req, res) => handleUpdateProfile(req, res, u));
|
|
460
498
|
// recordings
|
|
@@ -462,6 +500,22 @@ export class RelayServer {
|
|
|
462
500
|
this.router.post('/api/v1/recordings/upload', (req, res) => handleUploadRecording(req, res, this.recordingsDir));
|
|
463
501
|
this.router.get('/api/v1/recordings', (req, res) => handleListRecordings(req, res));
|
|
464
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));
|
|
465
519
|
// logs — the relay host only. The buffer holds the addresses of refused connections, nothing
|
|
466
520
|
// remote reads it (`tapflow logs` is its only consumer), and every line is also in the relay's own
|
|
467
521
|
// output. Same locality rule and proxy list as `auth/init` and the WebSocket handshake, so the tunnel
|
|
@@ -521,13 +575,31 @@ export class RelayServer {
|
|
|
521
575
|
purgeOldResources();
|
|
522
576
|
this.purgeOldResourcesTimer = setInterval(purgeOldResources, 24 * 60 * 60 * 1000);
|
|
523
577
|
this.purgeOldResourcesTimer.unref();
|
|
524
|
-
|
|
525
|
-
|
|
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);
|
|
526
589
|
this.purgeBuildsTimer.unref();
|
|
527
590
|
this.flushResourcesTimer = setInterval(() => this.flushResourceBuffers(), 60_000);
|
|
528
591
|
this.flushResourcesTimer.unref();
|
|
529
|
-
this.
|
|
592
|
+
this.lastSweepAt = Date.now();
|
|
593
|
+
this.heartbeatTimer = setInterval(() => { this.noteLateSweep(); this.runHeartbeat(); }, HEARTBEAT_MS);
|
|
530
594
|
this.heartbeatTimer.unref();
|
|
595
|
+
this.releaseStallWatch ??= acquireLoopStallWatch();
|
|
596
|
+
// The rule a proxy has to follow, said once to whoever set the list: the case it covers (a proxy that
|
|
597
|
+
// sends no forwarding header at all) is one no request-time check can see.
|
|
598
|
+
const trustedProxies = this.options.trustedProxies ?? [];
|
|
599
|
+
if (trustedProxies.length > 0) {
|
|
600
|
+
logger.info(`Trusted proxies: ${trustedProxies.join(', ')}. A request from one of these without ` +
|
|
601
|
+
'X-Forwarded-For is treated as coming from that address itself, so the proxy must send X-Forwarded-For.');
|
|
602
|
+
}
|
|
531
603
|
return new Promise((resolve, reject) => {
|
|
532
604
|
this.httpServer.once('error', (err) => {
|
|
533
605
|
if (err.code === 'EADDRINUSE') {
|
|
@@ -586,6 +658,8 @@ export class RelayServer {
|
|
|
586
658
|
clearInterval(this.heartbeatTimer);
|
|
587
659
|
this.heartbeatTimer = null;
|
|
588
660
|
}
|
|
661
|
+
this.releaseStallWatch?.();
|
|
662
|
+
this.releaseStallWatch = null;
|
|
589
663
|
// `unref()` would not do: the test runner's process outlives the server, so an un-cleared hold
|
|
590
664
|
// still fires — against a server that has already stopped.
|
|
591
665
|
this.stopping = true;
|
|
@@ -664,14 +738,42 @@ export class RelayServer {
|
|
|
664
738
|
// interval, whatever path the change took. `onAuthChanged` is the same check run early.
|
|
665
739
|
runHeartbeat(clients = this.wss.clients) {
|
|
666
740
|
this.revalidateSockets(clients);
|
|
741
|
+
// Agents one line each; everything else summed, because a relay waking from sleep finds every socket
|
|
742
|
+
// stale at once and one line per browser tab would push the agent lines out of the log buffer.
|
|
743
|
+
const others = new Map();
|
|
667
744
|
for (const ws of clients) {
|
|
668
745
|
if (!this.isAlive(ws)) {
|
|
746
|
+
const role = this.wsRoles.get(ws);
|
|
747
|
+
if (role === 'agent') {
|
|
748
|
+
this.pushLog(`heartbeat terminated agent ${this.agentNames.get(ws) ?? 'unknown'} — last pong ${this.pongAgeSeconds(ws)}s ago`);
|
|
749
|
+
this.explainedCloses.set(ws, 'terminated');
|
|
750
|
+
}
|
|
751
|
+
else {
|
|
752
|
+
const key = role ?? 'unregistered';
|
|
753
|
+
others.set(key, (others.get(key) ?? 0) + 1);
|
|
754
|
+
}
|
|
669
755
|
ws.terminate();
|
|
670
756
|
continue;
|
|
671
757
|
}
|
|
672
758
|
if (ws.readyState === WebSocket.OPEN)
|
|
673
759
|
ws.ping();
|
|
674
760
|
}
|
|
761
|
+
if (others.size > 0) {
|
|
762
|
+
this.pushLog(`heartbeat terminated ${[...others].map(([role, n]) => `${n} ${role}`).join(', ')} socket(s)`);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
/** A sweep that runs late means this process was held, or the machine slept: the pongs it judges were
|
|
766
|
+
* never read, so its terminations say nothing about the clients. Same clock as `isAlive`. */
|
|
767
|
+
noteLateSweep(now = Date.now()) {
|
|
768
|
+
const late = now - this.lastSweepAt - HEARTBEAT_MS;
|
|
769
|
+
if (late > HEARTBEAT_MS / 2) {
|
|
770
|
+
this.pushLog(`heartbeat sweep ran ${Math.round(late / 1000)}s late — the relay was stalled or the system slept; this sweep may end sockets that were alive`);
|
|
771
|
+
}
|
|
772
|
+
this.lastSweepAt = now;
|
|
773
|
+
}
|
|
774
|
+
pongAgeSeconds(ws) {
|
|
775
|
+
const at = this.lastPongAt.get(ws);
|
|
776
|
+
return at === undefined ? '?' : Math.round((Date.now() - at) / 1000);
|
|
675
777
|
}
|
|
676
778
|
/**
|
|
677
779
|
* Injected into the handlers whose write can take access away — member update and removal, token
|
|
@@ -767,6 +869,32 @@ export class RelayServer {
|
|
|
767
869
|
* negative beats 30 with one every cycle**, because a false negative here does not merely delay: it
|
|
768
870
|
* hands a live tester's session to whoever asked next.
|
|
769
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
|
+
};
|
|
770
898
|
isAlive(ws) {
|
|
771
899
|
const at = this.lastPongAt.get(ws);
|
|
772
900
|
// **No entry means just connected, not dead.** `handleConnection` seeds one, but a socket is in
|
|
@@ -809,6 +937,7 @@ export class RelayServer {
|
|
|
809
937
|
*/
|
|
810
938
|
forgetSessionState(sessionId) {
|
|
811
939
|
this.dropHandlers.delete(sessionId);
|
|
940
|
+
this.dropHandlers.delete(`${sessionId} watchers`);
|
|
812
941
|
this.audioDropHandlers.delete(sessionId);
|
|
813
942
|
this.droppers.delete(sessionId);
|
|
814
943
|
this.idrRequesters.delete(sessionId);
|
|
@@ -897,6 +1026,24 @@ export class RelayServer {
|
|
|
897
1026
|
remoteAddressOf(request) {
|
|
898
1027
|
return request.socket.remoteAddress ?? '';
|
|
899
1028
|
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Called on both doors — the WebSocket upgrade and every HTTP request — since `auth/init` and the logs
|
|
1031
|
+
* route decide locality on the HTTP side. Not on the tunnel listener, where every request is remote
|
|
1032
|
+
* whatever it carries.
|
|
1033
|
+
*/
|
|
1034
|
+
warnTrustedProxyWithoutXffOnce(request) {
|
|
1035
|
+
if (this.warnedTrustedProxyWithoutXff || isTunnelIngress(request))
|
|
1036
|
+
return;
|
|
1037
|
+
const header = proxiedWithoutForwardedFor(this.remoteAddressOf(request), request.headers, this.options.trustedProxies ?? []);
|
|
1038
|
+
if (!header)
|
|
1039
|
+
return;
|
|
1040
|
+
logger.warn(`A request from a trusted proxy (TAPFLOW_TRUSTED_PROXIES) carried ${header} but no X-Forwarded-For. ` +
|
|
1041
|
+
'Without that header the relay cannot see the real client, so it treats the request as coming from the ' +
|
|
1042
|
+
'proxy itself: local, and so unauthenticated, when that address is loopback. Configure the proxy to ' +
|
|
1043
|
+
'send X-Forwarded-For. A proxy on this host can instead use the tunnel port, which is open only when a ' +
|
|
1044
|
+
'tunnel is configured or TAPFLOW_TUNNEL_PORT names a port.');
|
|
1045
|
+
this.warnedTrustedProxyWithoutXff = true;
|
|
1046
|
+
}
|
|
900
1047
|
warnProxyMisconfigOnce(socketAddr, forwardedFor) {
|
|
901
1048
|
if (this.warnedProxyMisconfig)
|
|
902
1049
|
return;
|
|
@@ -906,12 +1053,19 @@ export class RelayServer {
|
|
|
906
1053
|
if (a === '::1' || a.startsWith('127.')) {
|
|
907
1054
|
logger.warn('Received X-Forwarded-For from a loopback connection but TAPFLOW_TRUSTED_PROXIES is unset. ' +
|
|
908
1055
|
'If a same-host reverse proxy or `tailscale serve` forwards to this port, every client it forwards is ' +
|
|
909
|
-
'treated as localhost (unauthenticated). Point it at the tunnel port instead (
|
|
910
|
-
'
|
|
1056
|
+
'treated as localhost (unauthenticated). Point it at the tunnel port instead (open only when a tunnel ' +
|
|
1057
|
+
'is configured or TAPFLOW_TUNNEL_PORT names a port), or set TAPFLOW_TRUSTED_PROXIES so the real ' +
|
|
1058
|
+
'client IP is used.');
|
|
911
1059
|
this.warnedProxyMisconfig = true;
|
|
912
1060
|
}
|
|
913
1061
|
}
|
|
914
1062
|
handleConnection(ws, request) {
|
|
1063
|
+
// **First, before anything can return.** A frame that breaks the protocol makes `ws` emit `'error'` on
|
|
1064
|
+
// this socket (a reset it handles itself), and an EventEmitter with no `'error'` listener throws: from
|
|
1065
|
+
// inside `ws`'s receiver, with nothing above it to catch. One client could end the relay for everyone,
|
|
1066
|
+
// signed in or not. `ws` closes the socket itself after emitting, so the `'close'` handler below still runs.
|
|
1067
|
+
// Debug, not warn: any client can produce this at will, and the close is already logged where it matters.
|
|
1068
|
+
ws.on('error', (err) => logger.debug(`WebSocket error: ${err.message}`));
|
|
915
1069
|
const socketAddr = this.remoteAddressOf(request);
|
|
916
1070
|
const xff = request.headers['x-forwarded-for'];
|
|
917
1071
|
const forwardedFor = Array.isArray(xff) ? xff[0] : xff;
|
|
@@ -919,6 +1073,7 @@ export class RelayServer {
|
|
|
919
1073
|
// A proxy in front of the tunnel port adds the header too, and there it changes nothing about auth.
|
|
920
1074
|
if (!viaTunnel)
|
|
921
1075
|
this.warnProxyMisconfigOnce(socketAddr, forwardedFor);
|
|
1076
|
+
this.warnTrustedProxyWithoutXffOnce(request);
|
|
922
1077
|
const { addr, isLocal } = resolveClientAddress({
|
|
923
1078
|
socketAddr,
|
|
924
1079
|
forwardedFor,
|
|
@@ -952,6 +1107,10 @@ export class RelayServer {
|
|
|
952
1107
|
if (pat)
|
|
953
1108
|
touchPat(pat.patId);
|
|
954
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)));
|
|
955
1114
|
if (!isLocal) {
|
|
956
1115
|
if (cookie)
|
|
957
1116
|
this.principals.set(ws, { via: 'cookie', userId: cookie.userId, jwtExp: cookie.exp, pwv: cookie.pwv });
|
|
@@ -994,43 +1153,73 @@ export class RelayServer {
|
|
|
994
1153
|
if (this.revokedSockets.has(ws))
|
|
995
1154
|
return;
|
|
996
1155
|
if (isBinary) {
|
|
997
|
-
// 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.
|
|
998
1157
|
const session = this.sessions.getByStreamSocket(ws);
|
|
999
|
-
if (session
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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)
|
|
1013
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);
|
|
1014
1184
|
}
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
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) {
|
|
1020
1203
|
let dropper = this.droppers.get(session.id);
|
|
1021
1204
|
if (!dropper) {
|
|
1022
1205
|
dropper = createKeyframeAwareSender();
|
|
1023
1206
|
this.droppers.set(session.id, dropper);
|
|
1024
1207
|
}
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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);
|
|
1032
1222
|
}
|
|
1033
|
-
dropper.send(session.browserSocket, frameBuf, this.backpressureBytes, isKeyframe, onDrop, requestIdr);
|
|
1034
1223
|
}
|
|
1035
1224
|
return;
|
|
1036
1225
|
}
|
|
@@ -1085,7 +1274,19 @@ export class RelayServer {
|
|
|
1085
1274
|
logger.error(`route failed for ${inbound.msg.type}:`, e);
|
|
1086
1275
|
}
|
|
1087
1276
|
});
|
|
1088
|
-
ws.on('close', () => {
|
|
1277
|
+
ws.on('close', (code, reason) => {
|
|
1278
|
+
// Not while stopping: the relay ending every socket itself says nothing about the agents.
|
|
1279
|
+
if (this.wsRoles.get(ws) === 'agent' && !this.stopping) {
|
|
1280
|
+
const name = this.agentNames.get(ws) ?? 'unknown';
|
|
1281
|
+
const explained = this.explainedCloses.get(ws);
|
|
1282
|
+
if (explained === 'replaced') {
|
|
1283
|
+
this.pushLog(`agent socket replaced: ${name} registered again on a new connection`);
|
|
1284
|
+
}
|
|
1285
|
+
else if (!explained) {
|
|
1286
|
+
const why = reason.length > 0 ? ` ${reason.toString()}` : '';
|
|
1287
|
+
this.pushLog(`agent socket closed: ${name} (code ${code}${why}, last pong ${this.pongAgeSeconds(ws)}s ago)`);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1089
1290
|
this.wsRoles.delete(ws);
|
|
1090
1291
|
this.wsExternal.delete(ws);
|
|
1091
1292
|
this.forgetShutdownRequesters(ws);
|
|
@@ -1102,6 +1303,9 @@ export class RelayServer {
|
|
|
1102
1303
|
const streamSession = this.sessions.getByStreamSocket(ws);
|
|
1103
1304
|
if (streamSession)
|
|
1104
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);
|
|
1105
1309
|
// Browser socket disconnected → release **every** session it held, each with its own idle timer.
|
|
1106
1310
|
//
|
|
1107
1311
|
// `getByBrowserSocket` used to answer with one session because the index held one, and a socket
|
|
@@ -1116,6 +1320,20 @@ export class RelayServer {
|
|
|
1116
1320
|
return;
|
|
1117
1321
|
for (const held of this.sessions.getByBrowserSocket(ws)) {
|
|
1118
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);
|
|
1119
1337
|
}
|
|
1120
1338
|
});
|
|
1121
1339
|
}
|
|
@@ -1284,6 +1502,26 @@ export class RelayServer {
|
|
|
1284
1502
|
case 'session:start':
|
|
1285
1503
|
this.handleSessionStart(ws, msg);
|
|
1286
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
|
+
}
|
|
1287
1525
|
// ── the two session commands, gated but not answered ───────────────────────────────────────
|
|
1288
1526
|
//
|
|
1289
1527
|
// Both destroy state a viewer depends on, and until L5c both acted on the strength of the session
|
|
@@ -1317,6 +1555,10 @@ export class RelayServer {
|
|
|
1317
1555
|
if (this.ownsSession(ws, this.sessions.get(msg.sessionId))) {
|
|
1318
1556
|
this.sessions.clearBrowser(msg.sessionId);
|
|
1319
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');
|
|
1320
1562
|
}
|
|
1321
1563
|
break;
|
|
1322
1564
|
}
|
|
@@ -1352,6 +1594,7 @@ export class RelayServer {
|
|
|
1352
1594
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
1353
1595
|
session.browserSocket.send(JSON.stringify(raw));
|
|
1354
1596
|
}
|
|
1597
|
+
this.forwardToWatchers(session.id, raw);
|
|
1355
1598
|
break;
|
|
1356
1599
|
}
|
|
1357
1600
|
case 'device:postures': {
|
|
@@ -1366,6 +1609,7 @@ export class RelayServer {
|
|
|
1366
1609
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
1367
1610
|
session.browserSocket.send(JSON.stringify(raw));
|
|
1368
1611
|
}
|
|
1612
|
+
this.forwardToWatchers(session.id, raw);
|
|
1369
1613
|
break;
|
|
1370
1614
|
}
|
|
1371
1615
|
case 'session:deviceInfo': {
|
|
@@ -1377,6 +1621,7 @@ export class RelayServer {
|
|
|
1377
1621
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
1378
1622
|
session.browserSocket.send(JSON.stringify(raw));
|
|
1379
1623
|
}
|
|
1624
|
+
this.forwardToWatchers(session.id, raw);
|
|
1380
1625
|
break;
|
|
1381
1626
|
}
|
|
1382
1627
|
case 'device:booting': {
|
|
@@ -1388,6 +1633,7 @@ export class RelayServer {
|
|
|
1388
1633
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
1389
1634
|
session.browserSocket.send(JSON.stringify(raw));
|
|
1390
1635
|
}
|
|
1636
|
+
this.forwardToWatchers(session.id, raw);
|
|
1391
1637
|
break;
|
|
1392
1638
|
}
|
|
1393
1639
|
case 'device:boot-error': {
|
|
@@ -1441,6 +1687,7 @@ export class RelayServer {
|
|
|
1441
1687
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
1442
1688
|
session.browserSocket.send(JSON.stringify(raw));
|
|
1443
1689
|
}
|
|
1690
|
+
this.forwardToWatchers(session.id, raw);
|
|
1444
1691
|
break;
|
|
1445
1692
|
}
|
|
1446
1693
|
case 'app:install-done':
|
|
@@ -1525,6 +1772,7 @@ export class RelayServer {
|
|
|
1525
1772
|
// presence check the old line carried is gone because the schema requires the payload — and
|
|
1526
1773
|
// `external` is deliberately not declared on `DeviceBoot`, since the browser never sends it.
|
|
1527
1774
|
msg.payload['external'] = this.wsExternal.get(ws) ?? false;
|
|
1775
|
+
msg.payload['automation'] = isAiClientKind(boot.session.holderKind);
|
|
1528
1776
|
// Tracked by the socket it actually goes to (#885): a rebind invalidates only the boots
|
|
1529
1777
|
// tied to the replaced socket, so a boot dispatched after the rebind stays answerable.
|
|
1530
1778
|
this.rememberBoot(msg.sessionId, msg.requestId, boot.session.agentSocket);
|
|
@@ -1547,6 +1795,18 @@ export class RelayServer {
|
|
|
1547
1795
|
// command the relay never answers. `mcp-server`'s `shutdownDevice` waits 30s on `device:shutdown-done`
|
|
1548
1796
|
// and then reports `Request timed out` with no cause, which is the silence, not a diagnosis.
|
|
1549
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
|
+
}
|
|
1550
1810
|
if (session && !this.mayShutDown(ws, session)) {
|
|
1551
1811
|
this.sendTo(ws, {
|
|
1552
1812
|
type: 'device:shutdown-error',
|
|
@@ -1605,6 +1865,8 @@ export class RelayServer {
|
|
|
1605
1865
|
const target = this.dispatchTarget(ws, msg.sessionId);
|
|
1606
1866
|
if (target.ok) {
|
|
1607
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' });
|
|
1608
1870
|
}
|
|
1609
1871
|
else {
|
|
1610
1872
|
this.sendTo(ws, {
|
|
@@ -1622,6 +1884,7 @@ export class RelayServer {
|
|
|
1622
1884
|
const target = this.dispatchTarget(ws, msg.sessionId);
|
|
1623
1885
|
if (target.ok) {
|
|
1624
1886
|
target.session.agentSocket.send(JSON.stringify(msg));
|
|
1887
|
+
this.tellWatchersAction(msg.sessionId, { kind: 'clear-state' });
|
|
1625
1888
|
}
|
|
1626
1889
|
else {
|
|
1627
1890
|
this.sendTo(ws, {
|
|
@@ -1920,6 +2183,7 @@ export class RelayServer {
|
|
|
1920
2183
|
for (const s of sessions) {
|
|
1921
2184
|
if (s.browserSocket)
|
|
1922
2185
|
this.sendTo(s.browserSocket, { type: 'session:agent-away', sessionId: s.id });
|
|
2186
|
+
this.tellWatchers(s.id, { type: 'session:agent-away', sessionId: s.id });
|
|
1923
2187
|
}
|
|
1924
2188
|
const timer = setTimeout(() => {
|
|
1925
2189
|
this.agentHolds.delete(ws);
|
|
@@ -2040,6 +2304,7 @@ export class RelayServer {
|
|
|
2040
2304
|
// gone and its in-flight screenshots would be undiscoverable — reject them here instead.
|
|
2041
2305
|
// The rebound sessions have already moved off `old`, so this no longer covers them.
|
|
2042
2306
|
this.evictAgentSocket(old, 'replaced');
|
|
2307
|
+
this.explainedCloses.set(old, 'replaced');
|
|
2043
2308
|
old.terminate();
|
|
2044
2309
|
}
|
|
2045
2310
|
}
|
|
@@ -2084,6 +2349,7 @@ export class RelayServer {
|
|
|
2084
2349
|
if (s?.browserSocket) {
|
|
2085
2350
|
this.sendTo(s.browserSocket, { type: 'session:rebound', sessionId, capabilities: msg.capabilities ?? [] });
|
|
2086
2351
|
}
|
|
2352
|
+
this.tellWatchers(sessionId, { type: 'session:rebound', sessionId, capabilities: msg.capabilities ?? [] });
|
|
2087
2353
|
}
|
|
2088
2354
|
// After the rebound frames, so the client records `needsReboot` before the error below
|
|
2089
2355
|
// arrives and classifies it with the cause attached. Only boots tied to the replaced
|
|
@@ -2097,7 +2363,9 @@ export class RelayServer {
|
|
|
2097
2363
|
// transition, matching the disconnect line in evictAgentSocket.
|
|
2098
2364
|
// `||`, not `??`: the schema defaults both of these to `''` for an agent that omits them, so `??`
|
|
2099
2365
|
// would print an empty name and an empty platform where this used to print `unknown`.
|
|
2100
|
-
|
|
2366
|
+
this.agentNames.set(ws, msg.agentName || msg.agentId || 'unknown');
|
|
2367
|
+
// Through `pushLog` too, so `tapflow logs` shows an agent coming back after the line about it leaving.
|
|
2368
|
+
this.pushLog(`agent connected: ${msg.agentName || msg.agentId || 'unknown'} (${msg.platform || 'unknown'}) — ${registeredSessions.length} device(s)`);
|
|
2101
2369
|
}
|
|
2102
2370
|
/** The only producer of `error` — all five exits below, and nothing else in the repo sends that message.
|
|
2103
2371
|
*
|
|
@@ -2112,6 +2380,17 @@ export class RelayServer {
|
|
|
2112
2380
|
this.sendTo(ws, { type: 'error', sessionId: msg.sessionId, message: 'Session not found', reason: 'session-not-found' });
|
|
2113
2381
|
return;
|
|
2114
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
|
+
}
|
|
2115
2394
|
// Occupancy first, because it is the more specific answer and both can be true at once. A tester
|
|
2116
2395
|
// joining a device someone else has open on a loaded Mac was being told "this Mac is overloaded,
|
|
2117
2396
|
// pick another" — advice for a problem they cannot act on, while the actual reason went unreported.
|
|
@@ -2143,7 +2422,9 @@ export class RelayServer {
|
|
|
2143
2422
|
}
|
|
2144
2423
|
}
|
|
2145
2424
|
try {
|
|
2146
|
-
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 } : {}) });
|
|
2147
2428
|
if (!joined.ok) {
|
|
2148
2429
|
// Both of these are answered above, so arriving here means the state moved between that check and
|
|
2149
2430
|
// this call. They are **values now rather than throws** (#515), and that is what fixes the defect:
|
|
@@ -2174,6 +2455,7 @@ export class RelayServer {
|
|
|
2174
2455
|
// deliberately instead of inferring anything from a timeout.
|
|
2175
2456
|
this.sendTo(ws, {
|
|
2176
2457
|
type: 'session:joined', sessionId: msg.sessionId, capabilities: session.agentCapabilities ?? [],
|
|
2458
|
+
...(isAiClientKind(msg.clientKind) ? this.watchUrlField(msg.sessionId) : {}),
|
|
2177
2459
|
});
|
|
2178
2460
|
if (agentAway) {
|
|
2179
2461
|
// Joining into a held session. Refusing instead would be worse than it sounds: the viewer
|
|
@@ -2218,7 +2500,10 @@ export class RelayServer {
|
|
|
2218
2500
|
// the P-frames around it, so the amplification lands on the tester who is actually watching. The
|
|
2219
2501
|
// backpressure path already throttles this exact message per session; sharing that requester is
|
|
2220
2502
|
// what keeps the two callers from having two policies.
|
|
2221
|
-
|
|
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)();
|
|
2222
2507
|
// And ask what the device's network is doing (#614). `NetworkState` is agent-produced and is not
|
|
2223
2508
|
// among the three replayed above, so without this a viewer that reconnects has no way to learn
|
|
2224
2509
|
// whether the device is offline and its control renders in a guessed position.
|
|
@@ -2242,6 +2527,150 @@ export class RelayServer {
|
|
|
2242
2527
|
this.networkStateRequester(session.id)();
|
|
2243
2528
|
}
|
|
2244
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
|
+
}
|
|
2245
2674
|
/** Shut the device down because nobody is watching it any more. The idle timer's payload, hoisted out
|
|
2246
2675
|
* of the close handler so the release loop there reads as one line per session. */
|
|
2247
2676
|
idleShutdown(sessionId) {
|
|
@@ -2391,6 +2820,12 @@ export class RelayServer {
|
|
|
2391
2820
|
}
|
|
2392
2821
|
if (session?.agentSocket.readyState === WebSocket.OPEN) {
|
|
2393
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;
|
|
2394
2829
|
}
|
|
2395
2830
|
}
|
|
2396
2831
|
/** The five inputs an ack answers, so the five that carry a required correlator.
|
|
@@ -2418,6 +2853,7 @@ export class RelayServer {
|
|
|
2418
2853
|
}
|
|
2419
2854
|
if (session?.agentSocket.readyState === WebSocket.OPEN) {
|
|
2420
2855
|
session.agentSocket.send(JSON.stringify(msg));
|
|
2856
|
+
this.tellWatchersInput(session, msg);
|
|
2421
2857
|
return;
|
|
2422
2858
|
}
|
|
2423
2859
|
// A request that cannot be dispatched. Two situations reach here and only one is the agent's fault:
|
|
@@ -2585,6 +3021,7 @@ export class RelayServer {
|
|
|
2585
3021
|
buildBytes: bytes,
|
|
2586
3022
|
},
|
|
2587
3023
|
});
|
|
3024
|
+
this.tellWatchersAction(sessionId, { kind: 'install' });
|
|
2588
3025
|
}
|
|
2589
3026
|
/**
|
|
2590
3027
|
* Says once, per agent, that this agent cannot fetch builds over HTTP.
|
|
@@ -2641,6 +3078,7 @@ export class RelayServer {
|
|
|
2641
3078
|
requestId,
|
|
2642
3079
|
payload: { bundleId: build.bundle_id },
|
|
2643
3080
|
});
|
|
3081
|
+
this.tellWatchersAction(sessionId, { kind: 'launch' });
|
|
2644
3082
|
}
|
|
2645
3083
|
async handleGetScreenshot(req, res, params) {
|
|
2646
3084
|
if (!requireViewAuth(req, res))
|
|
@@ -2791,6 +3229,7 @@ export class RelayServer {
|
|
|
2791
3229
|
this.resourceBuffers.clear();
|
|
2792
3230
|
}
|
|
2793
3231
|
async handleRequest(req, res) {
|
|
3232
|
+
this.warnTrustedProxyWithoutXffOnce(req);
|
|
2794
3233
|
const corsHeaders = resolveCorsHeaders(req.headers.origin, this.corsAllowed);
|
|
2795
3234
|
if (corsHeaders) {
|
|
2796
3235
|
for (const [k, v] of Object.entries(corsHeaders))
|