@walkhi/code-relax 0.1.0-beta.50 → 0.1.0-beta.59
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/README.md +105 -105
- package/dist/bin/cleanup-accounts.mjs +63 -63
- package/dist/bin/codex-remote.mjs +403 -403
- package/dist/bin/publish-android-release.mjs +63 -63
- package/dist/bin/release-upload-server.mjs +190 -190
- package/dist/bin/set-relay-admin-password.mjs +11 -0
- package/dist/build-platform.json +1 -1
- package/dist/get-shared-service-url.ps1 +14 -14
- package/dist/shared/code-browser.cjs +159 -159
- package/dist/shared/file-changes.cjs +31 -31
- package/dist/shared/file-citations.cjs +117 -117
- package/dist/shared/file-mentions.cjs +92 -92
- package/dist/shared/message-delivery.cjs +115 -115
- package/dist/shared/p2p-file-channel.cjs +87 -87
- package/dist/shared/release-version.cjs +31 -0
- package/dist/shared/server-requests.cjs +311 -311
- package/dist/shared/timeline-sync.cjs +173 -173
- package/dist/skills/code-relax/SKILL.md +22 -22
- package/dist/src/bounded-process-log.mjs +63 -63
- package/dist/src/celsius-client.mjs +107 -107
- package/dist/src/dots-approval-timeline.mjs +99 -99
- package/dist/src/dots-avatar.mjs +42 -42
- package/dist/src/dots-chat-projection.mjs +50 -50
- package/dist/src/dots-chat-server.mjs +483 -483
- package/dist/src/dots-message-images.mjs +319 -319
- package/dist/src/dots-profile.mjs +77 -77
- package/dist/src/dots-room-activity.mjs +172 -172
- package/dist/src/dots-room-client.mjs +137 -135
- package/dist/src/dots-room-projection.mjs +94 -94
- package/dist/src/dots-schedules.mjs +66 -66
- package/dist/src/dots-summary-tasks.mjs +32 -32
- package/dist/src/dots-task-catalog.mjs +430 -430
- package/dist/src/file-transfer-range.mjs +22 -22
- package/dist/src/file-upload-session.mjs +146 -130
- package/dist/src/hosted-history-app-server.mjs +103 -103
- package/dist/src/hosted-history-cache.mjs +113 -113
- package/dist/src/lan-socket-server.mjs +338 -266
- package/dist/src/managed-app-server.mjs +373 -373
- package/dist/src/managed-endpoint.mjs +25 -25
- package/dist/src/message-upload-files.mjs +109 -109
- package/dist/src/onboarding.mjs +12 -12
- package/dist/src/platform/README.md +1 -1
- package/dist/src/platform/codex-protocol.mjs +46 -46
- package/dist/src/platform/desktop-project-store.mjs +219 -208
- package/dist/src/platform/linux/desktop-host.mjs +87 -87
- package/dist/src/platform/linux/process.mjs +53 -53
- package/dist/src/platform/linux/system.mjs +50 -50
- package/dist/src/platform/linux/user-environment.mjs +9 -9
- package/dist/src/platform/macos/codex-runtime.mjs +125 -125
- package/dist/src/platform/macos/desktop-host.mjs +86 -86
- package/dist/src/platform/macos/user-environment.mjs +9 -9
- package/dist/src/platform/native-package.mjs +58 -58
- package/dist/src/platform/windows/activate-desktop.ps1 +49 -49
- package/dist/src/platform/windows/codex-runtime.mjs +215 -215
- package/dist/src/platform/windows/credential-store.mjs +27 -27
- package/dist/src/platform/windows/credential-store.ps1 +49 -49
- package/dist/src/platform/windows/desktop-host.mjs +65 -65
- package/dist/src/platform/windows/desktop-info.ps1 +38 -38
- package/dist/src/platform/windows/desktop-project-registration.mjs +4 -4
- package/dist/src/platform/windows/desktop-shortcuts.ps1 +14 -14
- package/dist/src/platform/windows/dots-profile.mjs +49 -49
- package/dist/src/platform/windows/launch-worker.mjs +41 -41
- package/dist/src/platform/windows/project-locations.mjs +16 -16
- package/dist/src/platform/windows/service-host.mjs +35 -35
- package/dist/src/platform/windows/user-environment.mjs +52 -52
- package/dist/src/project-management.mjs +58 -58
- package/dist/src/runtime-discovery.mjs +47 -47
- package/dist/src/self-relay/account-cleanup.mjs +30 -30
- package/dist/src/self-relay/admin-auth.mjs +59 -0
- package/dist/src/self-relay/admin-login-page.mjs +7 -0
- package/dist/src/self-relay/admin-page.mjs +87 -80
- package/dist/src/self-relay/admin-server.mjs +49 -18
- package/dist/src/self-relay/admin-style.mjs +58 -14
- package/dist/src/self-relay/admin-workstations-page.mjs +95 -0
- package/dist/src/self-relay/admin-workstations.mjs +143 -0
- package/dist/src/self-relay/connection-reporting.mjs +38 -0
- package/dist/src/self-relay/connector.mjs +444 -439
- package/dist/src/self-relay/demo.mjs +31 -31
- package/dist/src/self-relay/diagnostics.mjs +54 -54
- package/dist/src/self-relay/email-accounts.mjs +183 -183
- package/dist/src/self-relay/file-wire.mjs +13 -13
- package/dist/src/self-relay/lifecycle.mjs +11 -0
- package/dist/src/self-relay/p2p-probe.mjs +168 -168
- package/dist/src/self-relay/phone-connections.mjs +55 -0
- package/dist/src/self-relay/privacy-page.mjs +26 -26
- package/dist/src/self-relay/public-releases.mjs +67 -67
- package/dist/src/self-relay/rtc-file-bridge.mjs +46 -41
- package/dist/src/self-relay/server.mjs +63 -3
- package/dist/src/self-relay/usage-meter.mjs +141 -141
- package/dist/src/server.mjs +23 -9
- package/dist/src/service-doctor.mjs +148 -148
- package/dist/src/service-install.mjs +97 -97
- package/dist/src/service-lifecycle.mjs +139 -139
- package/dist/src/service-update.mjs +6 -3
- package/dist/src/shared-app-server.mjs +683 -666
- package/dist/src/shared-catalog.mjs +132 -132
- package/dist/src/shared-thread-stream.mjs +165 -165
- package/dist/src/usage-reset.mjs +17 -17
- package/dist/src/workstation-downloads.mjs +120 -120
- package/package.json +4 -4
- package/tools/postinstall.mjs +186 -186
|
@@ -1,266 +1,338 @@
|
|
|
1
|
-
import { EventEmitter } from 'node:events';
|
|
2
|
-
import crypto from 'node:crypto';
|
|
3
|
-
import { WebSocketServer, WebSocket } from 'ws';
|
|
4
|
-
import timelineSync from '../shared/timeline-sync.cjs';
|
|
5
|
-
import { createFileUploadSession, FILE_CHUNK_BYTES, MAX_FILE_BYTES, MAX_FILE_COUNT, MAX_TOTAL_BYTES } from './file-upload-session.mjs';
|
|
6
|
-
import { FILE_READ_LIMIT } from './file-transfer-range.mjs';
|
|
7
|
-
|
|
8
|
-
// One authenticated connection carries requests and explicitly owned subscriptions.
|
|
9
|
-
// The dispatcher is shared business routing, not an HTTP loopback proxy.
|
|
10
|
-
export function attachLanSocket(server, { token, dispatch, authorizeDevice = () => false, onDeviceAuthorizationsChanged, onDeviceConnected,
|
|
11
|
-
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
};
|
|
58
|
-
ws.on('
|
|
59
|
-
|
|
60
|
-
if (stopped
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (message.type === '
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
4
|
+
import timelineSync from '../shared/timeline-sync.cjs';
|
|
5
|
+
import { createFileUploadSession, FILE_CHUNK_BYTES, MAX_FILE_BYTES, MAX_FILE_COUNT, MAX_TOTAL_BYTES } from './file-upload-session.mjs';
|
|
6
|
+
import { FILE_READ_LIMIT } from './file-transfer-range.mjs';
|
|
7
|
+
|
|
8
|
+
// One authenticated connection carries requests and explicitly owned subscriptions.
|
|
9
|
+
// The dispatcher is shared business routing, not an HTTP loopback proxy.
|
|
10
|
+
export function attachLanSocket(server, { token, dispatch, authorizeDevice = () => false, onDeviceAuthorizationsChanged, onDeviceConnected,
|
|
11
|
+
onPhoneConnected, onPhoneDisconnected, uploadsRoot }) {
|
|
12
|
+
// Allow four individually valid images to reach the aggregate-size validator,
|
|
13
|
+
// so an oversized composition returns an error instead of dropping the connection.
|
|
14
|
+
const wss = new WebSocketServer({ noServer: true, maxPayload: 48 * 1024 * 1024, perMessageDeflate: false });
|
|
15
|
+
const uploadWss = new WebSocketServer({ noServer: true, maxPayload: 512 * 1024, perMessageDeflate: false });
|
|
16
|
+
const activeUploads = new Map();
|
|
17
|
+
const uploadSessions = new Map();
|
|
18
|
+
let closing = false;
|
|
19
|
+
const expireUpload = async (id, entry) => {
|
|
20
|
+
if (uploadSessions.get(id) !== entry) return;
|
|
21
|
+
uploadSessions.delete(id);
|
|
22
|
+
clearTimeout(entry.timer);
|
|
23
|
+
await entry.session.close();
|
|
24
|
+
};
|
|
25
|
+
const deviceConnections = new Map();
|
|
26
|
+
const stopWatching = onDeviceAuthorizationsChanged?.(() => {
|
|
27
|
+
for (const connection of deviceConnections.values()) {
|
|
28
|
+
if (!authorizeDevice(connection.clientId, connection.credential)) connection.revoke();
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
server.on('upgrade', (request, socket, head) => {
|
|
32
|
+
const origin = request.headers.origin;
|
|
33
|
+
const upload = request.url === '/api/file-transfer' && Boolean(uploadsRoot);
|
|
34
|
+
let allowed = request.url === '/api/socket' || upload;
|
|
35
|
+
// Packaged ArkWeb pages have an opaque ("null") origin; device authentication remains required.
|
|
36
|
+
if (origin && origin !== 'null') {
|
|
37
|
+
try {
|
|
38
|
+
const originUrl = new URL(origin);
|
|
39
|
+
const requestUrl = new URL(`http://${request.headers.host}`);
|
|
40
|
+
const nativeClient = request.headers['x-code-relax-native'] === '1';
|
|
41
|
+
allowed &&= ['http:', 'https:'].includes(originUrl.protocol) &&
|
|
42
|
+
(originUrl.host === request.headers.host || (nativeClient && originUrl.hostname === requestUrl.hostname));
|
|
43
|
+
}
|
|
44
|
+
catch { allowed = false; }
|
|
45
|
+
}
|
|
46
|
+
if (!allowed) { socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); return; }
|
|
47
|
+
const target = upload ? uploadWss : wss;
|
|
48
|
+
target.handleUpgrade(request, socket, head, ws => target.emit('connection', ws));
|
|
49
|
+
});
|
|
50
|
+
uploadWss.on('connection', ws => {
|
|
51
|
+
let authenticated = false, stopped = false, identity = '', upload, purpose, chunk, uploadEntry, uploadId;
|
|
52
|
+
let release, discardUpload = false;
|
|
53
|
+
const released = new Promise(resolve => { release = resolve; });
|
|
54
|
+
let queued = 0;
|
|
55
|
+
let pending = Promise.resolve();
|
|
56
|
+
const authTimer = setTimeout(() => ws.close(1008, 'Authentication required'), 5000);
|
|
57
|
+
const reply = value => { if (!stopped && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(value)); };
|
|
58
|
+
ws.on('error', () => {});
|
|
59
|
+
const stop = () => {
|
|
60
|
+
if (stopped) return;
|
|
61
|
+
stopped = true;
|
|
62
|
+
deviceConnections.delete(ws);
|
|
63
|
+
clearTimeout(authTimer);
|
|
64
|
+
void pending.finally(async () => {
|
|
65
|
+
activeUploads.delete(ws);
|
|
66
|
+
if (uploadEntry) {
|
|
67
|
+
uploadEntry.ws = null;
|
|
68
|
+
if (discardUpload || closing) await expireUpload(uploadId, uploadEntry);
|
|
69
|
+
else {
|
|
70
|
+
uploadEntry.timer = setTimeout(() => { void expireUpload(uploadId, uploadEntry); }, 5 * 60 * 1000);
|
|
71
|
+
uploadEntry.timer.unref();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
release();
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
ws.on('close', stop);
|
|
78
|
+
ws.on('message', (bytes, binary) => {
|
|
79
|
+
if (stopped || ws.readyState !== WebSocket.OPEN) return;
|
|
80
|
+
// Three upload chunks may be in flight, each with a header and binary frame.
|
|
81
|
+
if (++queued > 8) { stop(); ws.close(1013, 'File request queue exceeded'); return; }
|
|
82
|
+
pending = pending.then(async () => {
|
|
83
|
+
if (stopped || ws.readyState !== WebSocket.OPEN) return;
|
|
84
|
+
if (binary) {
|
|
85
|
+
if (!authenticated || purpose !== 'upload' || !chunk || bytes.length !== chunk.length) throw new Error('无效的文件分片');
|
|
86
|
+
const current = chunk; chunk = undefined;
|
|
87
|
+
reply(await upload.receive({ ...current, data: bytes })); return;
|
|
88
|
+
}
|
|
89
|
+
if (bytes.length > 16 * 1024 || chunk) throw new Error('无效的文件请求');
|
|
90
|
+
const message = JSON.parse(bytes.toString());
|
|
91
|
+
if (!authenticated) {
|
|
92
|
+
purpose = message.purpose;
|
|
93
|
+
if (!['upload', 'download'].includes(purpose)) throw new Error('文件通道用途无效');
|
|
94
|
+
if (message?.type === 'device-auth' && typeof message.clientId === 'string' &&
|
|
95
|
+
authorizeDevice(message.clientId, message.credential) === true) identity = `device:${message.clientId}`;
|
|
96
|
+
else if (token && message?.type === 'auth' && typeof message.token === 'string') {
|
|
97
|
+
const supplied = Buffer.from(message.token), expected = Buffer.from(token || '');
|
|
98
|
+
if (supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected)) {
|
|
99
|
+
identity = typeof message.relayClientId === 'string' && message.relayClientId.length <= 200
|
|
100
|
+
? `relay:${message.relayClientId}` : 'local-token';
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (identity && purpose === 'upload') {
|
|
104
|
+
uploadId = message.uploadSessionId;
|
|
105
|
+
if (typeof uploadId !== 'string' || !/^[a-f0-9-]{36}$/.test(uploadId)) throw new Error('上传批次编号无效');
|
|
106
|
+
const entry = uploadSessions.get(uploadId);
|
|
107
|
+
if (entry) {
|
|
108
|
+
if (entry.identity !== identity) throw new Error('上传批次身份不一致');
|
|
109
|
+
if (entry.ws) { entry.ws.terminate(); await entry.released; }
|
|
110
|
+
if (uploadSessions.get(uploadId) !== entry) throw new Error('上传批次已失效,无法确认原批次状态');
|
|
111
|
+
} else if (message.resumeUpload) throw new Error('上传断点已过期或工作站已重启,请重新发送');
|
|
112
|
+
}
|
|
113
|
+
// Taking over a connection can await an in-flight disk write. The new
|
|
114
|
+
// socket and its authorization must still be live when that finishes.
|
|
115
|
+
if (stopped || closing || ws.readyState !== WebSocket.OPEN) return;
|
|
116
|
+
if (message.type === 'device-auth' && !authorizeDevice(message.clientId, message.credential)) throw new Error('设备授权已失效');
|
|
117
|
+
const same = [...activeUploads.values()].filter(item => item.identity === identity && item.purpose === purpose);
|
|
118
|
+
const uploads = [...activeUploads.values()].filter(item => item.purpose === 'upload').length;
|
|
119
|
+
if (!identity || same.length >= (purpose === 'upload' ? 1 : 3) || activeUploads.size >= 8 ||
|
|
120
|
+
(purpose === 'upload' && uploads >= 2)) throw new Error('文件身份无效或文件任务过多');
|
|
121
|
+
if (message.type === 'device-auth') deviceConnections.set(ws, {
|
|
122
|
+
clientId: message.clientId, credential: message.credential,
|
|
123
|
+
revoke() { discardUpload = true; stop(); ws.close(4401, 'Device authorization revoked'); },
|
|
124
|
+
});
|
|
125
|
+
activeUploads.set(ws, { identity, purpose }); authenticated = true; clearTimeout(authTimer);
|
|
126
|
+
if (message.type === 'device-auth') onDeviceConnected?.(message.clientId, message);
|
|
127
|
+
if (purpose === 'upload') {
|
|
128
|
+
uploadEntry = uploadSessions.get(uploadId);
|
|
129
|
+
if (!uploadEntry) {
|
|
130
|
+
if ([...uploadSessions.values()].filter(entry => !entry.committed).length >= 2) throw new Error('待完成上传批次过多,请稍后再试');
|
|
131
|
+
if (uploadSessions.size >= 32) {
|
|
132
|
+
const oldest = [...uploadSessions].find(([, entry]) => entry.committed && !entry.ws);
|
|
133
|
+
if (!oldest) throw new Error('上传批次过多,请稍后再试');
|
|
134
|
+
await expireUpload(...oldest);
|
|
135
|
+
}
|
|
136
|
+
if (stopped || closing || ws.readyState !== WebSocket.OPEN) return;
|
|
137
|
+
uploadEntry = { identity, session: createFileUploadSession(uploadsRoot) };
|
|
138
|
+
uploadSessions.set(uploadId, uploadEntry);
|
|
139
|
+
}
|
|
140
|
+
clearTimeout(uploadEntry.timer);
|
|
141
|
+
Object.assign(uploadEntry, { ws, released });
|
|
142
|
+
upload = uploadEntry.session;
|
|
143
|
+
}
|
|
144
|
+
reply({ type: 'ready', version: 3, purpose, maxDownloadBytes: FILE_READ_LIMIT, maxFileBytes: MAX_FILE_BYTES, maxFileCount: MAX_FILE_COUNT,
|
|
145
|
+
maxTotalBytes: MAX_TOTAL_BYTES, chunkBytes: FILE_CHUNK_BYTES, uploadSessionId: uploadId });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (purpose === 'download') {
|
|
149
|
+
if (message.type !== 'read' || typeof message.path !== 'string' || message.path.length > 12000 ||
|
|
150
|
+
!/^\/api\/(?:images\/[a-f0-9]{64}|files\/(?:local|[a-f0-9]{64})|code\/(?:image|binary)|desktop\/frames\/[a-f0-9]{32})(?:\?|$)/.test(message.path) || message.path.includes('#')) throw new Error('无效的文件读取请求');
|
|
151
|
+
await dispatch({ method: 'GET', url: message.path, headers: {}, socketRequest: true, fileTransfer: true }, {
|
|
152
|
+
json(status, body) { reply({ type: 'error', status, error: body?.error || '文件读取失败' }); },
|
|
153
|
+
filePart(metadata, data) {
|
|
154
|
+
if (stopped || ws.readyState !== WebSocket.OPEN) return;
|
|
155
|
+
if (!Buffer.isBuffer(data) || data.length > FILE_CHUNK_BYTES || ws.bufferedAmount > 1024 * 1024) throw new Error('文件接收过慢或分片无效');
|
|
156
|
+
reply({ type: 'part', ...metadata, length: data.length });
|
|
157
|
+
ws.send(data);
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (message.type === 'chunk') {
|
|
163
|
+
if (!Number.isSafeInteger(message.length) || message.length < 1 || message.length > FILE_CHUNK_BYTES) throw new Error('文件分片大小无效');
|
|
164
|
+
chunk = message; return;
|
|
165
|
+
}
|
|
166
|
+
reply(await upload.receive(message));
|
|
167
|
+
if (message.type === 'commit') uploadEntry.committed = true;
|
|
168
|
+
if (message.type === 'cancel') {
|
|
169
|
+
await expireUpload(uploadId, uploadEntry);
|
|
170
|
+
uploadEntry = null;
|
|
171
|
+
}
|
|
172
|
+
}).catch(error => { reply({ type: 'error', error: error.message }); discardUpload = true; stop(); ws.close(1008, 'File request rejected'); })
|
|
173
|
+
.finally(() => { queued--; });
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
wss.on('connection', ws => {
|
|
177
|
+
let authenticated = false, alive = true, stopped = false;
|
|
178
|
+
let phone, reported = false;
|
|
179
|
+
const connectionId = crypto.randomUUID();
|
|
180
|
+
const active = new Map();
|
|
181
|
+
const send = value => {
|
|
182
|
+
if (stopped || ws.readyState !== WebSocket.OPEN) return;
|
|
183
|
+
if (ws.bufferedAmount > 64 * 1024 * 1024) { ws.close(1013, 'Client too slow'); return; }
|
|
184
|
+
ws.send(JSON.stringify(value));
|
|
185
|
+
};
|
|
186
|
+
const authTimer = setTimeout(() => ws.close(1008, 'Authentication required'), 5000);
|
|
187
|
+
const heartbeat = setInterval(() => {
|
|
188
|
+
if (!alive) { ws.terminate(); return; }
|
|
189
|
+
alive = false; ws.ping();
|
|
190
|
+
}, 30000);
|
|
191
|
+
ws.on('pong', () => { alive = true; });
|
|
192
|
+
ws.on('error', () => {}); // close rejects requests and releases every subscription.
|
|
193
|
+
const stop = () => {
|
|
194
|
+
if (stopped) return;
|
|
195
|
+
stopped = true;
|
|
196
|
+
deviceConnections.delete(ws);
|
|
197
|
+
clearTimeout(authTimer); clearInterval(heartbeat);
|
|
198
|
+
if (reported) onPhoneDisconnected?.(connectionId);
|
|
199
|
+
for (const reply of active.values()) reply.end();
|
|
200
|
+
active.clear();
|
|
201
|
+
};
|
|
202
|
+
ws.on('close', stop);
|
|
203
|
+
ws.on('message', async (bytes, binary) => {
|
|
204
|
+
if (stopped || ws.readyState !== WebSocket.OPEN) return;
|
|
205
|
+
let message;
|
|
206
|
+
try { if (binary) throw new Error(); message = JSON.parse(bytes.toString()); }
|
|
207
|
+
catch { ws.close(1008, 'Invalid JSON'); return; }
|
|
208
|
+
if (!message || typeof message !== 'object') { ws.close(1008, 'Invalid message'); return; }
|
|
209
|
+
if (!authenticated) {
|
|
210
|
+
let accepted = false;
|
|
211
|
+
if (message.type === 'device-auth') {
|
|
212
|
+
accepted = authorizeDevice(message.clientId, message.credential) === true;
|
|
213
|
+
} else if (message.type === 'auth') {
|
|
214
|
+
const supplied = Buffer.from(typeof message.token === 'string' ? message.token : '');
|
|
215
|
+
const expected = Buffer.from(token || '');
|
|
216
|
+
accepted = supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected);
|
|
217
|
+
}
|
|
218
|
+
if (!accepted) {
|
|
219
|
+
ws.close(1008, 'Authentication failed'); return;
|
|
220
|
+
}
|
|
221
|
+
if (message.type === 'device-auth') phone = { clientId: message.clientId, mode: 'lan' };
|
|
222
|
+
else if (typeof message.relayClientId === 'string' && /^[\w-]{16,128}$/.test(message.relayClientId)) {
|
|
223
|
+
phone = { clientId: message.relayClientId, mode: 'self-relay' };
|
|
224
|
+
}
|
|
225
|
+
if (message.type === 'device-auth') onDeviceConnected?.(message.clientId, message);
|
|
226
|
+
if (message.type === 'device-auth') deviceConnections.set(ws, {
|
|
227
|
+
clientId: message.clientId, credential: message.credential,
|
|
228
|
+
revoke() { stop(); ws.close(4401, 'Device authorization revoked'); },
|
|
229
|
+
});
|
|
230
|
+
authenticated = true; clearTimeout(authTimer); send({ type: 'ready', version: 1 }); return;
|
|
231
|
+
}
|
|
232
|
+
const { id, type } = message;
|
|
233
|
+
if (!Number.isSafeInteger(id) || id < 1) { ws.close(1008, 'Invalid request id'); return; }
|
|
234
|
+
if (type === 'response-ack') { active.get(id)?.acknowledge?.(message.batch); return; }
|
|
235
|
+
if (type === 'cancel') { active.get(id)?.end(); return; }
|
|
236
|
+
if (type === 'unsubscribe') { const reply = active.get(id); if (reply?.subscription) reply.end(); return; }
|
|
237
|
+
if (!['request', 'subscribe'].includes(type) || active.has(id)) { ws.close(1008, 'Invalid request'); return; }
|
|
238
|
+
const subscription = type === 'subscribe';
|
|
239
|
+
const path = message.path;
|
|
240
|
+
const isStream = typeof path === 'string' && /^\/api\/(?:usage-events|thread-events|turn-events|shared-recovery\/events|threads\/[^/?]+\/(?:events|dots-activity-events))(?:\?|$)/.test(path);
|
|
241
|
+
// Native recovery, usage, catalog, current chat, and the main Dot's sidebar
|
|
242
|
+
// share one station connection (including when viewing a delegated task).
|
|
243
|
+
if (active.size >= 32 || (subscription && [...active.values()].filter(r => r.subscription).length >= 5)) {
|
|
244
|
+
send({ type: 'response', id, status: 429, body: { error: '同时进行的请求过多' } }); return;
|
|
245
|
+
}
|
|
246
|
+
if (typeof path !== 'string' || !path.startsWith('/api/') || path.includes('#') ||
|
|
247
|
+
path.startsWith('/api/attachments/') || path === '/api/socket' || path === '/api/uploads' ||
|
|
248
|
+
subscription !== isStream || !['GET', 'POST', 'PUT', 'DELETE'].includes(message.method || 'GET') ||
|
|
249
|
+
(subscription && message.method && message.method !== 'GET') ||
|
|
250
|
+
(message.body !== undefined && (!message.body || typeof message.body !== 'object' || Array.isArray(message.body)))) {
|
|
251
|
+
send({ type: 'response', id, status: 400, body: { error: '无效的连接请求' } }); return;
|
|
252
|
+
}
|
|
253
|
+
// Health probes, management and file sockets are not chat connections.
|
|
254
|
+
// Three message sources share this catalog/events entry point.
|
|
255
|
+
if (phone && !reported && /^\/api\/(?:threads(?:\?|$)|thread-events(?:\?|$)|threads\/[^/?]+\/(?:events|dots-activity-events)(?:\?|$))/.test(path)) {
|
|
256
|
+
reported = true;
|
|
257
|
+
onPhoneConnected?.(connectionId, phone.clientId, phone.mode);
|
|
258
|
+
}
|
|
259
|
+
const reply = new EventEmitter();
|
|
260
|
+
let waiting, batch = 0;
|
|
261
|
+
const sendPart = (pieces, pageEnd = false) => new Promise((resolve, reject) => {
|
|
262
|
+
const timer = setTimeout(() => reject(new Error('历史接收确认超时')), 20000);
|
|
263
|
+
waiting = { resolve, reject, timer, batch: ++batch };
|
|
264
|
+
send({ type: subscription ? 'event-part' : 'response-part', id, batch, pieces, ...(pageEnd ? { pageEnd: true } : {}) });
|
|
265
|
+
});
|
|
266
|
+
const sendTimeline = async (status, body, event) => {
|
|
267
|
+
if (event) batch = 0;
|
|
268
|
+
try {
|
|
269
|
+
for (const pieces of timelineSync.timelineBatches(body)) {
|
|
270
|
+
if (reply.writableEnded) return;
|
|
271
|
+
await sendPart(pieces);
|
|
272
|
+
}
|
|
273
|
+
if (!reply.writableEnded) {
|
|
274
|
+
send(event ? { type: 'event', id, event, streamed: true } : { type: 'response', id, status, streamed: true });
|
|
275
|
+
if (!event) reply.end();
|
|
276
|
+
}
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if (!reply.writableEnded) {
|
|
279
|
+
send(event ? { type: 'event', id, event: 'bridge-error', data: { error: error.message } }
|
|
280
|
+
: { type: 'response', id, status: 500, body: { error: error.message } });
|
|
281
|
+
reply.end();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
Object.assign(reply, {
|
|
286
|
+
subscription, headersSent: false, writableEnded: false, destroyed: false,
|
|
287
|
+
async historyPage(page) {
|
|
288
|
+
const parts = timelineSync.timelineBatches(page);
|
|
289
|
+
let part = parts.next();
|
|
290
|
+
while (!part.done) {
|
|
291
|
+
if (this.writableEnded) throw new Error('历史读取已取消');
|
|
292
|
+
const next = parts.next();
|
|
293
|
+
await sendPart(part.value, next.done);
|
|
294
|
+
part = next;
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
json(status, body) {
|
|
298
|
+
if (this.writableEnded) return;
|
|
299
|
+
if (status < 400 && body?.schemaVersion === 1 && Array.isArray(body.turns)) {
|
|
300
|
+
void sendTimeline(status, body); return;
|
|
301
|
+
}
|
|
302
|
+
send({ type: 'response', id, status, body }); this.end();
|
|
303
|
+
},
|
|
304
|
+
acknowledge(sequence) {
|
|
305
|
+
if (!waiting || sequence !== waiting.batch) return;
|
|
306
|
+
const completed = waiting; waiting = null; clearTimeout(completed.timer);
|
|
307
|
+
this.emit('progress'); completed.resolve();
|
|
308
|
+
},
|
|
309
|
+
startEvents() {
|
|
310
|
+
if (this.writableEnded) return;
|
|
311
|
+
this.headersSent = true; send({ type: 'subscribed', id });
|
|
312
|
+
},
|
|
313
|
+
event(event, data) {
|
|
314
|
+
if (this.writableEnded) return;
|
|
315
|
+
if (event === 'update' && data?.schemaVersion === 1) return sendTimeline(200, data, event);
|
|
316
|
+
send({ type: 'event', id, event, data });
|
|
317
|
+
},
|
|
318
|
+
end() {
|
|
319
|
+
if (this.writableEnded) return;
|
|
320
|
+
this.writableEnded = this.destroyed = true; active.delete(id);
|
|
321
|
+
if (waiting) { clearTimeout(waiting.timer); waiting.reject(new Error('历史读取已取消')); waiting = null; }
|
|
322
|
+
if (subscription) send({ type: 'end', id });
|
|
323
|
+
this.emit('close');
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
active.set(id, reply);
|
|
327
|
+
try { await dispatch({ method: message.method || 'GET', url: path, body: message.body || {}, headers: {}, socketRequest: true }, reply); }
|
|
328
|
+
catch (error) { reply.json(500, { error: error.message }); }
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
return { close() {
|
|
332
|
+
closing = true;
|
|
333
|
+
stopWatching?.();
|
|
334
|
+
for (const ws of wss.clients) ws.terminate(); wss.close();
|
|
335
|
+
for (const ws of uploadWss.clients) ws.terminate(); uploadWss.close();
|
|
336
|
+
for (const [id, entry] of uploadSessions) if (!entry.ws) void expireUpload(id, entry);
|
|
337
|
+
} };
|
|
338
|
+
}
|