@walkhi/code-relax 0.1.0-beta.76 → 0.1.0-beta.79

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.
@@ -254,7 +254,6 @@ export class DotsMessageImages {
254
254
  }
255
255
  const headers = await this.authorization(image.identity);
256
256
  await this.metadata(image, headers);
257
- if (image.size > this.maxBytes) throw new Error('点点附件超过 20 MiB 下载上限。');
258
257
  const revision = image.id;
259
258
  const { offset, length } = fileRange(query, image.size, revision);
260
259
  if (!offset || !image.downloadUrl) image.downloadUrl = await this.contentUrl(image, headers, true);
@@ -1,157 +1,163 @@
1
- import http2 from 'node:http2';
2
- import { CelsiusClient } from './celsius-client.mjs';
3
-
4
- const topic = 'calpico-chatgpt-messaging';
5
- const identityKey = identity => JSON.stringify([identity?.accountId, identity?.userId]);
6
-
7
- // Desktop 26.1016.6573.0: the DM API and Celsius share the kernel account,
8
- // but are independent of the hosted execution socket. Never replay a POST.
9
- export class DotsRoomClient extends CelsiusClient {
10
- constructor(transport) {
11
- super({ topics: [topic] }); this.transport = transport;
12
- this.getConnection = () => this.request('/celsius/ws/user');
13
- this.controllers = new Set(); this.routes = new Map();
14
- }
15
-
16
- async authorization() {
17
- const identity = this.transport.profileIdentity, generation = this.generation;
18
- const auth = await this.transport.localServer.request('getAuthStatus', { includeToken: true, refreshToken: false });
19
- const principal = auth.authToken && JSON.parse(Buffer.from(auth.authToken.split('.')[1], 'base64url'))['https://api.openai.com/auth'];
20
- if (!identity?.accountId || principal?.chatgpt_account_id !== identity.accountId
21
- || principal.chatgpt_user_id !== identity.userId || generation !== this.generation
22
- || identityKey(identity) !== identityKey(this.transport.profileIdentity)) throw new Error('点点账号已变化,请重新连接。');
23
- return { identity, generation, headers: { authorization: `Bearer ${auth.authToken}`,
24
- 'chatgpt-account-id': identity.accountId, 'user-agent': 'Code-Relax/0.1.0' } };
25
- }
26
-
27
- async request(route, { method = 'GET', body, contentType, signal, onEvent, rawResponse = false } = {}) {
28
- const auth = await this.authorization();
29
- const controller = new AbortController(); this.controllers.add(controller);
30
- const cancelled = () => controller.abort(signal.reason);
31
- signal?.addEventListener('abort', cancelled, { once: true });
32
- if (signal?.aborted) cancelled();
33
- let sent = false, status, timer, responseKind = 'none';
34
- const started = Date.now();
35
- const clientId = method === 'POST' && /^[a-f0-9-]{36}$/i.test(body?.request_id || '') ? body.request_id : undefined;
36
- try {
37
- return await new Promise((resolve, reject) => {
38
- const session = http2.connect('https://chatgpt.com');
39
- let request, settled = false, bytes = 0, text = '';
40
- const current = () => auth.generation === this.generation
41
- && identityKey(auth.identity) === identityKey(this.transport.profileIdentity);
42
- const finish = (error, value) => {
43
- if (settled) return; settled = true;
44
- clearTimeout(timer); controller.signal.removeEventListener('abort', abort);
45
- request?.destroy(); session.destroy();
46
- if (error) {
47
- error.details = { ...error.details, ...(status ? { upstreamStatus: status } : {}), responseKind };
48
- if (!controller.signal.aborted) console.error(JSON.stringify({ time: new Date().toISOString(), event: 'dots-room-request', method,
49
- route: route.split('?')[0], clientId, status: status || 0, responseKind,
50
- ms: Date.now() - started, outcome: 'failed' }));
51
- if (method !== 'GET' && !onEvent) error.deliveryUnknown = sent && !(status >= 400 && status < 500 && status !== 408);
52
- reject(error);
53
- } else resolve(value);
54
- };
55
- const abort = () => finish(controller.signal.reason || new Error('点点请求已取消。'));
56
- controller.signal.addEventListener('abort', abort, { once: true });
57
- session.on('error', error => finish(error));
58
- if (controller.signal.aborted) { abort(); return; }
59
- const roomRoute = method === 'POST' ? /^\/messaging\/rooms\/([^/]+)\/messages$/.exec(route)?.[1] : null;
60
- request = session.request({ ':method': method, ':path': `/backend-api${route}`, ...auth.headers,
61
- accept: onEvent ? 'text/event-stream' : 'application/json',
62
- ...(body != null ? { 'content-type': contentType || 'application/json' } : {}),
63
- ...(roomRoute && this.routes.has(roomRoute) ? { 'x-openai-thread-route': this.routes.get(roomRoute) } : {}) });
64
- timer = setTimeout(() => finish(new Error('点点请求超时,请重新核对状态。')), 30000);
65
- request.setEncoding('utf8');
66
- request.on('response', headers => {
67
- status = headers[':status'];
68
- const type = String(headers['content-type'] || '').split(';')[0].trim().toLowerCase();
69
- responseKind = headers['cf-mitigated'] === 'challenge' ? 'challenge'
70
- : type === 'application/json' ? 'json' : type === 'text/html' ? 'html' : type === 'text/event-stream' ? 'sse' : 'other';
71
- if (roomRoute && current()) {
72
- // Desktop replaces the route on every send response, including removal.
73
- this.routes.delete(roomRoute);
74
- const routeHint = headers['x-openai-thread-route'];
75
- if (typeof routeHint === 'string' && routeHint && routeHint.length <= 1024) {
76
- if (this.routes.size >= 128) this.routes.clear();
77
- this.routes.set(roomRoute, routeHint);
78
- }
79
- }
80
- if (status < 200 || status >= 300) {
81
- const message = status === 401 ? '点点登录状态需要恢复,请重新连接。'
82
- : status === 403 ? '点点服务拒绝访问(HTTP 403),请稍后重试。'
83
- : status === 404 ? '点点消息暂不可用,请稍后重新同步。'
84
- : status === 429 ? '点点暂时繁忙,请稍后再试。' : '点点请求尚未完成,请稍后核对。';
85
- finish(Object.assign(new Error(message), { status }));
86
- } else if (roomRoute || onEvent) {
87
- console.log(JSON.stringify({ time: new Date().toISOString(), event: 'dots-room-request', method,
88
- route: route.split('?')[0], clientId, status, responseKind, ms: Date.now() - started, outcome: 'response' }));
89
- }
90
- });
91
- request.on('data', chunk => {
92
- if (!current()) { finish(new Error('点点账号已变化,请重新连接。')); return; }
93
- bytes += Buffer.byteLength(chunk); text += chunk;
94
- if (onEvent) {
95
- text = text.replace(/\r\n/g, '\n');
96
- let end;
97
- while ((end = text.indexOf('\n\n')) >= 0) {
98
- const frame = text.slice(0, end); text = text.slice(end + 2);
99
- const data = frame.split('\n').filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n');
100
- // Desktop's ChatGPT SSE treats [DONE] as completion, not JSON.
101
- if (data.trim() === '[DONE]') { finish(null, null); return; }
102
- if (data) try { onEvent(JSON.parse(data)); clearTimeout(timer); } catch (error) { finish(error); return; }
103
- }
104
- bytes = Buffer.byteLength(text);
105
- }
106
- if (bytes > 8 * 1024 * 1024) finish(new Error('点点响应超出单次读取上限。'));
107
- });
108
- request.on('error', error => finish(error));
109
- request.on('end', () => {
110
- if (!current()) { finish(new Error('点点账号已变化,请重新连接。')); return; }
111
- try { finish(null, onEvent ? null : rawResponse ? text : text ? JSON.parse(text) : null); } catch (error) { finish(error); }
112
- });
113
- request.on('close', () => { if (!settled) finish(new Error('点点请求连接已断开。')); });
114
- // end() can queue data before TLS connects. Only a ready HTTP/2 stream
115
- // has been assigned an ID and may have submitted the request.
116
- sent = !request.pending;
117
- request.once('ready', () => { sent = true; });
118
- request.end(body == null ? undefined : Buffer.isBuffer(body) ? body : JSON.stringify(body));
119
- });
120
- } finally { this.controllers.delete(controller); signal?.removeEventListener('abort', cancelled); }
121
- }
122
-
123
- async roomId(threadId) {
124
- let profile = this.transport.liveProfiles.get(threadId);
125
- if (!profile?.messagingRoomId) {
126
- const value = await this.request(`/tbo/by-thread/${encodeURIComponent(threadId)}`);
127
- profile = { messagingRoomId: value?.messaging_room_id };
128
- }
129
- if (typeof profile.messagingRoomId !== 'string' || !profile.messagingRoomId) throw new Error('点点消息房间暂不可用。');
130
- return profile.messagingRoomId;
131
- }
132
-
133
- async room(id) {
134
- const room = await this.request(`/messaging/rooms/${encodeURIComponent(id)}`);
135
- if (room?.id !== id || room.type !== 'DM' || room.app_source !== 'chatgpt:messaging' || !Array.isArray(room.members)) {
136
- throw new Error('点点消息房间身份不匹配。');
137
- }
138
- return room;
139
- }
140
-
141
- async messages(roomId, { before, after, around, limit = 20 } = {}) {
142
- const query = new URLSearchParams({ limit: String(limit), ...(before ? { before } : {}),
143
- ...(after ? { after } : {}), ...(around ? { around } : {}) });
144
- const page = await this.request(`/messaging/rooms/${encodeURIComponent(roomId)}/messages?${query}`);
145
- if (!Array.isArray(page?.items) || page.items.some(message => typeof message.id !== 'string' || !message.id)) throw new Error('点点消息页格式无效。');
146
- // Null prev_cursor is also returned for nonempty middle pages. Desktop uses
147
- // the first message ID; only an empty page proves that history is exhausted.
148
- return { items: page.items, before: page.prev_cursor ?? page.items[0]?.id ?? null,
149
- after: page.next_cursor ?? (after && page.items.length >= limit ? page.items.at(-1)?.id : null) };
150
- }
151
-
152
- close() {
153
- super.close();
154
- for (const controller of this.controllers) controller.abort(new Error('点点连接已结束。'));
155
- this.controllers.clear(); this.routes.clear();
156
- }
157
- }
1
+ import http2 from 'node:http2';
2
+ import { Readable } from 'node:stream';
3
+ import { CelsiusClient } from './celsius-client.mjs';
4
+
5
+ const topic = 'calpico-chatgpt-messaging';
6
+ const identityKey = identity => JSON.stringify([identity?.accountId, identity?.userId]);
7
+
8
+ // Desktop 26.1016.6573.0: the DM API and Celsius share the kernel account,
9
+ // but are independent of the hosted execution socket. Never replay a POST.
10
+ export class DotsRoomClient extends CelsiusClient {
11
+ constructor(transport) {
12
+ super({ topics: [topic] }); this.transport = transport;
13
+ this.getConnection = () => this.request('/celsius/ws/user');
14
+ this.controllers = new Set(); this.routes = new Map();
15
+ }
16
+
17
+ async authorization() {
18
+ const identity = this.transport.profileIdentity, generation = this.generation;
19
+ const auth = await this.transport.localServer.request('getAuthStatus', { includeToken: true, refreshToken: false });
20
+ const principal = auth.authToken && JSON.parse(Buffer.from(auth.authToken.split('.')[1], 'base64url'))['https://api.openai.com/auth'];
21
+ if (!identity?.accountId || principal?.chatgpt_account_id !== identity.accountId
22
+ || principal.chatgpt_user_id !== identity.userId || generation !== this.generation
23
+ || identityKey(identity) !== identityKey(this.transport.profileIdentity)) throw new Error('点点账号已变化,请重新连接。');
24
+ return { identity, generation, headers: { authorization: `Bearer ${auth.authToken}`,
25
+ 'chatgpt-account-id': identity.accountId, 'user-agent': 'Code-Relax/0.1.0' } };
26
+ }
27
+
28
+ async request(route, { method = 'GET', body, contentType, signal, onEvent, rawResponse = false } = {}) {
29
+ const auth = await this.authorization();
30
+ const controller = new AbortController(); this.controllers.add(controller);
31
+ const cancelled = () => controller.abort(signal.reason);
32
+ signal?.addEventListener('abort', cancelled, { once: true });
33
+ if (signal?.aborted) cancelled();
34
+ let sent = false, status, timer, responseKind = 'none';
35
+ const started = Date.now();
36
+ const clientId = method === 'POST' && /^[a-f0-9-]{36}$/i.test(body?.request_id || '') ? body.request_id : undefined;
37
+ try {
38
+ return await new Promise((resolve, reject) => {
39
+ const session = http2.connect('https://chatgpt.com');
40
+ let request, settled = false, bytes = 0, text = '';
41
+ const current = () => auth.generation === this.generation
42
+ && identityKey(auth.identity) === identityKey(this.transport.profileIdentity);
43
+ const finish = (error, value) => {
44
+ if (settled) return; settled = true;
45
+ clearTimeout(timer); controller.signal.removeEventListener('abort', abort);
46
+ request?.destroy(); session.destroy();
47
+ if (error) {
48
+ error.details = { ...error.details, ...(status ? { upstreamStatus: status } : {}), responseKind };
49
+ if (!controller.signal.aborted) console.error(JSON.stringify({ time: new Date().toISOString(), event: 'dots-room-request', method,
50
+ route: route.split('?')[0], clientId, status: status || 0, responseKind,
51
+ ms: Date.now() - started, outcome: 'failed' }));
52
+ if (method !== 'GET' && !onEvent) error.deliveryUnknown = sent && !(status >= 400 && status < 500 && status !== 408);
53
+ reject(error);
54
+ } else resolve(value);
55
+ };
56
+ const abort = () => finish(controller.signal.reason || new Error('点点请求已取消。'));
57
+ controller.signal.addEventListener('abort', abort, { once: true });
58
+ session.on('error', error => finish(error));
59
+ if (controller.signal.aborted) { abort(); return; }
60
+ const roomRoute = method === 'POST' ? /^\/messaging\/rooms\/([^/]+)\/messages$/.exec(route)?.[1] : null;
61
+ request = session.request({ ':method': method, ':path': `/backend-api${route}`, ...auth.headers,
62
+ accept: onEvent ? 'text/event-stream' : 'application/json',
63
+ ...(body != null ? { 'content-type': contentType || 'application/json' } : {}),
64
+ ...(roomRoute && this.routes.has(roomRoute) ? { 'x-openai-thread-route': this.routes.get(roomRoute) } : {}) });
65
+ timer = setTimeout(() => finish(new Error('点点请求超时,请重新核对状态。')), 30000);
66
+ request.setEncoding('utf8');
67
+ request.on('response', headers => {
68
+ status = headers[':status'];
69
+ const type = String(headers['content-type'] || '').split(';')[0].trim().toLowerCase();
70
+ responseKind = headers['cf-mitigated'] === 'challenge' ? 'challenge'
71
+ : type === 'application/json' ? 'json' : type === 'text/html' ? 'html' : type === 'text/event-stream' ? 'sse' : 'other';
72
+ if (roomRoute && current()) {
73
+ // Desktop replaces the route on every send response, including removal.
74
+ this.routes.delete(roomRoute);
75
+ const routeHint = headers['x-openai-thread-route'];
76
+ if (typeof routeHint === 'string' && routeHint && routeHint.length <= 1024) {
77
+ if (this.routes.size >= 128) this.routes.clear();
78
+ this.routes.set(roomRoute, routeHint);
79
+ }
80
+ }
81
+ if (status < 200 || status >= 300) {
82
+ const message = status === 401 ? '点点登录状态需要恢复,请重新连接。'
83
+ : status === 403 ? '点点服务拒绝访问(HTTP 403),请稍后重试。'
84
+ : status === 404 ? '点点消息暂不可用,请稍后重新同步。'
85
+ : status === 429 ? '点点暂时繁忙,请稍后再试。' : '点点请求尚未完成,请稍后核对。';
86
+ finish(Object.assign(new Error(message), { status }));
87
+ } else if (roomRoute || onEvent) {
88
+ console.log(JSON.stringify({ time: new Date().toISOString(), event: 'dots-room-request', method,
89
+ route: route.split('?')[0], clientId, status, responseKind, ms: Date.now() - started, outcome: 'response' }));
90
+ }
91
+ });
92
+ request.on('data', chunk => {
93
+ if (!current()) { finish(new Error('点点账号已变化,请重新连接。')); return; }
94
+ bytes += Buffer.byteLength(chunk); text += chunk;
95
+ if (onEvent) {
96
+ text = text.replace(/\r\n/g, '\n');
97
+ let end;
98
+ while ((end = text.indexOf('\n\n')) >= 0) {
99
+ const frame = text.slice(0, end); text = text.slice(end + 2);
100
+ const data = frame.split('\n').filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n');
101
+ // Desktop's ChatGPT SSE treats [DONE] as completion, not JSON.
102
+ if (data.trim() === '[DONE]') { finish(null, null); return; }
103
+ if (data) try { onEvent(JSON.parse(data)); clearTimeout(timer); } catch (error) { finish(error); return; }
104
+ }
105
+ bytes = Buffer.byteLength(text);
106
+ }
107
+ if (bytes > 8 * 1024 * 1024) finish(new Error('点点响应超出单次读取上限。'));
108
+ });
109
+ request.on('error', error => finish(error));
110
+ request.on('end', () => {
111
+ if (!current()) { finish(new Error('点点账号已变化,请重新连接。')); return; }
112
+ try { finish(null, onEvent ? null : rawResponse ? text : text ? JSON.parse(text) : null); } catch (error) { finish(error); }
113
+ });
114
+ request.on('close', () => { if (!settled) finish(new Error('点点请求连接已断开。')); });
115
+ // end() can queue data before TLS connects. Only a ready HTTP/2 stream
116
+ // has been assigned an ID and may have submitted the request.
117
+ sent = !request.pending;
118
+ request.once('ready', () => { sent = true; });
119
+ if (body instanceof Blob) {
120
+ const source = Readable.fromWeb(body.stream());
121
+ source.on('error', error => finish(error));
122
+ request.on('close', () => source.destroy());
123
+ source.pipe(request);
124
+ } else request.end(body == null ? undefined : Buffer.isBuffer(body) ? body : JSON.stringify(body));
125
+ });
126
+ } finally { this.controllers.delete(controller); signal?.removeEventListener('abort', cancelled); }
127
+ }
128
+
129
+ async roomId(threadId) {
130
+ let profile = this.transport.liveProfiles.get(threadId);
131
+ if (!profile?.messagingRoomId) {
132
+ const value = await this.request(`/tbo/by-thread/${encodeURIComponent(threadId)}`);
133
+ profile = { messagingRoomId: value?.messaging_room_id };
134
+ }
135
+ if (typeof profile.messagingRoomId !== 'string' || !profile.messagingRoomId) throw new Error('点点消息房间暂不可用。');
136
+ return profile.messagingRoomId;
137
+ }
138
+
139
+ async room(id) {
140
+ const room = await this.request(`/messaging/rooms/${encodeURIComponent(id)}`);
141
+ if (room?.id !== id || room.type !== 'DM' || room.app_source !== 'chatgpt:messaging' || !Array.isArray(room.members)) {
142
+ throw new Error('点点消息房间身份不匹配。');
143
+ }
144
+ return room;
145
+ }
146
+
147
+ async messages(roomId, { before, after, around, limit = 20 } = {}) {
148
+ const query = new URLSearchParams({ limit: String(limit), ...(before ? { before } : {}),
149
+ ...(after ? { after } : {}), ...(around ? { around } : {}) });
150
+ const page = await this.request(`/messaging/rooms/${encodeURIComponent(roomId)}/messages?${query}`);
151
+ if (!Array.isArray(page?.items) || page.items.some(message => typeof message.id !== 'string' || !message.id)) throw new Error('点点消息页格式无效。');
152
+ // Null prev_cursor is also returned for nonempty middle pages. Desktop uses
153
+ // the first message ID; only an empty page proves that history is exhausted.
154
+ return { items: page.items, before: page.prev_cursor ?? page.items[0]?.id ?? null,
155
+ after: page.next_cursor ?? (after && page.items.length >= limit ? page.items.at(-1)?.id : null) };
156
+ }
157
+
158
+ close() {
159
+ super.close();
160
+ for (const controller of this.controllers) controller.abort(new Error('点点连接已结束。'));
161
+ this.controllers.clear(); this.routes.clear();
162
+ }
163
+ }
@@ -9,7 +9,7 @@ export async function uploadTaskFiles(files, { uploadsRoot, client, current, fet
9
9
  for (const file of uploads) {
10
10
  current();
11
11
  const reserved = await client.request('/files', { method: 'POST', body: {
12
- file_name: file.name, file_size: file.bytes.length, mime_type: file.mimeType,
12
+ file_name: file.name, file_size: file.bytes.size ?? file.bytes.length, mime_type: file.mimeType,
13
13
  use_case: 'codex', store_in_library: false, timezone_offset_min: new Date().getTimezoneOffset(), reset_rate_limits: false,
14
14
  } });
15
15
  current();
@@ -22,7 +22,7 @@ export async function uploadTaskFiles(files, { uploadsRoot, client, current, fet
22
22
  if (!uploadUrl) throw new Error('云端文件上传地址缺少参数。');
23
23
  const boundary = `relax-${randomUUID()}`;
24
24
  const name = file.name.replaceAll('"', '%22').replaceAll('\\', '%5C');
25
- const body = Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: ${file.mimeType}\r\n\r\n`),
25
+ const body = new Blob([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: ${file.mimeType}\r\n\r\n`),
26
26
  file.bytes, Buffer.from(`\r\n--${boundary}\r\nContent-Disposition: form-data; name="upload_url"\r\n\r\n${uploadUrl}\r\n--${boundary}--\r\n`)]);
27
27
  await client.request(url.pathname.slice('/backend-api'.length) + url.search, {
28
28
  method: 'POST', body, contentType: `multipart/form-data; boundary=${boundary}`, rawResponse: true,
@@ -56,9 +56,16 @@ export function createFileUploadSession(root) {
56
56
  completed.clear(); files.clear(); completedBytes = 0;
57
57
  };
58
58
  return {
59
- async receive(message) {
59
+ async receive(message, { relayed = false } = {}) {
60
60
  if (!message || typeof message !== 'object') throw new Error('无效的文件上传请求');
61
61
  if (message.type === 'cancel') { await clear(); return { type: 'cancelled' }; }
62
+ // Recheck the complete batch on every relayed request, including resume
63
+ // and commit after a direct connection has been replaced.
64
+ if (relayed) {
65
+ const originals = [...files.values()].filter(file => !file.previewOf);
66
+ if (originals.some(file => file.size > MAX_FILE_BYTES)) throw new Error('服务器转发仅支持单个文件不超过 20 MiB,请使用局域网或 RTC 直连');
67
+ if (originals.reduce((sum, file) => sum + file.size, 0) > MAX_TOTAL_BYTES) throw new Error('服务器转发仅支持每批合计不超过 40 MiB,请使用局域网或 RTC 直连');
68
+ }
62
69
  if (message.type === 'commit') {
63
70
  if (upload) throw new Error('文件尚未上传完成');
64
71
  committed = true;
@@ -86,14 +93,14 @@ export function createFileUploadSession(root) {
86
93
  if (committed) throw new Error('文件批次已提交');
87
94
  if (upload) throw new Error('已有文件正在上传');
88
95
  if (!validName(message.name)) throw new Error('文件名无效');
89
- if (!Number.isSafeInteger(message.size) || message.size < 1 || message.size > MAX_FILE_BYTES) {
90
- throw new Error('单个文件不得超过 20 MiB');
96
+ if (!Number.isSafeInteger(message.size) || message.size < 1) {
97
+ throw new Error('文件大小无效');
91
98
  }
99
+ if (relayed && message.size > MAX_FILE_BYTES) throw new Error('服务器转发仅支持单个文件不超过 20 MiB,请使用局域网或 RTC 直连');
92
100
  const parent = message.previewOf ? completed.get(message.previewOf) : null;
93
101
  if (message.previewOf && (!parent || parent.preview || message.size > FILE_CHUNK_BYTES)) throw new Error('预览图关联无效或超过 256 KiB');
94
- if (!parent && (completed.size >= MAX_FILE_COUNT || completedBytes + message.size > MAX_TOTAL_BYTES)) {
95
- throw new Error('每条消息最多 4 个文件,合计不超过 40 MiB');
96
- }
102
+ if (!parent && completed.size >= MAX_FILE_COUNT) throw new Error('每条消息最多 4 个文件');
103
+ if (!parent && relayed && completedBytes + message.size > MAX_TOTAL_BYTES) throw new Error('服务器转发仅支持每批合计不超过 40 MiB,请使用局域网或 RTC 直连');
97
104
  if (!/^[a-f0-9]{64}$/i.test(message.sha256 || '')) throw new Error('文件校验值无效');
98
105
  const id = crypto.randomUUID();
99
106
  await fs.mkdir(directory, { recursive: true });
@@ -84,7 +84,7 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
84
84
  if (binary) {
85
85
  if (!authenticated || purpose !== 'upload' || !chunk || bytes.length !== chunk.length) throw new Error('无效的文件分片');
86
86
  const current = chunk; chunk = undefined;
87
- reply(await upload.receive({ ...current, data: bytes })); return;
87
+ reply(await upload.receive({ ...current, data: bytes }, { relayed: fileRelay })); return;
88
88
  }
89
89
  if (bytes.length > 16 * 1024 || chunk) throw new Error('无效的文件请求');
90
90
  const message = JSON.parse(bytes.toString());
@@ -142,8 +142,8 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
142
142
  Object.assign(uploadEntry, { ws, released });
143
143
  upload = uploadEntry.session;
144
144
  }
145
- reply({ type: 'ready', version: 3, purpose, maxDownloadBytes: FILE_READ_LIMIT, maxFileBytes: MAX_FILE_BYTES, maxFileCount: MAX_FILE_COUNT,
146
- maxTotalBytes: MAX_TOTAL_BYTES, chunkBytes: FILE_CHUNK_BYTES, uploadSessionId: uploadId });
145
+ reply({ type: 'ready', version: 4, purpose, maxDownloadBytes: fileRelay ? FILE_READ_LIMIT : null, maxFileBytes: fileRelay ? MAX_FILE_BYTES : null, maxFileCount: MAX_FILE_COUNT,
146
+ maxTotalBytes: fileRelay ? MAX_TOTAL_BYTES : null, chunkBytes: FILE_CHUNK_BYTES, uploadSessionId: uploadId });
147
147
  return;
148
148
  }
149
149
  if (purpose === 'download') {
@@ -154,6 +154,7 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
154
154
  json(status, body) { reply({ type: 'error', status, error: body?.error || '文件读取失败' }); },
155
155
  filePart(metadata, data) {
156
156
  if (stopped || ws.readyState !== WebSocket.OPEN) return;
157
+ if (fileRelay && metadata.size > FILE_READ_LIMIT) throw new Error('服务器转发仅支持下载不超过 20 MiB 的文件,请使用局域网或 RTC 直连');
157
158
  if (!Buffer.isBuffer(data) || data.length > FILE_CHUNK_BYTES || ws.bufferedAmount > 1024 * 1024) throw new Error('文件接收过慢或分片无效');
158
159
  reply({ type: 'part', ...metadata, length: data.length });
159
160
  ws.send(data);
@@ -165,7 +166,7 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
165
166
  if (!Number.isSafeInteger(message.length) || message.length < 1 || message.length > FILE_CHUNK_BYTES) throw new Error('文件分片大小无效');
166
167
  chunk = message; return;
167
168
  }
168
- reply(await upload.receive(message));
169
+ reply(await upload.receive(message, { relayed: fileRelay }));
169
170
  if (message.type === 'commit') uploadEntry.committed = true;
170
171
  if (message.type === 'cancel') {
171
172
  await expireUpload(uploadId, uploadEntry);