@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.
- package/LICENSE +21 -0
- package/README.md +30 -0
- package/dist/RelayServer.d.ts +37 -0
- package/dist/RelayServer.d.ts.map +1 -0
- package/dist/RelayServer.js +531 -0
- package/dist/RelayServer.js.map +1 -0
- package/dist/SessionManager.d.ts +62 -0
- package/dist/SessionManager.d.ts.map +1 -0
- package/dist/SessionManager.js +174 -0
- package/dist/SessionManager.js.map +1 -0
- package/dist/api/agents.d.ts +4 -0
- package/dist/api/agents.d.ts.map +1 -0
- package/dist/api/agents.js +36 -0
- package/dist/api/agents.js.map +1 -0
- package/dist/api/apps.d.ts +6 -0
- package/dist/api/apps.d.ts.map +1 -0
- package/dist/api/apps.js +76 -0
- package/dist/api/apps.js.map +1 -0
- package/dist/api/auth.d.ts +9 -0
- package/dist/api/auth.d.ts.map +1 -0
- package/dist/api/auth.js +81 -0
- package/dist/api/auth.js.map +1 -0
- package/dist/api/builds.d.ts +14 -0
- package/dist/api/builds.d.ts.map +1 -0
- package/dist/api/builds.js +368 -0
- package/dist/api/builds.js.map +1 -0
- package/dist/api/comments.d.ts +5 -0
- package/dist/api/comments.d.ts.map +1 -0
- package/dist/api/comments.js +114 -0
- package/dist/api/comments.js.map +1 -0
- package/dist/api/invitations.d.ts +4 -0
- package/dist/api/invitations.d.ts.map +1 -0
- package/dist/api/invitations.js +95 -0
- package/dist/api/invitations.js.map +1 -0
- package/dist/api/passwordReset.d.ts +6 -0
- package/dist/api/passwordReset.d.ts.map +1 -0
- package/dist/api/passwordReset.js +64 -0
- package/dist/api/passwordReset.js.map +1 -0
- package/dist/api/profile.d.ts +3 -0
- package/dist/api/profile.d.ts.map +1 -0
- package/dist/api/profile.js +56 -0
- package/dist/api/profile.js.map +1 -0
- package/dist/api/recordings.d.ts +6 -0
- package/dist/api/recordings.d.ts.map +1 -0
- package/dist/api/recordings.js +167 -0
- package/dist/api/recordings.js.map +1 -0
- package/dist/api/settings.d.ts +4 -0
- package/dist/api/settings.d.ts.map +1 -0
- package/dist/api/settings.js +65 -0
- package/dist/api/settings.js.map +1 -0
- package/dist/api/team.d.ts +6 -0
- package/dist/api/team.d.ts.map +1 -0
- package/dist/api/team.js +65 -0
- package/dist/api/team.js.map +1 -0
- package/dist/api/tokens.d.ts +5 -0
- package/dist/api/tokens.d.ts.map +1 -0
- package/dist/api/tokens.js +39 -0
- package/dist/api/tokens.js.map +1 -0
- package/dist/db.d.ts +5 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +43 -0
- package/dist/db.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/config.d.ts +34 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +97 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/mailer.d.ts +2 -0
- package/dist/lib/mailer.d.ts.map +1 -0
- package/dist/lib/mailer.js +30 -0
- package/dist/lib/mailer.js.map +1 -0
- package/dist/middleware/auth.d.ts +17 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +65 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/migrations/001_initial.sql +55 -0
- package/dist/migrations/002_dashboard_foundation.sql +54 -0
- package/dist/migrations/003_recordings.sql +14 -0
- package/dist/migrations/004_app_build_split.sql +88 -0
- package/dist/migrations/005_user_avatar.sql +1 -0
- package/dist/migrations/006_password_reset.sql +7 -0
- package/dist/migrations/007_app_platform_model.sql +34 -0
- package/dist/migrations/008_recordings_build_id.sql +4 -0
- package/dist/migrations/009_agent_resources.sql +12 -0
- package/dist/router.d.ts +16 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +53 -0
- package/dist/router.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +17 -0
- package/dist/server.js.map +1 -0
- package/dist/types.d.ts +41 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +72 -0
- package/public/assets/index-CIXHMEdP.css +1 -0
- package/public/assets/index-CzYNt1iH.js +499 -0
- package/public/assets/inter-cyrillic-400-normal-HOLc17fK.woff +0 -0
- package/public/assets/inter-cyrillic-400-normal-obahsSVq.woff2 +0 -0
- package/public/assets/inter-cyrillic-500-normal-BasfLYem.woff2 +0 -0
- package/public/assets/inter-cyrillic-500-normal-CxZf_p3X.woff +0 -0
- package/public/assets/inter-cyrillic-600-normal-4D_pXhcN.woff +0 -0
- package/public/assets/inter-cyrillic-600-normal-CWCymEST.woff2 +0 -0
- package/public/assets/inter-cyrillic-ext-400-normal-BQZuk6qB.woff2 +0 -0
- package/public/assets/inter-cyrillic-ext-400-normal-DQukG94-.woff +0 -0
- package/public/assets/inter-cyrillic-ext-500-normal-B0yAr1jD.woff2 +0 -0
- package/public/assets/inter-cyrillic-ext-500-normal-BmqWE9Dz.woff +0 -0
- package/public/assets/inter-cyrillic-ext-600-normal-Bcila6Z-.woff +0 -0
- package/public/assets/inter-cyrillic-ext-600-normal-Dfes3d0z.woff2 +0 -0
- package/public/assets/inter-greek-400-normal-B4URO6DV.woff2 +0 -0
- package/public/assets/inter-greek-400-normal-q2sYcFCs.woff +0 -0
- package/public/assets/inter-greek-500-normal-BIZE56-Y.woff2 +0 -0
- package/public/assets/inter-greek-500-normal-Xzm54t5V.woff +0 -0
- package/public/assets/inter-greek-600-normal-BZpKdvQh.woff +0 -0
- package/public/assets/inter-greek-600-normal-plRanbMR.woff2 +0 -0
- package/public/assets/inter-greek-ext-400-normal-DGGRlc-M.woff2 +0 -0
- package/public/assets/inter-greek-ext-400-normal-KugGGMne.woff +0 -0
- package/public/assets/inter-greek-ext-500-normal-2j5mBUwD.woff +0 -0
- package/public/assets/inter-greek-ext-500-normal-C4iEst2y.woff2 +0 -0
- package/public/assets/inter-greek-ext-600-normal-B8X0CLgF.woff +0 -0
- package/public/assets/inter-greek-ext-600-normal-DRtmH8MT.woff2 +0 -0
- package/public/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/public/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
- package/public/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
- package/public/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/public/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
- package/public/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/public/assets/inter-latin-ext-400-normal-77YHD8bZ.woff +0 -0
- package/public/assets/inter-latin-ext-400-normal-C1nco2VV.woff2 +0 -0
- package/public/assets/inter-latin-ext-500-normal-BxGbmqWO.woff +0 -0
- package/public/assets/inter-latin-ext-500-normal-CV4jyFjo.woff2 +0 -0
- package/public/assets/inter-latin-ext-600-normal-CIVaiw4L.woff +0 -0
- package/public/assets/inter-latin-ext-600-normal-D2bJ5OIk.woff2 +0 -0
- package/public/assets/inter-vietnamese-400-normal-Bbgyi5SW.woff +0 -0
- package/public/assets/inter-vietnamese-400-normal-DMkecbls.woff2 +0 -0
- package/public/assets/inter-vietnamese-500-normal-DOriooB6.woff2 +0 -0
- package/public/assets/inter-vietnamese-500-normal-mJboJaSs.woff +0 -0
- package/public/assets/inter-vietnamese-600-normal-BuLX-rYi.woff +0 -0
- package/public/assets/inter-vietnamese-600-normal-Cc8MFFhd.woff2 +0 -0
- package/public/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
- package/public/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
- package/public/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
- package/public/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
- package/public/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
- package/public/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/public/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
- package/public/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
- package/public/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
- package/public/favicon.svg +5 -0
- package/public/index.html +14 -0
- package/public/logo-dark.svg +12 -0
- 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
|