@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.
@@ -1,109 +1,115 @@
1
- import fs from 'node:fs/promises';
2
- import path from 'node:path';
3
- import crypto from 'node:crypto';
4
- import sharp from 'sharp';
5
- import media from '../shared/code-browser.cjs';
6
- import { validateInlineImage } from './message-images.mjs';
7
- import { createFileUploadSession, FILE_CHUNK_BYTES, MAX_FILE_BYTES, MAX_FILE_COUNT, MAX_TOTAL_BYTES } from './file-upload-session.mjs';
8
- import { fileRevision } from './file-transfer-range.mjs';
9
-
10
- const mimeTypes = { pdf: 'application/pdf', txt: 'text/plain', md: 'text/markdown', csv: 'text/csv',
11
- json: 'application/json', zip: 'application/zip', doc: 'application/msword',
12
- docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
13
- xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
14
- ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' };
15
-
16
- // Reuse the incoming-file contract without sending workstation bytes to the phone.
17
- export async function stageWorkstationMessageFile(file, uploadsRoot) {
18
- const handle = await fs.open(file.path, 'r');
19
- const session = createFileUploadSession(uploadsRoot);
20
- try {
21
- const stat = await handle.stat();
22
- if (!stat.isFile() || stat.size < 1 || stat.size > MAX_FILE_BYTES) throw new Error('附件为空或超过 20 MiB。');
23
- const validate = async () => {
24
- if (fileRevision(await handle.stat()) !== file.revision || fileRevision(await fs.stat(file.path)) !== file.revision) {
25
- throw new Error('电脑文件已变化,请重新添加。');
26
- }
27
- };
28
- await validate();
29
- const bytes = Buffer.alloc(stat.size);
30
- for (let offset = 0; offset < bytes.length;) {
31
- const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, offset);
32
- if (!bytesRead) throw new Error('电脑文件读取不完整,请重新添加。');
33
- offset += bytesRead;
34
- }
35
- await validate();
36
- const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
37
- const started = await session.receive({ type: 'start', name: file.name, size: bytes.length, sha256 });
38
- for (let offset = 0; offset < bytes.length; offset += FILE_CHUNK_BYTES) {
39
- await session.receive({ type: 'chunk', id: started.id, offset, data: bytes.subarray(offset, offset + FILE_CHUNK_BYTES) });
40
- }
41
- await validate();
42
- const result = await session.receive({ type: 'finish', id: started.id });
43
- await session.receive({ type: 'commit' });
44
- return { ...result, mediaKind: media.mediaKind(file),
45
- mimeType: media.mediaMime(file) || mimeTypes[path.extname(file.name).slice(1).toLowerCase()] || 'application/octet-stream' };
46
- } finally { await handle.close(); await session.close(); }
47
- }
48
-
49
- async function localFile(file, directory) {
50
- if (typeof file?.path !== 'string' || typeof file.name !== 'string' || !file.name || /[\r\n\0]/.test(file.name)) {
51
- throw new Error('附件信息无效,请重新选择。');
52
- }
53
- const root = await fs.realpath(directory), resolved = await fs.realpath(file.path);
54
- if (path.relative(root, path.dirname(resolved)) !== '') throw new Error('附件必须来自本次工作站上传目录。');
55
- const handle = await fs.open(resolved, 'r');
56
- try {
57
- const stat = await handle.stat();
58
- if (!stat.isFile() || stat.size < 1 || stat.size > MAX_FILE_BYTES) throw new Error('附件为空或超过 20 MiB。');
59
- const bytes = await handle.readFile();
60
- if (bytes.length !== stat.size) throw new Error('附件在读取时发生变化,请重新上传。');
61
- const mimeType = media.mediaMime(file) || mimeTypes[path.extname(file.name).slice(1).toLowerCase()] || 'application/octet-stream';
62
- if (!/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/i.test(mimeType)) throw new Error('附件类型无效。');
63
- return { name: file.name, mimeType, bytes };
64
- } finally { await handle.close(); }
65
- }
66
-
67
- export async function readMessageUploads(body, uploadsRoot) {
68
- const images = body.images ?? [], originals = body.originalImages ?? [], files = body.files ?? [];
69
- if (![images, originals, files].every(Array.isArray) || images.length + originals.length > 4 || files.length + originals.length > MAX_FILE_COUNT) {
70
- throw new Error('每条消息最多 4 张图片、4 个文件。');
71
- }
72
- const uploads = [];
73
- let imageBytes = 0, fileBytes = 0;
74
- for (const image of images) {
75
- let file;
76
- if (typeof image === 'string' && image.startsWith('data:')) {
77
- validateInlineImage(image);
78
- const mimeType = image.slice(5, image.indexOf(';'));
79
- file = { name: `image.${mimeType.split('/')[1]}`, mimeType, bytes: Buffer.from(image.slice(image.indexOf(',') + 1), 'base64') };
80
- } else {
81
- if (typeof image !== 'string' || !/^[a-f0-9]{64}$/.test(image)) throw new Error('图片标识无效。');
82
- let extension = 'webp';
83
- try { await fs.access(path.join(uploadsRoot, `${image}.webp`)); }
84
- catch (error) { if (error.code !== 'ENOENT') throw error; extension = 'png'; }
85
- file = await localFile({ path: path.join(uploadsRoot, `${image}.${extension}`), name: `image.${extension}` }, uploadsRoot);
86
- }
87
- imageBytes += file.bytes.length;
88
- if (imageBytes > 8 * 1024 * 1024) throw new Error('压缩图片合计不能超过 8 MiB。');
89
- uploads.push(file);
90
- }
91
- for (const original of originals) {
92
- if (typeof original !== 'string' || !/\.(png|jpe?g|webp|gif)$/i.test(original)) throw new Error('原图路径无效。');
93
- }
94
- for (const file of [...originals.map(value => ({ path: value, name: path.basename(value) })), ...files]) {
95
- const uploaded = await localFile(file, path.join(uploadsRoot, 'incoming'));
96
- fileBytes += uploaded.bytes.length;
97
- if (fileBytes > MAX_TOTAL_BYTES) throw new Error('文件合计不能超过 40 MiB。');
98
- uploads.push(uploaded);
99
- }
100
- for (const file of uploads) {
101
- if (/^image\/(?:png|jpeg|webp|gif)$/.test(file.mimeType)) {
102
- const metadata = await sharp(file.bytes).metadata();
103
- file.width = metadata.width; file.height = metadata.height;
104
- if (!file.width || !file.height) throw new Error('无法读取 图片尺寸。');
105
- }
106
- }
107
- if (uploads.filter(file => file.width).length > 4) throw new Error('每条消息最多 4 张图片。');
108
- return uploads;
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 signature = JSON.stringify([await runtimeInstallationFingerprint(), candidates.map(candidate => {
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
- send(receiver, { type: 'file-close', session: message.session });
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 || receiver.bufferedAmount > 1024 * 1024) return socket.close(1013, 'File receiver too slow');
235
- if (isBinary) receiver.send(routedFileBytes(message.session, data));
236
- else send(receiver, { type: 'file-frame', session: message.session, payload: data.toString() });
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) send(room.uploadHost, { type: 'file-close', session: upload.session });
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) send(room.uploadHost, { type: 'file-close', session: upload.session });
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) send(room.uploadHost, { type: 'file-close', session });
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) send(room.uploadHost, { type: 'file-close', session });
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
- if (client.ws.bufferedAmount > 1024 * 1024) return client.ws.close(1013, 'File receiver too slow');
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
- if (room.uploadHost.bufferedAmount > 1024 * 1024) return fail(1013, 'File receiver too slow');
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
- const same = [...room.uploadClients.values()].filter(item => item.clientId === message.clientId && item.purpose === message.purpose);
581
- const all = [...rooms.values()].flatMap(item => [...(item.uploadClients?.values() || [])]);
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
- if (!await recordConnection(room, clientId, new Date().toISOString(), deviceInfo(message))) return fail(4401, 'Device authorization revoked');
588
- room.uploadClients.set(session, { ws, session, clientId, userId, purpose: message.purpose });
589
- send(room.uploadHost, { type: 'file-open', session, purpose: message.purpose }); return;
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() || []) client.ws.close(4401, 'Device authorization revoked');
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
- if (client.ws.bufferedAmount > 1024 * 1024) return client.ws.close(1013, 'File receiver too slow');
913
- send(client.ws, { type: 'file-frame', payload: message.payload });
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?.bufferedAmount > 1024 * 1024) return fail(1013, 'File receiver too slow');
919
- send(room.uploadHost, { type: 'file-frame', session, payload: message.payload });
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 });