@walkhi/code-relax 0.1.0-beta.71 → 0.1.0-beta.78
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/codex-remote.mjs +23 -5
- package/dist/build-platform.json +1 -1
- package/dist/src/code-browser.mjs +0 -1
- package/dist/src/desktop-launcher.mjs +15 -6
- package/dist/src/dots-chat-server.mjs +531 -498
- package/dist/src/dots-message-images.mjs +0 -1
- package/dist/src/dots-room-activity.mjs +25 -4
- package/dist/src/dots-room-client.mjs +163 -137
- package/dist/src/dots-task-catalog.mjs +4 -1
- package/dist/src/dots-task-files.mjs +2 -2
- package/dist/src/file-upload-session.mjs +13 -6
- package/dist/src/lan-socket-server.mjs +8 -5
- package/dist/src/managed-app-server.mjs +26 -1
- package/dist/src/message-upload-files.mjs +115 -109
- package/dist/src/runtime-discovery.mjs +25 -5
- package/dist/src/self-relay/connector.mjs +9 -6
- package/dist/src/self-relay/file-wire.mjs +63 -0
- package/dist/src/self-relay/server.mjs +47 -20
- package/dist/src/server.mjs +1981 -1969
- package/dist/src/shared-app-server.mjs +4 -3
- package/dist/src/workstation-downloads.mjs +120 -120
- package/package.json +4 -4
|
@@ -1,109 +1,115 @@
|
|
|
1
|
-
import fs from 'node:fs/promises';
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
await
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
throw new Error('
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { openAsBlob } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import sharp from 'sharp';
|
|
6
|
+
import media from '../shared/code-browser.cjs';
|
|
7
|
+
import { validateInlineImage } from './message-images.mjs';
|
|
8
|
+
import { createFileUploadSession, FILE_CHUNK_BYTES, MAX_FILE_COUNT } from './file-upload-session.mjs';
|
|
9
|
+
import { fileRevision } from './file-transfer-range.mjs';
|
|
10
|
+
|
|
11
|
+
const mimeTypes = { pdf: 'application/pdf', txt: 'text/plain', md: 'text/markdown', csv: 'text/csv',
|
|
12
|
+
json: 'application/json', zip: 'application/zip', doc: 'application/msword',
|
|
13
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
14
|
+
xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
15
|
+
ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' };
|
|
16
|
+
|
|
17
|
+
// Reuse the incoming-file contract without sending workstation bytes to the phone.
|
|
18
|
+
export async function stageWorkstationMessageFile(file, uploadsRoot) {
|
|
19
|
+
const handle = await fs.open(file.path, 'r');
|
|
20
|
+
const session = createFileUploadSession(uploadsRoot);
|
|
21
|
+
try {
|
|
22
|
+
const stat = await handle.stat();
|
|
23
|
+
if (!stat.isFile() || stat.size < 1) throw new Error('附件为空。');
|
|
24
|
+
const validate = async () => {
|
|
25
|
+
if (fileRevision(await handle.stat()) !== file.revision || fileRevision(await fs.stat(file.path)) !== file.revision) {
|
|
26
|
+
throw new Error('电脑文件已变化,请重新添加。');
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
await validate();
|
|
30
|
+
const bytes = Buffer.alloc(FILE_CHUNK_BYTES);
|
|
31
|
+
const hash = crypto.createHash('sha256');
|
|
32
|
+
const readPart = async offset => {
|
|
33
|
+
const length = Math.min(bytes.length, stat.size - offset);
|
|
34
|
+
let count = 0;
|
|
35
|
+
while (count < length) {
|
|
36
|
+
const { bytesRead } = await handle.read(bytes, count, length - count, offset + count);
|
|
37
|
+
if (!bytesRead) throw new Error('电脑文件读取不完整,请重新添加。');
|
|
38
|
+
count += bytesRead;
|
|
39
|
+
}
|
|
40
|
+
return bytes.subarray(0, length);
|
|
41
|
+
};
|
|
42
|
+
for (let offset = 0; offset < stat.size; offset += FILE_CHUNK_BYTES) hash.update(await readPart(offset));
|
|
43
|
+
await validate();
|
|
44
|
+
const sha256 = hash.digest('hex');
|
|
45
|
+
const started = await session.receive({ type: 'start', fileKey: '0', name: file.name, size: stat.size, sha256 });
|
|
46
|
+
for (let offset = 0; offset < stat.size; offset += FILE_CHUNK_BYTES) {
|
|
47
|
+
await session.receive({ type: 'chunk', id: started.id, offset, data: await readPart(offset) });
|
|
48
|
+
}
|
|
49
|
+
await validate();
|
|
50
|
+
const result = await session.receive({ type: 'finish', id: started.id });
|
|
51
|
+
await session.receive({ type: 'commit' });
|
|
52
|
+
return { ...result, mediaKind: media.mediaKind(file),
|
|
53
|
+
mimeType: media.mediaMime(file) || mimeTypes[path.extname(file.name).slice(1).toLowerCase()] || 'application/octet-stream' };
|
|
54
|
+
} finally { await handle.close(); await session.close(); }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function localFile(file, directory) {
|
|
58
|
+
if (typeof file?.path !== 'string' || typeof file.name !== 'string' || !file.name || /[\r\n\0]/.test(file.name)) {
|
|
59
|
+
throw new Error('附件信息无效,请重新选择。');
|
|
60
|
+
}
|
|
61
|
+
const root = await fs.realpath(directory), resolved = await fs.realpath(file.path);
|
|
62
|
+
if (path.relative(root, path.dirname(resolved)) !== '') throw new Error('附件必须来自本次工作站上传目录。');
|
|
63
|
+
const handle = await fs.open(resolved, 'r');
|
|
64
|
+
try {
|
|
65
|
+
const stat = await handle.stat();
|
|
66
|
+
if (!stat.isFile() || stat.size < 1) throw new Error('附件为空。');
|
|
67
|
+
const bytes = await openAsBlob(resolved);
|
|
68
|
+
if (bytes.size !== stat.size) throw new Error('附件在读取时发生变化,请重新上传。');
|
|
69
|
+
const mimeType = media.mediaMime(file) || mimeTypes[path.extname(file.name).slice(1).toLowerCase()] || 'application/octet-stream';
|
|
70
|
+
if (!/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/i.test(mimeType)) throw new Error('附件类型无效。');
|
|
71
|
+
return { name: file.name, mimeType, bytes, path: resolved };
|
|
72
|
+
} finally { await handle.close(); }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function readMessageUploads(body, uploadsRoot) {
|
|
76
|
+
const images = body.images ?? [], originals = body.originalImages ?? [], files = body.files ?? [];
|
|
77
|
+
if (![images, originals, files].every(Array.isArray) || images.length + originals.length > 4 || files.length + originals.length > MAX_FILE_COUNT) {
|
|
78
|
+
throw new Error('每条消息最多 4 张图片、4 个文件。');
|
|
79
|
+
}
|
|
80
|
+
const uploads = [];
|
|
81
|
+
let imageBytes = 0;
|
|
82
|
+
for (const image of images) {
|
|
83
|
+
let file;
|
|
84
|
+
if (typeof image === 'string' && image.startsWith('data:')) {
|
|
85
|
+
validateInlineImage(image);
|
|
86
|
+
const mimeType = image.slice(5, image.indexOf(';'));
|
|
87
|
+
file = { name: `image.${mimeType.split('/')[1]}`, mimeType, bytes: Buffer.from(image.slice(image.indexOf(',') + 1), 'base64') };
|
|
88
|
+
} else {
|
|
89
|
+
if (typeof image !== 'string' || !/^[a-f0-9]{64}$/.test(image)) throw new Error('图片标识无效。');
|
|
90
|
+
let extension = 'webp';
|
|
91
|
+
try { await fs.access(path.join(uploadsRoot, `${image}.webp`)); }
|
|
92
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; extension = 'png'; }
|
|
93
|
+
file = await localFile({ path: path.join(uploadsRoot, `${image}.${extension}`), name: `image.${extension}` }, uploadsRoot);
|
|
94
|
+
}
|
|
95
|
+
imageBytes += file.bytes.size ?? file.bytes.length;
|
|
96
|
+
if (imageBytes > 8 * 1024 * 1024) throw new Error('压缩图片合计不能超过 8 MiB。');
|
|
97
|
+
uploads.push(file);
|
|
98
|
+
}
|
|
99
|
+
for (const original of originals) {
|
|
100
|
+
if (typeof original !== 'string' || !/\.(png|jpe?g|webp|gif)$/i.test(original)) throw new Error('原图路径无效。');
|
|
101
|
+
}
|
|
102
|
+
for (const file of [...originals.map(value => ({ path: value, name: path.basename(value) })), ...files]) {
|
|
103
|
+
const uploaded = await localFile(file, path.join(uploadsRoot, 'incoming'));
|
|
104
|
+
uploads.push(uploaded);
|
|
105
|
+
}
|
|
106
|
+
for (const file of uploads) {
|
|
107
|
+
if (/^image\/(?:png|jpeg|webp|gif)$/.test(file.mimeType)) {
|
|
108
|
+
const metadata = await sharp(file.path || file.bytes).metadata();
|
|
109
|
+
file.width = metadata.width; file.height = metadata.height;
|
|
110
|
+
if (!file.width || !file.height) throw new Error('无法读取 图片尺寸。');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (uploads.filter(file => file.width).length > 4) throw new Error('每条消息最多 4 张图片。');
|
|
114
|
+
return uploads;
|
|
115
|
+
}
|
|
@@ -3,8 +3,8 @@ import { discoverCodex, resolveCodexRuntime, runtimeFingerprint, runtimeInstalla
|
|
|
3
3
|
// Advisory discovery is independent of execution health. Failure here must never
|
|
4
4
|
// disable an already running app-server or cause an automatic process restart.
|
|
5
5
|
export class RuntimeDiscovery {
|
|
6
|
-
constructor({ stateRoot, host, url, interval = 60000 }) {
|
|
7
|
-
Object.assign(this, { stateRoot, host, url, interval });
|
|
6
|
+
constructor({ stateRoot, host, url, interval = 60000, diagnose = () => {} }) {
|
|
7
|
+
Object.assign(this, { stateRoot, host, url, interval, diagnose });
|
|
8
8
|
this.state = {};
|
|
9
9
|
}
|
|
10
10
|
start() { this.stopped = false; void this.tick(); }
|
|
@@ -13,19 +13,29 @@ export class RuntimeDiscovery {
|
|
|
13
13
|
const errors = [];
|
|
14
14
|
try {
|
|
15
15
|
const candidates = discoverCodex();
|
|
16
|
-
const
|
|
16
|
+
const installation = await runtimeInstallationFingerprint();
|
|
17
|
+
const fingerprints = candidates.map(candidate => {
|
|
17
18
|
try { return [candidate.executable, runtimeFingerprint(candidate.executable)]; }
|
|
18
19
|
catch (error) { return [candidate.executable, error.message]; }
|
|
19
|
-
})
|
|
20
|
+
});
|
|
21
|
+
const signature = JSON.stringify([installation, fingerprints]);
|
|
22
|
+
if (signature !== this.diagnosticCandidateSignature) {
|
|
23
|
+
this.diagnosticCandidateSignature = signature;
|
|
24
|
+
this.diagnose('runtime-candidates', { installation, candidates: candidates.map((candidate, index) => ({
|
|
25
|
+
executable: candidate.executable, source: candidate.source, fingerprint: fingerprints[index][1] })) });
|
|
26
|
+
}
|
|
20
27
|
if (signature !== this.signature) {
|
|
21
|
-
await resolveCodexRuntime(this.stateRoot, { refresh: true });
|
|
28
|
+
const selected = await resolveCodexRuntime(this.stateRoot, { refresh: true });
|
|
22
29
|
this.signature = signature;
|
|
30
|
+
this.diagnose('runtime-selected', { version: selected.version, source: selected.source,
|
|
31
|
+
executable: selected.executable, runningKernelPreserved: true });
|
|
23
32
|
}
|
|
24
33
|
} catch (error) { errors.push(error.message); }
|
|
25
34
|
try {
|
|
26
35
|
if (this.host.desktopIntegration === false) {
|
|
27
36
|
this.state = { desktop: { state: 'unsupported', integrationSupported: false }, checkedAt: new Date().toISOString() };
|
|
28
37
|
if (errors.length) this.state.discoveryError = errors.join('\n');
|
|
38
|
+
this.diagnoseState();
|
|
29
39
|
return this.state;
|
|
30
40
|
}
|
|
31
41
|
const desktop = await this.host.inspect();
|
|
@@ -38,8 +48,18 @@ export class RuntimeDiscovery {
|
|
|
38
48
|
this.state = { checkedAt: new Date().toISOString() };
|
|
39
49
|
}
|
|
40
50
|
if (errors.length) this.state.discoveryError = errors.join('\n');
|
|
51
|
+
this.diagnoseState();
|
|
41
52
|
return this.state;
|
|
42
53
|
}
|
|
54
|
+
diagnoseState() {
|
|
55
|
+
const details = { desktop: this.state.desktop || null,
|
|
56
|
+
desktopRestartRecommended: this.state.desktopRestartRecommended === true,
|
|
57
|
+
discoveryError: this.state.discoveryError || null, runningKernelPreserved: true };
|
|
58
|
+
const signature = JSON.stringify(details);
|
|
59
|
+
if (signature === this.diagnosticStateSignature) return;
|
|
60
|
+
this.diagnosticStateSignature = signature;
|
|
61
|
+
this.diagnose('runtime-discovery', details);
|
|
62
|
+
}
|
|
43
63
|
async tick() {
|
|
44
64
|
await this.sample();
|
|
45
65
|
if (!this.stopped) { this.timer = setTimeout(() => this.tick(), this.interval); this.timer.unref?.(); }
|
|
@@ -3,7 +3,7 @@ import { EventEmitter } from 'node:events';
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import WebSocket from 'ws';
|
|
5
5
|
import { decode, loopbackUrl, relayAddress, send } from './wire.mjs';
|
|
6
|
-
import { routedFileBytes, parseFileBytes } from './file-wire.mjs';
|
|
6
|
+
import { routedFileBytes, parseFileBytes, sendFileFrame } from './file-wire.mjs';
|
|
7
7
|
import { attachRtcFile } from './rtc-file-bridge.mjs';
|
|
8
8
|
import { createP2pProbe, validProbeSignal } from './p2p-probe.mjs';
|
|
9
9
|
import { createWindowsRouteHelper } from './windows-route-helper.mjs';
|
|
@@ -217,23 +217,26 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
217
217
|
const message = decode(bytes, binary);
|
|
218
218
|
if (message.type === 'file-host-ready') return;
|
|
219
219
|
if (message.type === 'file-open') {
|
|
220
|
+
if (typeof message.clientId !== 'string' || !/^[\w-]{16,128}$/.test(message.clientId)) throw new Error('Invalid file client identity');
|
|
220
221
|
const connection = bridgeConnection?.() || { url: bridgeUrl, token: bridgeToken };
|
|
221
222
|
const url = new URL(connection.url); url.pathname = '/api/file-transfer';
|
|
222
223
|
loopbackUrl(url.href, '/api/file-transfer');
|
|
223
224
|
const socket = new WebSocket(url.href, { maxPayload: 512 * 1024, perMessageDeflate: false });
|
|
224
225
|
fileLocals.set(message.session, socket);
|
|
225
|
-
socket.on('open', () => send(socket, { type: 'auth', token: connection.token, purpose: message.purpose
|
|
226
|
+
socket.on('open', () => send(socket, { type: 'auth', token: connection.token, purpose: message.purpose,
|
|
227
|
+
uploadSessionId: message.uploadSessionId, resumeUpload: message.resumeUpload,
|
|
228
|
+
relayClientId: message.clientId, fileRelay: true }));
|
|
226
229
|
socket.on('error', error => events.emit('diagnostic', `Local file receiver: ${error.message}`));
|
|
227
230
|
socket.on('close', () => {
|
|
228
231
|
if (fileLocals.get(message.session) !== socket) return;
|
|
229
232
|
fileLocals.delete(message.session);
|
|
230
|
-
|
|
233
|
+
sendFileFrame(receiver, { type: 'file-close', session: message.session });
|
|
231
234
|
});
|
|
232
235
|
socket.on('message', (data, isBinary) => {
|
|
233
236
|
if (fileLocals.get(message.session) !== socket) return socket.terminate();
|
|
234
|
-
if (receiver.readyState !== WebSocket.OPEN
|
|
235
|
-
if (isBinary) receiver
|
|
236
|
-
else
|
|
237
|
+
if (receiver.readyState !== WebSocket.OPEN) return socket.close(1011, 'File receiver unavailable');
|
|
238
|
+
if (isBinary) sendFileFrame(receiver, routedFileBytes(message.session, data));
|
|
239
|
+
else sendFileFrame(receiver, { type: 'file-frame', session: message.session, payload: data.toString() });
|
|
237
240
|
});
|
|
238
241
|
} else if (message.type === 'file-frame') {
|
|
239
242
|
const socket = fileLocals.get(message.session);
|
|
@@ -11,3 +11,66 @@ export function parseFileBytes(bytes) {
|
|
|
11
11
|
if (!/^[a-f0-9-]{36}$/.test(session)) throw new Error('Invalid file session');
|
|
12
12
|
return { session, data: bytes.subarray(36) };
|
|
13
13
|
}
|
|
14
|
+
|
|
15
|
+
const senders = new WeakMap();
|
|
16
|
+
// A workstation multiplexes up to eight three-chunk windows. Keep that burst
|
|
17
|
+
// bounded without dropping every transfer when one shared socket gets busy.
|
|
18
|
+
export function sendFileFrame(socket, data, { bytesPerSecond = 0, session = data?.session } = {}) {
|
|
19
|
+
if (!socket || socket.readyState !== socket.OPEN) return false;
|
|
20
|
+
let state = senders.get(socket);
|
|
21
|
+
if (!state) {
|
|
22
|
+
state = { queue: [], bytes: 0, active: false, nextAt: 0, timer: null, closed: false, bytesPerSecond };
|
|
23
|
+
senders.set(socket, state);
|
|
24
|
+
socket.once('close', () => {
|
|
25
|
+
state.closed = true; clearTimeout(state.timer); state.queue.length = 0; state.bytes = 0;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
if (state.closeAfter) return false;
|
|
29
|
+
const payload = typeof data === 'string' || Buffer.isBuffer(data) ? data : JSON.stringify(data);
|
|
30
|
+
const size = Buffer.byteLength(payload);
|
|
31
|
+
if (size > 512 * 1024 || state.bytes + size > 8 * 1024 * 1024 || state.queue.length >= 128) {
|
|
32
|
+
socket.close(1013, 'File receiver too slow'); return false;
|
|
33
|
+
}
|
|
34
|
+
state.queue.push({ payload, size, session }); state.bytes += size;
|
|
35
|
+
const pump = () => {
|
|
36
|
+
if (state.active || state.closed || socket.readyState !== socket.OPEN) return;
|
|
37
|
+
if (!state.queue.length) {
|
|
38
|
+
if (state.closeAfter) socket.close(state.closeAfter.code, state.closeAfter.reason);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const wait = state.nextAt - performance.now();
|
|
42
|
+
if (wait > 1) {
|
|
43
|
+
if (!state.timer) state.timer = setTimeout(() => { state.timer = null; pump(); }, wait);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const frame = state.queue.shift(); state.active = true;
|
|
47
|
+
if (state.bytesPerSecond) state.nextAt = performance.now() + frame.size * 1000 / state.bytesPerSecond;
|
|
48
|
+
socket.send(frame.payload, error => {
|
|
49
|
+
if (state.closed) return;
|
|
50
|
+
state.bytes -= frame.size; state.active = false;
|
|
51
|
+
if (error) { socket.close(1011, 'File delivery failed'); return; }
|
|
52
|
+
pump();
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
state.pump = pump;
|
|
56
|
+
pump();
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Preserve an error/ack already queued before ending this file connection.
|
|
61
|
+
// Revocation and account expiry deliberately use immediate socket.close instead.
|
|
62
|
+
export function closeFileSocket(socket, code, reason) {
|
|
63
|
+
const state = senders.get(socket);
|
|
64
|
+
if (!state) { socket.close(code, reason); return; }
|
|
65
|
+
state.closeAfter = { code, reason };
|
|
66
|
+
state.pump();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function discardFileSession(socket, session) {
|
|
70
|
+
const state = senders.get(socket);
|
|
71
|
+
if (!state) return;
|
|
72
|
+
state.queue = state.queue.filter(frame => {
|
|
73
|
+
if (frame.session !== session) return true;
|
|
74
|
+
state.bytes -= frame.size; return false;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -9,7 +9,7 @@ import { deviceStore, validId } from './device-store.mjs';
|
|
|
9
9
|
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
10
10
|
import { WebSocketServer } from 'ws';
|
|
11
11
|
import { secret, digest, matches, validSecret, send, decode } from './wire.mjs';
|
|
12
|
-
import { routedFileBytes, parseFileBytes } from './file-wire.mjs';
|
|
12
|
+
import { routedFileBytes, parseFileBytes, sendFileFrame, closeFileSocket, discardFileSession } from './file-wire.mjs';
|
|
13
13
|
import { readDebugAllowlist, debugAllowed } from './debug-allowlist.mjs';
|
|
14
14
|
import { validProbeSignal } from './p2p-probe.mjs';
|
|
15
15
|
import { MAX_ADMIN_REQUEST_BYTES, relayAdminState } from './admin-state.mjs';
|
|
@@ -137,7 +137,10 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
137
137
|
observer?.ws.close(4401, 'Device authorization revoked');
|
|
138
138
|
for (const [key, upload] of room.uploadClients || []) if (upload.clientId === message.id) {
|
|
139
139
|
room.uploadClients.delete(key);
|
|
140
|
-
if (room.uploadHost)
|
|
140
|
+
if (room.uploadHost) {
|
|
141
|
+
discardFileSession(room.uploadHost, upload.session);
|
|
142
|
+
send(room.uploadHost, { type: 'file-close', session: upload.session });
|
|
143
|
+
}
|
|
141
144
|
upload.ws.close(4401, 'Device authorization revoked');
|
|
142
145
|
}
|
|
143
146
|
for (const [socket, id] of room.deviceManagers || []) if (id === message.id) {
|
|
@@ -245,7 +248,10 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
245
248
|
for (const [clientId, upload] of room.uploadClients) {
|
|
246
249
|
if (upload.userId !== userId) continue;
|
|
247
250
|
room.uploadClients.delete(clientId);
|
|
248
|
-
if (room.uploadHost)
|
|
251
|
+
if (room.uploadHost) {
|
|
252
|
+
discardFileSession(room.uploadHost, upload.session);
|
|
253
|
+
send(room.uploadHost, { type: 'file-close', session: upload.session });
|
|
254
|
+
}
|
|
249
255
|
upload.ws.close(4431, 'Account cancelled');
|
|
250
256
|
}
|
|
251
257
|
}
|
|
@@ -438,7 +444,10 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
438
444
|
if (room.host) send(room.host, { type: 'detach', session });
|
|
439
445
|
} else if (role === 'file-client' && room?.uploadClients.get(session)?.ws === ws) {
|
|
440
446
|
room.uploadClients.delete(session);
|
|
441
|
-
if (room.uploadHost)
|
|
447
|
+
if (room.uploadHost) {
|
|
448
|
+
discardFileSession(room.uploadHost, session);
|
|
449
|
+
send(room.uploadHost, { type: 'file-close', session });
|
|
450
|
+
}
|
|
442
451
|
}
|
|
443
452
|
ws.close(4431, 'Account session invalid');
|
|
444
453
|
}
|
|
@@ -491,7 +500,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
491
500
|
for (const client of room.uploadClients?.values() || []) client.ws.close(code === 4432 ? 4432 : 4410, code === 4432 ? 'File receiver inbound queue limit exceeded' : 'File receiver offline');
|
|
492
501
|
} else if (role === 'file-client' && room?.uploadClients?.get(session)?.ws === ws) {
|
|
493
502
|
room.uploadClients.delete(session);
|
|
494
|
-
if (room.uploadHost)
|
|
503
|
+
if (room.uploadHost) sendFileFrame(room.uploadHost, { type: 'file-close', session }, { bytesPerSecond: 4 * 1024 * 1024 });
|
|
495
504
|
}
|
|
496
505
|
});
|
|
497
506
|
let messageTail = Promise.resolve();
|
|
@@ -533,13 +542,11 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
533
542
|
const frame = parseFileBytes(bytes);
|
|
534
543
|
const client = room.uploadClients.get(frame.session);
|
|
535
544
|
if (!client || client.ws.readyState !== ws.OPEN) return;
|
|
536
|
-
|
|
537
|
-
client.ws.send(frame.data); return;
|
|
545
|
+
sendFileFrame(client.ws, frame.data, { bytesPerSecond: 1024 * 1024 }); return;
|
|
538
546
|
}
|
|
539
547
|
if (role === 'file-client' && room.uploadClients?.get(session)?.ws === ws) {
|
|
540
548
|
if (room.uploadHost?.readyState !== ws.OPEN) return fail(4410, 'File receiver offline');
|
|
541
|
-
|
|
542
|
-
room.uploadHost.send(routedFileBytes(session, bytes)); return;
|
|
549
|
+
sendFileFrame(room.uploadHost, routedFileBytes(session, bytes), { bytesPerSecond: 4 * 1024 * 1024, session }); return;
|
|
543
550
|
}
|
|
544
551
|
return fail(1008, 'Invalid binary frame');
|
|
545
552
|
}
|
|
@@ -577,16 +584,31 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
577
584
|
!matches(message.credential, client.hash)) return fail();
|
|
578
585
|
room.uploadClients ||= new Map();
|
|
579
586
|
if (!['upload', 'download'].includes(message.purpose)) return fail(1008, 'Invalid file purpose');
|
|
580
|
-
|
|
581
|
-
|
|
587
|
+
if (message.purpose === 'upload' && (typeof message.uploadSessionId !== 'string' ||
|
|
588
|
+
!/^[a-f0-9-]{36}$/.test(message.uploadSessionId) || typeof message.resumeUpload !== 'boolean')) {
|
|
589
|
+
return fail(1008, 'Invalid upload session');
|
|
590
|
+
}
|
|
591
|
+
if (!await recordConnection(room, message.clientId, new Date().toISOString(), deviceInfo(message))) return fail(4401, 'Device authorization revoked');
|
|
592
|
+
if (ws.readyState !== ws.OPEN || !accountConnection.valid()) return;
|
|
593
|
+
if (room.host?.readyState !== ws.OPEN || room.uploadHost?.readyState !== ws.OPEN) return fail(4410, 'File receiver offline');
|
|
594
|
+
if (room.status !== 'active' || !room.clients.some(item => item.id === message.clientId && matches(message.credential, item.hash))) return fail(4401, 'Device authorization revoked');
|
|
595
|
+
const replacing = message.purpose === 'upload' ? [...room.uploadClients.values()].filter(item =>
|
|
596
|
+
item.clientId === message.clientId && item.uploadSessionId === message.uploadSessionId) : [];
|
|
597
|
+
const same = [...room.uploadClients.values()].filter(item => item.clientId === message.clientId && item.purpose === message.purpose && !replacing.includes(item));
|
|
598
|
+
const all = [...rooms.values()].flatMap(item => [...(item.uploadClients?.values() || [])]).filter(item => !replacing.includes(item));
|
|
582
599
|
if (same.length >= (message.purpose === 'upload' ? 1 : 3) ||
|
|
583
600
|
all.length >= 8 || (message.purpose === 'upload' && all.filter(item => item.purpose === 'upload').length >= 2)) {
|
|
584
601
|
return fail(4429, 'Too many file transfers');
|
|
585
602
|
}
|
|
603
|
+
for (const old of replacing) {
|
|
604
|
+
room.uploadClients.delete(old.session);
|
|
605
|
+
sendFileFrame(room.uploadHost, { type: 'file-close', session: old.session }, { bytesPerSecond: 4 * 1024 * 1024 });
|
|
606
|
+
old.ws.close(4409, 'Upload connection replaced');
|
|
607
|
+
}
|
|
586
608
|
role = 'file-client'; clientId = message.clientId; session = randomUUID(); clearTimeout(authTimer);
|
|
587
|
-
|
|
588
|
-
room.
|
|
589
|
-
|
|
609
|
+
room.uploadClients.set(session, { ws, session, clientId, userId, purpose: message.purpose, uploadSessionId: message.uploadSessionId });
|
|
610
|
+
sendFileFrame(room.uploadHost, { type: 'file-open', session, purpose: message.purpose, clientId,
|
|
611
|
+
uploadSessionId: message.uploadSessionId, resumeUpload: message.resumeUpload }, { bytesPerSecond: 4 * 1024 * 1024 }); return;
|
|
590
612
|
}
|
|
591
613
|
if (message.type === 'host') {
|
|
592
614
|
const durable = message.deviceId !== undefined;
|
|
@@ -881,7 +903,13 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
881
903
|
connection.ws.close(4401, 'Device authorization revoked');
|
|
882
904
|
}
|
|
883
905
|
room.connections.clear();
|
|
884
|
-
for (const client of room.uploadClients?.values() || [])
|
|
906
|
+
for (const client of room.uploadClients?.values() || []) {
|
|
907
|
+
if (room.uploadHost) {
|
|
908
|
+
discardFileSession(room.uploadHost, client.session);
|
|
909
|
+
send(room.uploadHost, { type: 'file-close', session: client.session });
|
|
910
|
+
}
|
|
911
|
+
client.ws.close(4401, 'Device authorization revoked');
|
|
912
|
+
}
|
|
885
913
|
room.uploadClients?.clear();
|
|
886
914
|
for (const observer of room.observers.values()) observer.ws.close(4401, 'Device authorization revoked');
|
|
887
915
|
room.observers.clear();
|
|
@@ -909,14 +937,13 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
909
937
|
const client = room.uploadClients.get(message.session);
|
|
910
938
|
if (!client) return;
|
|
911
939
|
if (message.type === 'file-frame' && typeof message.payload === 'string' && message.payload.length <= 512 * 1024) {
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
} else if (message.type === 'file-close') client.ws.close(1011, 'File receiver unavailable');
|
|
940
|
+
sendFileFrame(client.ws, { type: 'file-frame', payload: message.payload }, { bytesPerSecond: 1024 * 1024 });
|
|
941
|
+
} else if (message.type === 'file-close') closeFileSocket(client.ws, 1011, 'File receiver unavailable');
|
|
915
942
|
else fail(1008, 'Invalid file frame');
|
|
916
943
|
} else if (role === 'file-client' && room.uploadClients?.get(session)?.ws === ws) {
|
|
917
944
|
if (message.type !== 'file-frame' || typeof message.payload !== 'string' || message.payload.length > 512 * 1024) return fail(1008, 'Invalid file frame');
|
|
918
|
-
if (room.uploadHost?.
|
|
919
|
-
|
|
945
|
+
if (room.uploadHost?.readyState !== ws.OPEN) return fail(4410, 'File receiver offline');
|
|
946
|
+
sendFileFrame(room.uploadHost, { type: 'file-frame', session, payload: message.payload }, { bytesPerSecond: 4 * 1024 * 1024 });
|
|
920
947
|
} else if (role === 'observer' && room.observers.get(clientId)?.ws === ws && message.type === 'official-computer') {
|
|
921
948
|
const computer = normalizeOfficialComputer(message);
|
|
922
949
|
await commit(room, { officialComputer: computer });
|