@tapflowio/relay 0.1.0-alpha.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.
Files changed (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +30 -0
  3. package/dist/RelayServer.d.ts +37 -0
  4. package/dist/RelayServer.d.ts.map +1 -0
  5. package/dist/RelayServer.js +531 -0
  6. package/dist/RelayServer.js.map +1 -0
  7. package/dist/SessionManager.d.ts +62 -0
  8. package/dist/SessionManager.d.ts.map +1 -0
  9. package/dist/SessionManager.js +174 -0
  10. package/dist/SessionManager.js.map +1 -0
  11. package/dist/api/agents.d.ts +4 -0
  12. package/dist/api/agents.d.ts.map +1 -0
  13. package/dist/api/agents.js +36 -0
  14. package/dist/api/agents.js.map +1 -0
  15. package/dist/api/apps.d.ts +6 -0
  16. package/dist/api/apps.d.ts.map +1 -0
  17. package/dist/api/apps.js +76 -0
  18. package/dist/api/apps.js.map +1 -0
  19. package/dist/api/auth.d.ts +9 -0
  20. package/dist/api/auth.d.ts.map +1 -0
  21. package/dist/api/auth.js +81 -0
  22. package/dist/api/auth.js.map +1 -0
  23. package/dist/api/builds.d.ts +14 -0
  24. package/dist/api/builds.d.ts.map +1 -0
  25. package/dist/api/builds.js +368 -0
  26. package/dist/api/builds.js.map +1 -0
  27. package/dist/api/comments.d.ts +5 -0
  28. package/dist/api/comments.d.ts.map +1 -0
  29. package/dist/api/comments.js +114 -0
  30. package/dist/api/comments.js.map +1 -0
  31. package/dist/api/invitations.d.ts +4 -0
  32. package/dist/api/invitations.d.ts.map +1 -0
  33. package/dist/api/invitations.js +95 -0
  34. package/dist/api/invitations.js.map +1 -0
  35. package/dist/api/passwordReset.d.ts +6 -0
  36. package/dist/api/passwordReset.d.ts.map +1 -0
  37. package/dist/api/passwordReset.js +64 -0
  38. package/dist/api/passwordReset.js.map +1 -0
  39. package/dist/api/profile.d.ts +3 -0
  40. package/dist/api/profile.d.ts.map +1 -0
  41. package/dist/api/profile.js +56 -0
  42. package/dist/api/profile.js.map +1 -0
  43. package/dist/api/recordings.d.ts +6 -0
  44. package/dist/api/recordings.d.ts.map +1 -0
  45. package/dist/api/recordings.js +167 -0
  46. package/dist/api/recordings.js.map +1 -0
  47. package/dist/api/settings.d.ts +4 -0
  48. package/dist/api/settings.d.ts.map +1 -0
  49. package/dist/api/settings.js +65 -0
  50. package/dist/api/settings.js.map +1 -0
  51. package/dist/api/team.d.ts +6 -0
  52. package/dist/api/team.d.ts.map +1 -0
  53. package/dist/api/team.js +65 -0
  54. package/dist/api/team.js.map +1 -0
  55. package/dist/api/tokens.d.ts +5 -0
  56. package/dist/api/tokens.d.ts.map +1 -0
  57. package/dist/api/tokens.js +39 -0
  58. package/dist/api/tokens.js.map +1 -0
  59. package/dist/db.d.ts +5 -0
  60. package/dist/db.d.ts.map +1 -0
  61. package/dist/db.js +43 -0
  62. package/dist/db.js.map +1 -0
  63. package/dist/index.d.ts +6 -0
  64. package/dist/index.d.ts.map +1 -0
  65. package/dist/index.js +4 -0
  66. package/dist/index.js.map +1 -0
  67. package/dist/lib/config.d.ts +34 -0
  68. package/dist/lib/config.d.ts.map +1 -0
  69. package/dist/lib/config.js +97 -0
  70. package/dist/lib/config.js.map +1 -0
  71. package/dist/lib/mailer.d.ts +2 -0
  72. package/dist/lib/mailer.d.ts.map +1 -0
  73. package/dist/lib/mailer.js +30 -0
  74. package/dist/lib/mailer.js.map +1 -0
  75. package/dist/middleware/auth.d.ts +17 -0
  76. package/dist/middleware/auth.d.ts.map +1 -0
  77. package/dist/middleware/auth.js +65 -0
  78. package/dist/middleware/auth.js.map +1 -0
  79. package/dist/migrations/001_initial.sql +55 -0
  80. package/dist/migrations/002_dashboard_foundation.sql +54 -0
  81. package/dist/migrations/003_recordings.sql +14 -0
  82. package/dist/migrations/004_app_build_split.sql +88 -0
  83. package/dist/migrations/005_user_avatar.sql +1 -0
  84. package/dist/migrations/006_password_reset.sql +7 -0
  85. package/dist/migrations/007_app_platform_model.sql +34 -0
  86. package/dist/migrations/008_recordings_build_id.sql +4 -0
  87. package/dist/migrations/009_agent_resources.sql +12 -0
  88. package/dist/router.d.ts +16 -0
  89. package/dist/router.d.ts.map +1 -0
  90. package/dist/router.js +53 -0
  91. package/dist/router.js.map +1 -0
  92. package/dist/server.d.ts +2 -0
  93. package/dist/server.d.ts.map +1 -0
  94. package/dist/server.js +17 -0
  95. package/dist/server.js.map +1 -0
  96. package/dist/types.d.ts +41 -0
  97. package/dist/types.d.ts.map +1 -0
  98. package/dist/types.js +2 -0
  99. package/dist/types.js.map +1 -0
  100. package/package.json +72 -0
  101. package/public/assets/index-CIXHMEdP.css +1 -0
  102. package/public/assets/index-CzYNt1iH.js +499 -0
  103. package/public/assets/inter-cyrillic-400-normal-HOLc17fK.woff +0 -0
  104. package/public/assets/inter-cyrillic-400-normal-obahsSVq.woff2 +0 -0
  105. package/public/assets/inter-cyrillic-500-normal-BasfLYem.woff2 +0 -0
  106. package/public/assets/inter-cyrillic-500-normal-CxZf_p3X.woff +0 -0
  107. package/public/assets/inter-cyrillic-600-normal-4D_pXhcN.woff +0 -0
  108. package/public/assets/inter-cyrillic-600-normal-CWCymEST.woff2 +0 -0
  109. package/public/assets/inter-cyrillic-ext-400-normal-BQZuk6qB.woff2 +0 -0
  110. package/public/assets/inter-cyrillic-ext-400-normal-DQukG94-.woff +0 -0
  111. package/public/assets/inter-cyrillic-ext-500-normal-B0yAr1jD.woff2 +0 -0
  112. package/public/assets/inter-cyrillic-ext-500-normal-BmqWE9Dz.woff +0 -0
  113. package/public/assets/inter-cyrillic-ext-600-normal-Bcila6Z-.woff +0 -0
  114. package/public/assets/inter-cyrillic-ext-600-normal-Dfes3d0z.woff2 +0 -0
  115. package/public/assets/inter-greek-400-normal-B4URO6DV.woff2 +0 -0
  116. package/public/assets/inter-greek-400-normal-q2sYcFCs.woff +0 -0
  117. package/public/assets/inter-greek-500-normal-BIZE56-Y.woff2 +0 -0
  118. package/public/assets/inter-greek-500-normal-Xzm54t5V.woff +0 -0
  119. package/public/assets/inter-greek-600-normal-BZpKdvQh.woff +0 -0
  120. package/public/assets/inter-greek-600-normal-plRanbMR.woff2 +0 -0
  121. package/public/assets/inter-greek-ext-400-normal-DGGRlc-M.woff2 +0 -0
  122. package/public/assets/inter-greek-ext-400-normal-KugGGMne.woff +0 -0
  123. package/public/assets/inter-greek-ext-500-normal-2j5mBUwD.woff +0 -0
  124. package/public/assets/inter-greek-ext-500-normal-C4iEst2y.woff2 +0 -0
  125. package/public/assets/inter-greek-ext-600-normal-B8X0CLgF.woff +0 -0
  126. package/public/assets/inter-greek-ext-600-normal-DRtmH8MT.woff2 +0 -0
  127. package/public/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  128. package/public/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  129. package/public/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  130. package/public/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  131. package/public/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  132. package/public/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  133. package/public/assets/inter-latin-ext-400-normal-77YHD8bZ.woff +0 -0
  134. package/public/assets/inter-latin-ext-400-normal-C1nco2VV.woff2 +0 -0
  135. package/public/assets/inter-latin-ext-500-normal-BxGbmqWO.woff +0 -0
  136. package/public/assets/inter-latin-ext-500-normal-CV4jyFjo.woff2 +0 -0
  137. package/public/assets/inter-latin-ext-600-normal-CIVaiw4L.woff +0 -0
  138. package/public/assets/inter-latin-ext-600-normal-D2bJ5OIk.woff2 +0 -0
  139. package/public/assets/inter-vietnamese-400-normal-Bbgyi5SW.woff +0 -0
  140. package/public/assets/inter-vietnamese-400-normal-DMkecbls.woff2 +0 -0
  141. package/public/assets/inter-vietnamese-500-normal-DOriooB6.woff2 +0 -0
  142. package/public/assets/inter-vietnamese-500-normal-mJboJaSs.woff +0 -0
  143. package/public/assets/inter-vietnamese-600-normal-BuLX-rYi.woff +0 -0
  144. package/public/assets/inter-vietnamese-600-normal-Cc8MFFhd.woff2 +0 -0
  145. package/public/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  146. package/public/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  147. package/public/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  148. package/public/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  149. package/public/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  150. package/public/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  151. package/public/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  152. package/public/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  153. package/public/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  154. package/public/favicon.svg +5 -0
  155. package/public/index.html +14 -0
  156. package/public/logo-dark.svg +12 -0
  157. package/public/logo.svg +12 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 tapflow contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # @tapflowio/relay
2
+
3
+ WebSocket relay server for [tapflow](https://github.com/jo-duchan/tapflow).
4
+
5
+ Handles NAT traversal between browser clients and device agents, session routing, JWT authentication, and serves the bundled dashboard as a static SPA.
6
+
7
+ ## Requirements
8
+
9
+ - Node.js ≥ 20
10
+
11
+ ## Usage
12
+
13
+ Typically started via the `tapflow` CLI:
14
+
15
+ ```sh
16
+ tapflow start
17
+ ```
18
+
19
+ Or standalone:
20
+
21
+ ```ts
22
+ import { RelayServer } from '@tapflowio/relay'
23
+
24
+ const server = new RelayServer({ port: 3000 })
25
+ await server.start()
26
+ ```
27
+
28
+ ## License
29
+
30
+ [MIT](LICENSE) — part of the [tapflow](https://github.com/jo-duchan/tapflow) project.
@@ -0,0 +1,37 @@
1
+ export declare class RelayServer {
2
+ private readonly options;
3
+ private httpServer;
4
+ private wss;
5
+ private sessions;
6
+ private publicDir;
7
+ private uploadsDir;
8
+ private router;
9
+ private resourceBuffers;
10
+ private logBuffer;
11
+ private purgeRecordingsTimer;
12
+ private purgeOldResourcesTimer;
13
+ private flushResourcesTimer;
14
+ constructor(options: {
15
+ port: number;
16
+ publicDir?: string;
17
+ uploadsDir?: string;
18
+ idleTimeoutMs?: number;
19
+ });
20
+ private registerRoutes;
21
+ pushLog(msg: string): void;
22
+ start(): Promise<void>;
23
+ stop(): Promise<void>;
24
+ address(): string | import("net").AddressInfo | null;
25
+ private handleConnection;
26
+ private route;
27
+ private handleAgentResources;
28
+ private handleAgentRegister;
29
+ private handleSessionStart;
30
+ private handleBrowserAppInstall;
31
+ private handleBrowserAppLaunch;
32
+ private flushResourceBuffers;
33
+ private handleRequest;
34
+ private serveUpload;
35
+ private serveStatic;
36
+ }
37
+ //# sourceMappingURL=RelayServer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"RelayServer.d.ts","sourceRoot":"","sources":["../src/RelayServer.ts"],"names":[],"mappings":"AAuCA,qBAAa,WAAW;IAaV,OAAO,CAAC,QAAQ,CAAC,OAAO;IAZpC,OAAO,CAAC,UAAU,CAAa;IAC/B,OAAO,CAAC,GAAG,CAAiB;IAC5B,OAAO,CAAC,QAAQ,CAAgB;IAChC,OAAO,CAAC,SAAS,CAAQ;IACzB,OAAO,CAAC,UAAU,CAAQ;IAC1B,OAAO,CAAC,MAAM,CAAQ;IACtB,OAAO,CAAC,eAAe,CAAsD;IAC7E,OAAO,CAAC,SAAS,CAAe;IAChC,OAAO,CAAC,oBAAoB,CAA8C;IAC1E,OAAO,CAAC,sBAAsB,CAA8C;IAC5E,OAAO,CAAC,mBAAmB,CAA8C;gBAE5C,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAC;QAAC,aAAa,CAAC,EAAE,MAAM,CAAA;KAAE;IAYvH,OAAO,CAAC,cAAc;IAgFtB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAO1B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAatB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAYrB,OAAO;IAIP,OAAO,CAAC,gBAAgB;IAmDxB,OAAO,CAAC,KAAK;IAiIb,OAAO,CAAC,oBAAoB;IAY5B,OAAO,CAAC,mBAAmB;IAS3B,OAAO,CAAC,kBAAkB;IAyB1B,OAAO,CAAC,uBAAuB;IAuB/B,OAAO,CAAC,sBAAsB;IAuB9B,OAAO,CAAC,oBAAoB;YAed,aAAa;IA6B3B,OAAO,CAAC,WAAW;IAanB,OAAO,CAAC,WAAW;CAyBpB"}
@@ -0,0 +1,531 @@
1
+ import http from 'http';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { WebSocketServer, WebSocket } from 'ws';
5
+ import { SessionManager } from './SessionManager.js';
6
+ import { Router } from './router.js';
7
+ import { getDb } from './db.js';
8
+ import { handleLogin, handleLogout, handleMe, handleChangePassword, handleInit } from './api/auth.js';
9
+ import { handleVerify, handleAccept } from './api/invitations.js';
10
+ import { createLogger } from '@tapflowio/agent-core';
11
+ const logger = createLogger('relay');
12
+ import { handleVerifyReset, handleDoReset, handleSendMemberReset } from './api/passwordReset.js';
13
+ import { handleListBuilds, handleGetBuild, handleUpdateBuild, handleUploadBuild } from './api/builds.js';
14
+ import { handleListApps, handleCreateApp, handleUpdateApp, handleDeleteApp } from './api/apps.js';
15
+ import { handleListComments, handleCreateComment, handleDeleteComment } from './api/comments.js';
16
+ import { handleListMembers, handleInvite, handleUpdateMember, handleDeleteMember } from './api/team.js';
17
+ import { handleListTokens, handleCreateToken, handleRevokeToken } from './api/tokens.js';
18
+ import { handleGetSettings, handleUpdateSettings } from './api/settings.js';
19
+ import { handleUpdateProfile } from './api/profile.js';
20
+ import { handleUploadRecording, handleListRecordings, handleDownloadRecording, purgeExpiredRecordings } from './api/recordings.js';
21
+ import { handleListAgents, handleGetAgentResources } from './api/agents.js';
22
+ const MIME_TYPES = {
23
+ '.html': 'text/html',
24
+ '.js': 'application/javascript',
25
+ '.css': 'text/css',
26
+ '.json': 'application/json',
27
+ '.png': 'image/png',
28
+ '.jpg': 'image/jpeg',
29
+ '.jpeg': 'image/jpeg',
30
+ '.webp': 'image/webp',
31
+ '.svg': 'image/svg+xml',
32
+ '.ico': 'image/x-icon',
33
+ '.woff2': 'font/woff2',
34
+ '.woff': 'font/woff',
35
+ };
36
+ export class RelayServer {
37
+ options;
38
+ httpServer;
39
+ wss;
40
+ sessions;
41
+ publicDir;
42
+ uploadsDir;
43
+ router;
44
+ resourceBuffers = new Map();
45
+ logBuffer = [];
46
+ purgeRecordingsTimer = null;
47
+ purgeOldResourcesTimer = null;
48
+ flushResourcesTimer = null;
49
+ constructor(options) {
50
+ this.options = options;
51
+ this.sessions = new SessionManager({ idleTimeoutMs: options.idleTimeoutMs });
52
+ this.publicDir = options.publicDir ?? path.join(import.meta.dirname, '../public');
53
+ this.uploadsDir = options.uploadsDir ?? path.join(import.meta.dirname, '../uploads');
54
+ this.router = new Router();
55
+ this.registerRoutes();
56
+ this.httpServer = http.createServer((req, res) => this.handleRequest(req, res));
57
+ this.wss = new WebSocketServer({ server: this.httpServer });
58
+ this.wss.on('connection', (ws) => this.handleConnection(ws));
59
+ this.wss.on('error', () => { });
60
+ }
61
+ registerRoutes() {
62
+ const u = this.uploadsDir;
63
+ // auth
64
+ this.router.post('/api/v1/auth/init', handleInit);
65
+ this.router.get('/api/v1/auth/me', handleMe);
66
+ this.router.post('/api/v1/auth/login', handleLogin);
67
+ this.router.post('/api/v1/auth/logout', handleLogout);
68
+ this.router.post('/api/v1/auth/change-password', handleChangePassword);
69
+ this.router.get('/api/v1/auth/reset-password/verify', handleVerifyReset);
70
+ this.router.post('/api/v1/auth/reset-password', handleDoReset);
71
+ // invitations
72
+ this.router.get('/api/v1/invitations/verify', handleVerify);
73
+ this.router.post('/api/v1/invitations/accept', (req, res) => handleAccept(req, res, u));
74
+ // apps
75
+ this.router.get('/api/v1/apps', handleListApps);
76
+ this.router.post('/api/v1/apps', handleCreateApp);
77
+ this.router.patch('/api/v1/apps/:id', handleUpdateApp);
78
+ this.router.delete('/api/v1/apps/:id', handleDeleteApp);
79
+ // builds
80
+ this.router.get('/api/v1/builds', handleListBuilds);
81
+ this.router.get('/api/v1/builds/:id', handleGetBuild);
82
+ this.router.patch('/api/v1/builds/:id', handleUpdateBuild);
83
+ this.router.post('/api/v1/builds', (req, res) => handleUploadBuild(req, res, u));
84
+ // comments
85
+ this.router.get('/api/v1/comments', handleListComments);
86
+ this.router.post('/api/v1/comments', (req, res) => handleCreateComment(req, res, u));
87
+ this.router.delete('/api/v1/comments/:id', handleDeleteComment);
88
+ // team
89
+ this.router.get('/api/v1/team/members', handleListMembers);
90
+ this.router.post('/api/v1/team/invite', handleInvite);
91
+ this.router.patch('/api/v1/team/members/:id', handleUpdateMember);
92
+ this.router.delete('/api/v1/team/members/:id', handleDeleteMember);
93
+ this.router.post('/api/v1/team/members/:id/send-reset', handleSendMemberReset);
94
+ // tokens
95
+ this.router.get('/api/v1/tokens', handleListTokens);
96
+ this.router.post('/api/v1/tokens', handleCreateToken);
97
+ this.router.delete('/api/v1/tokens/:id', handleRevokeToken);
98
+ // settings
99
+ this.router.get('/api/v1/settings', handleGetSettings);
100
+ this.router.patch('/api/v1/settings', (req, res) => handleUpdateSettings(req, res, u));
101
+ this.router.patch('/api/v1/profile', (req, res) => handleUpdateProfile(req, res, u));
102
+ // recordings
103
+ const recordingsDir = path.join(u, '../recordings');
104
+ purgeExpiredRecordings(recordingsDir);
105
+ this.purgeRecordingsTimer = setInterval(() => purgeExpiredRecordings(recordingsDir), 24 * 60 * 60 * 1000);
106
+ this.purgeRecordingsTimer.unref();
107
+ this.router.post('/api/v1/recordings/upload', (req, res) => handleUploadRecording(req, res, recordingsDir));
108
+ this.router.get('/api/v1/recordings', (req, res) => handleListRecordings(req, res));
109
+ this.router.get('/api/v1/recordings/:filename', (req, res) => handleDownloadRecording(req, res, recordingsDir));
110
+ // logs
111
+ this.router.get('/api/v1/logs', (req, res) => {
112
+ const url = new URL(req.url ?? '/', `http://localhost`);
113
+ const lines = Math.min(Number(url.searchParams.get('lines') ?? 100), 500);
114
+ res.writeHead(200, { 'Content-Type': 'application/json' });
115
+ res.end(JSON.stringify(this.logBuffer.slice(-lines)));
116
+ });
117
+ // agent resources
118
+ const purgeOldResources = () => {
119
+ getDb().prepare(`DELETE FROM agent_resources WHERE recorded_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-30 days')`).run();
120
+ };
121
+ purgeOldResources();
122
+ this.purgeOldResourcesTimer = setInterval(purgeOldResources, 24 * 60 * 60 * 1000);
123
+ this.purgeOldResourcesTimer.unref();
124
+ this.flushResourcesTimer = setInterval(() => this.flushResourceBuffers(), 60_000);
125
+ this.flushResourcesTimer.unref();
126
+ this.router.get('/api/v1/agents', handleListAgents);
127
+ this.router.get('/api/v1/agents/:name/resources', handleGetAgentResources);
128
+ }
129
+ pushLog(msg) {
130
+ const line = `[${new Date().toISOString()}] ${msg}`;
131
+ this.logBuffer.push(line);
132
+ if (this.logBuffer.length > 500)
133
+ this.logBuffer.shift();
134
+ logger.info(msg);
135
+ }
136
+ start() {
137
+ return new Promise((resolve, reject) => {
138
+ this.httpServer.once('error', (err) => {
139
+ if (err.code === 'EADDRINUSE') {
140
+ reject(new Error(`Port ${this.options.port} is already in use. Stop the existing process and try again.`));
141
+ }
142
+ else {
143
+ reject(err);
144
+ }
145
+ });
146
+ this.httpServer.listen(this.options.port, resolve);
147
+ });
148
+ }
149
+ stop() {
150
+ if (this.purgeRecordingsTimer) {
151
+ clearInterval(this.purgeRecordingsTimer);
152
+ this.purgeRecordingsTimer = null;
153
+ }
154
+ if (this.purgeOldResourcesTimer) {
155
+ clearInterval(this.purgeOldResourcesTimer);
156
+ this.purgeOldResourcesTimer = null;
157
+ }
158
+ if (this.flushResourcesTimer) {
159
+ clearInterval(this.flushResourcesTimer);
160
+ this.flushResourcesTimer = null;
161
+ }
162
+ return new Promise((resolve, reject) => {
163
+ this.wss.clients.forEach((ws) => ws.terminate());
164
+ this.wss.close(() => {
165
+ this.httpServer.close((err) => (err ? reject(err) : resolve()));
166
+ });
167
+ });
168
+ }
169
+ address() {
170
+ return this.httpServer.address();
171
+ }
172
+ handleConnection(ws) {
173
+ ws.on('message', (data, isBinary) => {
174
+ if (isBinary) {
175
+ // Binary frames arrive on the dedicated stream WS, route to the session's browser
176
+ const session = this.sessions.getByStreamSocket(ws);
177
+ if (session?.browserSocket?.readyState === WebSocket.OPEN) {
178
+ session.browserSocket.send(data, { binary: true });
179
+ }
180
+ return;
181
+ }
182
+ try {
183
+ const msg = JSON.parse(data.toString());
184
+ this.route(ws, msg);
185
+ }
186
+ catch {
187
+ // ignore malformed messages
188
+ }
189
+ });
190
+ ws.on('close', () => {
191
+ // Agent main socket disconnected → remove all device sessions for this agent
192
+ const agentSessions = this.sessions.getAllByAgentSocket(ws);
193
+ if (agentSessions.length > 0) {
194
+ for (const s of agentSessions)
195
+ this.sessions.remove(s.id);
196
+ this.sessions.removeResources(ws);
197
+ return;
198
+ }
199
+ // Stream socket disconnected → clear the streamSocket reference
200
+ const streamSession = this.sessions.getByStreamSocket(ws);
201
+ if (streamSession) {
202
+ this.sessions.clearStreamSocket(streamSession.id);
203
+ return;
204
+ }
205
+ // Browser socket disconnected → clear browserSocket, start idle timer
206
+ const browserSession = this.sessions.getByBrowserSocket(ws);
207
+ if (browserSession) {
208
+ this.sessions.clearBrowser(browserSession.id, () => {
209
+ const session = this.sessions.get(browserSession.id);
210
+ if (session?.agentSocket.readyState === WebSocket.OPEN) {
211
+ session.agentSocket.send(JSON.stringify({
212
+ type: 'device:shutdown',
213
+ sessionId: session.id,
214
+ payload: { deviceId: session.deviceId },
215
+ }));
216
+ }
217
+ });
218
+ }
219
+ });
220
+ }
221
+ route(ws, msg) {
222
+ switch (msg.type) {
223
+ // ── Agent → Relay ─────────────────────────────────────────────────────
224
+ case 'agent:resources':
225
+ this.handleAgentResources(ws, msg);
226
+ break;
227
+ case 'agent:register':
228
+ this.handleAgentRegister(ws, msg);
229
+ break;
230
+ case 'agents:list': {
231
+ ws.send(JSON.stringify({ type: 'agents:listed', sessions: this.sessions.list() }));
232
+ break;
233
+ }
234
+ // ── Session / Stream lifecycle ─────────────────────────────────────────
235
+ case 'session:start':
236
+ this.handleSessionStart(ws, msg);
237
+ break;
238
+ case 'session:end': {
239
+ if (msg.sessionId)
240
+ this.sessions.remove(msg.sessionId);
241
+ break;
242
+ }
243
+ case 'stream:register': {
244
+ const session = this.sessions.get(msg.sessionId);
245
+ if (session) {
246
+ this.sessions.setStreamSocket(session.id, ws);
247
+ ws.send(JSON.stringify({ type: 'stream:registered' }));
248
+ }
249
+ break;
250
+ }
251
+ // ── Agent → Browser ────────────────────────────────────────────────────
252
+ case 'session:chrome': {
253
+ const session = this.sessions.get(msg.sessionId);
254
+ if (!session)
255
+ break;
256
+ this.sessions.setChromeData(session.id, msg.payload);
257
+ if (session.browserSocket?.readyState === WebSocket.OPEN) {
258
+ session.browserSocket.send(JSON.stringify(msg));
259
+ }
260
+ break;
261
+ }
262
+ case 'session:deviceInfo': {
263
+ const session = this.sessions.get(msg.sessionId);
264
+ if (!session)
265
+ break;
266
+ this.sessions.setDeviceInfo(session.id, msg.payload);
267
+ if (session.browserSocket?.readyState === WebSocket.OPEN) {
268
+ session.browserSocket.send(JSON.stringify(msg));
269
+ }
270
+ break;
271
+ }
272
+ case 'device:booting': {
273
+ // clear cached device data so reconnecting browser doesn't get stale chrome
274
+ const session = this.sessions.get(msg.sessionId);
275
+ if (!session)
276
+ break;
277
+ this.sessions.clearDeviceCache(session.id);
278
+ if (session.browserSocket?.readyState === WebSocket.OPEN) {
279
+ session.browserSocket.send(JSON.stringify(msg));
280
+ }
281
+ break;
282
+ }
283
+ case 'device:boot-error': {
284
+ const session = this.sessions.get(msg.sessionId);
285
+ if (session?.browserSocket?.readyState === WebSocket.OPEN) {
286
+ session.browserSocket.send(JSON.stringify(msg));
287
+ }
288
+ break;
289
+ }
290
+ case 'device:shutdown-done': {
291
+ const session = this.sessions.get(msg.sessionId);
292
+ if (!session)
293
+ break;
294
+ this.sessions.updateDeviceStatus(session.id, 'shutdown');
295
+ if (session.browserSocket?.readyState === WebSocket.OPEN) {
296
+ session.browserSocket.send(JSON.stringify(msg));
297
+ }
298
+ break;
299
+ }
300
+ case 'device:ready': {
301
+ const session = this.sessions.get(msg.sessionId);
302
+ if (!session)
303
+ break;
304
+ this.sessions.updateDeviceStatus(session.id, 'booted');
305
+ if (session.browserSocket?.readyState === WebSocket.OPEN) {
306
+ session.browserSocket.send(JSON.stringify(msg));
307
+ }
308
+ break;
309
+ }
310
+ case 'device:rotate': {
311
+ const session = this.sessions.get(msg.sessionId);
312
+ if (session?.browserSocket?.readyState === WebSocket.OPEN) {
313
+ session.browserSocket.send(JSON.stringify(msg));
314
+ }
315
+ break;
316
+ }
317
+ case 'app:install-done':
318
+ case 'app:install-error':
319
+ case 'app:launch-done':
320
+ case 'app:launch-error':
321
+ case 'keyboard:toggled': {
322
+ const session = this.sessions.get(msg.sessionId);
323
+ if (session?.browserSocket?.readyState === WebSocket.OPEN) {
324
+ session.browserSocket.send(JSON.stringify(msg));
325
+ }
326
+ break;
327
+ }
328
+ // ── Browser → Agent ────────────────────────────────────────────────────
329
+ case 'device:boot':
330
+ case 'device:shutdown': {
331
+ const session = this.sessions.get(msg.sessionId);
332
+ if (session?.agentSocket.readyState === WebSocket.OPEN) {
333
+ session.agentSocket.send(JSON.stringify(msg));
334
+ }
335
+ break;
336
+ }
337
+ case 'app:install':
338
+ this.handleBrowserAppInstall(ws, msg);
339
+ break;
340
+ case 'app:launch':
341
+ this.handleBrowserAppLaunch(ws, msg);
342
+ break;
343
+ case 'input:touch:start':
344
+ case 'input:touch:move':
345
+ case 'input:touch:end':
346
+ case 'input:pinch:start':
347
+ case 'input:pinch:move':
348
+ case 'input:pinch:end':
349
+ case 'input:key':
350
+ case 'input:type':
351
+ case 'input:button':
352
+ case 'input:rotate':
353
+ case 'input:keyboard:toggle': {
354
+ const session = this.sessions.get(msg.sessionId);
355
+ if (session?.agentSocket.readyState === WebSocket.OPEN) {
356
+ session.agentSocket.send(JSON.stringify(msg));
357
+ }
358
+ break;
359
+ }
360
+ }
361
+ }
362
+ handleAgentResources(ws, msg) {
363
+ if (!msg.resources)
364
+ return;
365
+ this.sessions.setResources(ws, msg.resources);
366
+ const agentName = this.sessions.getAllByAgentSocket(ws)[0]?.agentName;
367
+ if (agentName) {
368
+ const buf = this.resourceBuffers.get(agentName) ?? { cpu: [], mem: [] };
369
+ buf.cpu.push(msg.resources.cpuPercent);
370
+ buf.mem.push((msg.resources.memUsedMB / msg.resources.memTotalMB) * 100);
371
+ this.resourceBuffers.set(agentName, buf);
372
+ }
373
+ }
374
+ handleAgentRegister(ws, msg) {
375
+ const sessionIds = this.sessions.create(ws, msg.devices ?? [], msg.agentName, msg.platform);
376
+ const registeredSessions = (msg.devices ?? []).map((d, i) => ({
377
+ deviceId: d.id,
378
+ sessionId: sessionIds[i],
379
+ }));
380
+ ws.send(JSON.stringify({ type: 'agent:registered', registeredSessions }));
381
+ }
382
+ handleSessionStart(ws, msg) {
383
+ const session = this.sessions.get(msg.sessionId);
384
+ if (!session) {
385
+ ws.send(JSON.stringify({ type: 'error', message: 'Session not found' }));
386
+ return;
387
+ }
388
+ try {
389
+ this.sessions.join(msg.sessionId, ws);
390
+ }
391
+ catch {
392
+ ws.send(JSON.stringify({ type: 'error', message: 'Session busy' }));
393
+ return;
394
+ }
395
+ ws.send(JSON.stringify({ type: 'session:joined', sessionId: msg.sessionId }));
396
+ if (session.chromeData) {
397
+ ws.send(JSON.stringify({ type: 'session:chrome', payload: session.chromeData }));
398
+ }
399
+ if (session.deviceInfo) {
400
+ ws.send(JSON.stringify({ type: 'session:deviceInfo', payload: session.deviceInfo }));
401
+ }
402
+ // Replay device:ready if the device is already booted (browser WS blip reconnect)
403
+ if (session.deviceStatus === 'booted') {
404
+ ws.send(JSON.stringify({ type: 'device:ready', payload: { deviceId: session.deviceId } }));
405
+ }
406
+ }
407
+ handleBrowserAppInstall(ws, msg) {
408
+ // Relay looks up file_path from DB and enriches; includes sessionId for response routing
409
+ const session = this.sessions.get(msg.sessionId);
410
+ if (!session) {
411
+ ws.send(JSON.stringify({ type: 'error', message: 'Session not found' }));
412
+ return;
413
+ }
414
+ const build = getDb()
415
+ .prepare('SELECT file_path, bundle_id FROM builds WHERE id = ?')
416
+ .get(msg.buildId);
417
+ if (!build) {
418
+ ws.send(JSON.stringify({ type: 'app:install-error', message: 'Build not found' }));
419
+ return;
420
+ }
421
+ if (session.agentSocket.readyState === WebSocket.OPEN) {
422
+ session.agentSocket.send(JSON.stringify({
423
+ type: 'app:install',
424
+ sessionId: msg.sessionId,
425
+ payload: { filePath: build.file_path, bundleId: build.bundle_id },
426
+ }));
427
+ }
428
+ }
429
+ handleBrowserAppLaunch(ws, msg) {
430
+ // Relay looks up bundle_id from DB; includes sessionId for response routing
431
+ const session = this.sessions.get(msg.sessionId);
432
+ if (!session) {
433
+ ws.send(JSON.stringify({ type: 'error', message: 'Session not found' }));
434
+ return;
435
+ }
436
+ const build = getDb()
437
+ .prepare('SELECT bundle_id FROM builds WHERE id = ?')
438
+ .get(msg.buildId);
439
+ if (!build?.bundle_id) {
440
+ ws.send(JSON.stringify({ type: 'app:launch-error', message: 'Bundle ID not available for this build' }));
441
+ return;
442
+ }
443
+ if (session.agentSocket.readyState === WebSocket.OPEN) {
444
+ session.agentSocket.send(JSON.stringify({
445
+ type: 'app:launch',
446
+ sessionId: msg.sessionId,
447
+ payload: { bundleId: build.bundle_id },
448
+ }));
449
+ }
450
+ }
451
+ flushResourceBuffers() {
452
+ if (this.resourceBuffers.size === 0)
453
+ return;
454
+ const db = getDb();
455
+ const insert = db.prepare('INSERT INTO agent_resources (agent_name, cpu_percent, mem_percent) VALUES (?, ?, ?)');
456
+ db.transaction(() => {
457
+ for (const [agentName, buf] of this.resourceBuffers.entries()) {
458
+ if (buf.cpu.length === 0)
459
+ continue;
460
+ const avgCpu = buf.cpu.reduce((a, b) => a + b, 0) / buf.cpu.length;
461
+ const avgMem = buf.mem.reduce((a, b) => a + b, 0) / buf.mem.length;
462
+ insert.run(agentName, avgCpu, avgMem);
463
+ }
464
+ })();
465
+ this.resourceBuffers.clear();
466
+ }
467
+ async handleRequest(req, res) {
468
+ res.setHeader('Access-Control-Allow-Origin', '*');
469
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
470
+ if (req.method === 'OPTIONS') {
471
+ res.writeHead(204);
472
+ res.end();
473
+ return;
474
+ }
475
+ // uploads — serve uploaded files
476
+ const url = req.url ?? '/';
477
+ if (url.startsWith('/api/')) {
478
+ res.setHeader('Cache-Control', 'no-store');
479
+ }
480
+ if (url.startsWith('/uploads/')) {
481
+ this.serveUpload(req, res);
482
+ return;
483
+ }
484
+ // API routes
485
+ const handled = await this.router.handle(req, res);
486
+ if (handled)
487
+ return;
488
+ // SPA static fallback
489
+ this.serveStatic(req, res);
490
+ }
491
+ serveUpload(req, res) {
492
+ const urlPath = (req.url ?? '/').split('?')[0];
493
+ const filePath = path.join(this.uploadsDir, urlPath.replace('/uploads/', ''));
494
+ if (!fs.existsSync(filePath)) {
495
+ res.writeHead(404);
496
+ res.end('Not found');
497
+ return;
498
+ }
499
+ const contentType = MIME_TYPES[path.extname(filePath)] ?? 'application/octet-stream';
500
+ res.writeHead(200, { 'Content-Type': contentType });
501
+ fs.createReadStream(filePath)
502
+ .on('error', () => { res.destroy(); })
503
+ .pipe(res);
504
+ }
505
+ serveStatic(req, res) {
506
+ const urlPath = (req.url ?? '/').split('?')[0];
507
+ let filePath = path.join(this.publicDir, urlPath === '/' ? '/index.html' : urlPath);
508
+ // Next.js static export: try exact path, then path/index.html (trailingSlash)
509
+ if (!fs.existsSync(filePath)) {
510
+ const withIndex = path.join(filePath, 'index.html');
511
+ if (fs.existsSync(withIndex)) {
512
+ filePath = withIndex;
513
+ }
514
+ else {
515
+ // SPA fallback → index.html
516
+ filePath = path.join(this.publicDir, 'index.html');
517
+ }
518
+ }
519
+ if (!fs.existsSync(filePath)) {
520
+ res.writeHead(404);
521
+ res.end('Not found');
522
+ return;
523
+ }
524
+ const contentType = MIME_TYPES[path.extname(filePath)] ?? 'text/html';
525
+ res.writeHead(200, { 'Content-Type': contentType });
526
+ fs.createReadStream(filePath)
527
+ .on('error', () => { res.destroy(); })
528
+ .pipe(res);
529
+ }
530
+ }
531
+ //# sourceMappingURL=RelayServer.js.map