@walkhi/code-relax 0.1.0-beta.27 → 0.1.0-beta.29

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.
Files changed (68) hide show
  1. package/README.md +80 -67
  2. package/dist/bin/codex-remote.mjs +303 -239
  3. package/dist/bin/self-relay-server.mjs +2 -0
  4. package/dist/shared/app-server-events.cjs +102 -16
  5. package/dist/shared/code-browser.cjs +159 -0
  6. package/dist/shared/file-changes.cjs +31 -0
  7. package/dist/shared/file-citations.cjs +117 -82
  8. package/dist/shared/file-mentions.cjs +80 -35
  9. package/dist/shared/message-delivery.cjs +38 -0
  10. package/dist/shared/p2p-file-channel.cjs +74 -0
  11. package/dist/shared/server-requests.cjs +149 -41
  12. package/dist/shared/service-endpoints.cjs +11 -11
  13. package/dist/shared/timeline-sync.cjs +173 -156
  14. package/dist/skills/code-relax/SKILL.md +2 -1
  15. package/dist/src/bounded-process-log.mjs +63 -0
  16. package/dist/src/celsius-client.mjs +107 -0
  17. package/dist/src/code-browser.mjs +101 -0
  18. package/dist/src/desktop-launcher.mjs +30 -30
  19. package/dist/src/dots-approval-timeline.mjs +99 -0
  20. package/dist/src/dots-avatar.mjs +42 -0
  21. package/dist/src/dots-chat-projection.mjs +50 -0
  22. package/dist/src/dots-chat-server.mjs +412 -0
  23. package/dist/src/dots-message-images.mjs +319 -0
  24. package/dist/src/dots-profile.mjs +77 -0
  25. package/dist/src/dots-room-activity.mjs +94 -0
  26. package/dist/src/dots-room-client.mjs +135 -0
  27. package/dist/src/dots-room-projection.mjs +94 -0
  28. package/dist/src/dots-schedules.mjs +66 -0
  29. package/dist/src/dots-summary-tasks.mjs +32 -0
  30. package/dist/src/dots-task-catalog.mjs +411 -0
  31. package/dist/src/file-transfer-range.mjs +22 -0
  32. package/dist/src/file-upload-session.mjs +130 -124
  33. package/dist/src/hosted-history-app-server.mjs +103 -0
  34. package/dist/src/hosted-history-cache.mjs +113 -0
  35. package/dist/src/lan-socket-server.mjs +205 -160
  36. package/dist/src/managed-app-server.mjs +241 -238
  37. package/dist/src/message-upload-files.mjs +74 -0
  38. package/dist/src/platform/windows/activate-desktop.ps1 +48 -0
  39. package/dist/src/platform/windows/codex-runtime.mjs +253 -251
  40. package/dist/src/platform/windows/desktop-host.mjs +38 -32
  41. package/dist/src/platform/windows/desktop-info.ps1 +2 -2
  42. package/dist/src/platform/windows/desktop-project-registration.mjs +153 -0
  43. package/dist/src/platform/windows/dots-profile.mjs +49 -0
  44. package/dist/src/platform/windows/launch-worker.mjs +41 -42
  45. package/dist/src/platform/windows/project-locations.mjs +16 -0
  46. package/dist/src/project-create.mjs +119 -0
  47. package/dist/src/project-management.mjs +69 -0
  48. package/dist/src/self-relay/account-connections.mjs +52 -0
  49. package/dist/src/self-relay/admin-page.mjs +80 -80
  50. package/dist/src/self-relay/admin-style.mjs +14 -9
  51. package/dist/src/self-relay/connector.mjs +23 -5
  52. package/dist/src/self-relay/email-accounts.mjs +35 -10
  53. package/dist/src/self-relay/file-wire.mjs +13 -0
  54. package/dist/src/self-relay/lifecycle.mjs +22 -4
  55. package/dist/src/self-relay/long-pairing.mjs +40 -0
  56. package/dist/src/self-relay/p2p-probe.mjs +152 -123
  57. package/dist/src/self-relay/rtc-file-bridge.mjs +41 -0
  58. package/dist/src/self-relay/server.mjs +922 -849
  59. package/dist/src/server.mjs +474 -199
  60. package/dist/src/service-control.mjs +13 -3
  61. package/dist/src/service-doctor.mjs +72 -61
  62. package/dist/src/service-lifecycle.mjs +51 -43
  63. package/dist/src/shared-app-server.mjs +166 -107
  64. package/dist/src/shared-catalog.mjs +132 -40
  65. package/dist/src/shared-thread-stream.mjs +103 -25
  66. package/dist/src/usage-reset.mjs +17 -0
  67. package/dist/src/workstation-downloads.mjs +120 -0
  68. package/package.json +1 -1
@@ -1,92 +1,124 @@
1
1
  import { EventEmitter } from 'node:events';
2
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';
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';
6
7
 
7
8
  // One authenticated connection carries requests and explicitly owned subscriptions.
8
9
  // The dispatcher is shared business routing, not an HTTP loopback proxy.
9
- export function attachLanSocket(server, { token, dispatch, authorizeDevice = () => false, onDeviceAuthorizationsChanged, uploadsRoot }) {
10
- // Allow four individually valid images to reach the aggregate-size validator,
11
- // so an oversized composition returns an error instead of dropping the connection.
12
- const wss = new WebSocketServer({ noServer: true, maxPayload: 48 * 1024 * 1024, perMessageDeflate: false });
13
- const uploadWss = new WebSocketServer({ noServer: true, maxPayload: 512 * 1024, perMessageDeflate: false });
14
- const activeUploads = new Set();
15
- const deviceConnections = new Map();
16
- const stopWatching = onDeviceAuthorizationsChanged?.(() => {
17
- for (const connection of deviceConnections.values()) {
18
- if (!authorizeDevice(connection.clientId, connection.credential)) connection.revoke();
19
- }
20
- });
21
- server.on('upgrade', (request, socket, head) => {
10
+ export function attachLanSocket(server, { token, dispatch, authorizeDevice = () => false, onDeviceAuthorizationsChanged, uploadsRoot }) {
11
+ // Allow four individually valid images to reach the aggregate-size validator,
12
+ // so an oversized composition returns an error instead of dropping the connection.
13
+ const wss = new WebSocketServer({ noServer: true, maxPayload: 48 * 1024 * 1024, perMessageDeflate: false });
14
+ const uploadWss = new WebSocketServer({ noServer: true, maxPayload: 512 * 1024, perMessageDeflate: false });
15
+ const activeUploads = new Map();
16
+ const deviceConnections = new Map();
17
+ const stopWatching = onDeviceAuthorizationsChanged?.(() => {
18
+ for (const connection of deviceConnections.values()) {
19
+ if (!authorizeDevice(connection.clientId, connection.credential)) connection.revoke();
20
+ }
21
+ });
22
+ server.on('upgrade', (request, socket, head) => {
22
23
  const origin = request.headers.origin;
23
- const upload = request.url === '/api/file-transfer' && Boolean(uploadsRoot);
24
- let allowed = request.url === '/api/socket' || upload;
25
- // Packaged ArkWeb pages have an opaque ("null") origin; device authentication remains required.
26
- if (origin && origin !== 'null') {
27
- try {
28
- const originUrl = new URL(origin);
29
- const requestUrl = new URL(`http://${request.headers.host}`);
30
- const nativeClient = request.headers['x-code-relax-native'] === '1';
31
- allowed &&= ['http:', 'https:'].includes(originUrl.protocol) &&
32
- (originUrl.host === request.headers.host || (nativeClient && originUrl.hostname === requestUrl.hostname));
33
- }
34
- catch { allowed = false; }
24
+ const upload = request.url === '/api/file-transfer' && Boolean(uploadsRoot);
25
+ let allowed = request.url === '/api/socket' || upload;
26
+ // Packaged ArkWeb pages have an opaque ("null") origin; device authentication remains required.
27
+ if (origin && origin !== 'null') {
28
+ try {
29
+ const originUrl = new URL(origin);
30
+ const requestUrl = new URL(`http://${request.headers.host}`);
31
+ const nativeClient = request.headers['x-code-relax-native'] === '1';
32
+ allowed &&= ['http:', 'https:'].includes(originUrl.protocol) &&
33
+ (originUrl.host === request.headers.host || (nativeClient && originUrl.hostname === requestUrl.hostname));
34
+ }
35
+ catch { allowed = false; }
35
36
  }
36
37
  if (!allowed) { socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); return; }
37
- const target = upload ? uploadWss : wss;
38
- target.handleUpgrade(request, socket, head, ws => target.emit('connection', ws));
39
- });
40
- uploadWss.on('connection', ws => {
41
- let authenticated = false, stopped = false, identity = '', upload;
42
- let pending = Promise.resolve();
43
- const authTimer = setTimeout(() => ws.close(1008, 'Authentication required'), 5000);
44
- const reply = value => { if (!stopped && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(value)); };
45
- ws.on('error', () => {});
46
- const stop = () => {
47
- if (stopped) return;
48
- stopped = true;
49
- deviceConnections.delete(ws);
50
- clearTimeout(authTimer);
51
- void pending.finally(async () => {
52
- await upload?.close();
53
- if (identity) activeUploads.delete(identity);
54
- });
55
- };
56
- ws.on('close', stop);
57
- ws.on('message', (bytes, binary) => {
58
- if (stopped || ws.readyState !== WebSocket.OPEN) return;
59
- pending = pending.then(async () => {
60
- if (stopped || ws.readyState !== WebSocket.OPEN) return;
61
- if (binary || bytes.length > 512 * 1024) throw new Error('无效的上传分片');
62
- const message = JSON.parse(bytes.toString());
63
- if (!authenticated) {
64
- if (message?.type === 'device-auth' && typeof message.clientId === 'string' &&
65
- authorizeDevice(message.clientId, message.credential) === true) identity = `device:${message.clientId}`;
66
- else if (token && message?.type === 'auth' && typeof message.token === 'string') {
67
- const supplied = Buffer.from(message.token), expected = Buffer.from(token || '');
68
- if (supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected)) identity = 'local-token';
69
- }
70
- if (!identity || activeUploads.has(identity) || activeUploads.size >= 2) throw new Error('上传身份无效或工作站已有上传任务');
71
- if (message.type === 'device-auth') deviceConnections.set(ws, {
72
- clientId: message.clientId, credential: message.credential,
73
- revoke() { stop(); ws.close(4401, 'Device authorization revoked'); },
74
- });
75
- activeUploads.add(identity); authenticated = true; clearTimeout(authTimer);
76
- upload = createFileUploadSession(uploadsRoot);
77
- reply({ type: 'ready', maxFileBytes: MAX_FILE_BYTES, maxFileCount: MAX_FILE_COUNT,
78
- maxTotalBytes: MAX_TOTAL_BYTES, chunkBytes: FILE_CHUNK_BYTES });
79
- return;
80
- }
81
- reply(await upload.receive(message));
82
- }).catch(error => { reply({ type: 'error', error: error.message }); ws.close(1008, 'Upload rejected'); });
83
- });
84
- });
38
+ const target = upload ? uploadWss : wss;
39
+ target.handleUpgrade(request, socket, head, ws => target.emit('connection', ws));
40
+ });
41
+ uploadWss.on('connection', ws => {
42
+ let authenticated = false, stopped = false, identity = '', upload, purpose, chunk;
43
+ let queued = 0;
44
+ let pending = Promise.resolve();
45
+ const authTimer = setTimeout(() => ws.close(1008, 'Authentication required'), 5000);
46
+ const reply = value => { if (!stopped && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(value)); };
47
+ ws.on('error', () => {});
48
+ const stop = () => {
49
+ if (stopped) return;
50
+ stopped = true;
51
+ deviceConnections.delete(ws);
52
+ clearTimeout(authTimer);
53
+ void pending.finally(async () => {
54
+ await upload?.close();
55
+ activeUploads.delete(ws);
56
+ });
57
+ };
58
+ ws.on('close', stop);
59
+ ws.on('message', (bytes, binary) => {
60
+ if (stopped || ws.readyState !== WebSocket.OPEN) return;
61
+ if (++queued > 4) { stop(); ws.close(1013, 'File request queue exceeded'); return; }
62
+ pending = pending.then(async () => {
63
+ if (stopped || ws.readyState !== WebSocket.OPEN) return;
64
+ if (binary) {
65
+ if (!authenticated || purpose !== 'upload' || !chunk || bytes.length !== chunk.length) throw new Error('无效的文件分片');
66
+ const current = chunk; chunk = undefined;
67
+ reply(await upload.receive({ ...current, data: bytes })); return;
68
+ }
69
+ if (bytes.length > 16 * 1024 || chunk) throw new Error('无效的文件请求');
70
+ const message = JSON.parse(bytes.toString());
71
+ if (!authenticated) {
72
+ purpose = message.purpose;
73
+ if (!['upload', 'download'].includes(purpose)) throw new Error('文件通道用途无效');
74
+ if (message?.type === 'device-auth' && typeof message.clientId === 'string' &&
75
+ authorizeDevice(message.clientId, message.credential) === true) identity = `device:${message.clientId}`;
76
+ else if (token && message?.type === 'auth' && typeof message.token === 'string') {
77
+ const supplied = Buffer.from(message.token), expected = Buffer.from(token || '');
78
+ if (supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected)) identity = 'local-token';
79
+ }
80
+ const same = [...activeUploads.values()].filter(item => item.identity === identity && item.purpose === purpose);
81
+ const uploads = [...activeUploads.values()].filter(item => item.purpose === 'upload').length;
82
+ if (!identity || same.length >= (purpose === 'upload' ? 1 : 3) || activeUploads.size >= 8 ||
83
+ (purpose === 'upload' && uploads >= 2)) throw new Error('文件身份无效或文件任务过多');
84
+ if (message.type === 'device-auth') deviceConnections.set(ws, {
85
+ clientId: message.clientId, credential: message.credential,
86
+ revoke() { stop(); ws.close(4401, 'Device authorization revoked'); },
87
+ });
88
+ activeUploads.set(ws, { identity, purpose }); authenticated = true; clearTimeout(authTimer);
89
+ if (purpose === 'upload') upload = createFileUploadSession(uploadsRoot);
90
+ reply({ type: 'ready', version: 2, purpose, maxDownloadBytes: FILE_READ_LIMIT, maxFileBytes: MAX_FILE_BYTES, maxFileCount: MAX_FILE_COUNT,
91
+ maxTotalBytes: MAX_TOTAL_BYTES, chunkBytes: FILE_CHUNK_BYTES });
92
+ return;
93
+ }
94
+ if (purpose === 'download') {
95
+ if (message.type !== 'read' || typeof message.path !== 'string' || message.path.length > 12000 ||
96
+ !/^\/api\/(?:images\/[a-f0-9]{64}|files\/[a-f0-9]{64}|code\/(?:image|binary))(?:\?|$)/.test(message.path) || message.path.includes('#')) throw new Error('无效的文件读取请求');
97
+ await dispatch({ method: 'GET', url: message.path, headers: {}, socketRequest: true, fileTransfer: true }, {
98
+ json(status, body) { reply({ type: 'error', status, error: body?.error || '文件读取失败' }); },
99
+ filePart(metadata, data) {
100
+ if (stopped || ws.readyState !== WebSocket.OPEN) return;
101
+ if (!Buffer.isBuffer(data) || data.length > FILE_CHUNK_BYTES || ws.bufferedAmount > 1024 * 1024) throw new Error('文件接收过慢或分片无效');
102
+ reply({ type: 'part', ...metadata, length: data.length });
103
+ ws.send(data);
104
+ },
105
+ });
106
+ return;
107
+ }
108
+ if (message.type === 'chunk') {
109
+ if (!Number.isSafeInteger(message.length) || message.length < 1 || message.length > FILE_CHUNK_BYTES) throw new Error('文件分片大小无效');
110
+ chunk = message; return;
111
+ }
112
+ reply(await upload.receive(message));
113
+ }).catch(error => { reply({ type: 'error', error: error.message }); stop(); ws.close(1008, 'File request rejected'); })
114
+ .finally(() => { queued--; });
115
+ });
116
+ });
85
117
  wss.on('connection', ws => {
86
- let authenticated = false, alive = true, stopped = false;
118
+ let authenticated = false, alive = true, stopped = false;
87
119
  const active = new Map();
88
120
  const send = value => {
89
- if (stopped || ws.readyState !== WebSocket.OPEN) return;
121
+ if (stopped || ws.readyState !== WebSocket.OPEN) return;
90
122
  if (ws.bufferedAmount > 64 * 1024 * 1024) { ws.close(1013, 'Client too slow'); return; }
91
123
  ws.send(JSON.stringify(value));
92
124
  };
@@ -97,49 +129,51 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
97
129
  }, 30000);
98
130
  ws.on('pong', () => { alive = true; });
99
131
  ws.on('error', () => {}); // close rejects requests and releases every subscription.
100
- const stop = () => {
101
- if (stopped) return;
102
- stopped = true;
103
- deviceConnections.delete(ws);
104
- clearTimeout(authTimer); clearInterval(heartbeat);
132
+ const stop = () => {
133
+ if (stopped) return;
134
+ stopped = true;
135
+ deviceConnections.delete(ws);
136
+ clearTimeout(authTimer); clearInterval(heartbeat);
105
137
  for (const reply of active.values()) reply.end();
106
138
  active.clear();
107
- };
108
- ws.on('close', stop);
109
- ws.on('message', async (bytes, binary) => {
110
- if (stopped || ws.readyState !== WebSocket.OPEN) return;
139
+ };
140
+ ws.on('close', stop);
141
+ ws.on('message', async (bytes, binary) => {
142
+ if (stopped || ws.readyState !== WebSocket.OPEN) return;
111
143
  let message;
112
144
  try { if (binary) throw new Error(); message = JSON.parse(bytes.toString()); }
113
145
  catch { ws.close(1008, 'Invalid JSON'); return; }
114
146
  if (!message || typeof message !== 'object') { ws.close(1008, 'Invalid message'); return; }
115
- if (!authenticated) {
116
- let accepted = false;
117
- if (message.type === 'device-auth') {
118
- accepted = authorizeDevice(message.clientId, message.credential) === true;
119
- } else if (message.type === 'auth') {
120
- const supplied = Buffer.from(typeof message.token === 'string' ? message.token : '');
121
- const expected = Buffer.from(token || '');
122
- accepted = supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected);
123
- }
124
- if (!accepted) {
125
- ws.close(1008, 'Authentication failed'); return;
126
- }
127
- if (message.type === 'device-auth') deviceConnections.set(ws, {
128
- clientId: message.clientId, credential: message.credential,
129
- revoke() { stop(); ws.close(4401, 'Device authorization revoked'); },
130
- });
147
+ if (!authenticated) {
148
+ let accepted = false;
149
+ if (message.type === 'device-auth') {
150
+ accepted = authorizeDevice(message.clientId, message.credential) === true;
151
+ } else if (message.type === 'auth') {
152
+ const supplied = Buffer.from(typeof message.token === 'string' ? message.token : '');
153
+ const expected = Buffer.from(token || '');
154
+ accepted = supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected);
155
+ }
156
+ if (!accepted) {
157
+ ws.close(1008, 'Authentication failed'); return;
158
+ }
159
+ if (message.type === 'device-auth') deviceConnections.set(ws, {
160
+ clientId: message.clientId, credential: message.credential,
161
+ revoke() { stop(); ws.close(4401, 'Device authorization revoked'); },
162
+ });
131
163
  authenticated = true; clearTimeout(authTimer); send({ type: 'ready', version: 1 }); return;
132
164
  }
133
- const { id, type } = message;
134
- if (!Number.isSafeInteger(id) || id < 1) { ws.close(1008, 'Invalid request id'); return; }
135
- if (type === 'response-ack') { active.get(id)?.acknowledge?.(message.batch); return; }
136
- if (type === 'cancel') { active.get(id)?.end(); return; }
165
+ const { id, type } = message;
166
+ if (!Number.isSafeInteger(id) || id < 1) { ws.close(1008, 'Invalid request id'); return; }
167
+ if (type === 'response-ack') { active.get(id)?.acknowledge?.(message.batch); return; }
168
+ if (type === 'cancel') { active.get(id)?.end(); return; }
137
169
  if (type === 'unsubscribe') { const reply = active.get(id); if (reply?.subscription) reply.end(); return; }
138
170
  if (!['request', 'subscribe'].includes(type) || active.has(id)) { ws.close(1008, 'Invalid request'); return; }
139
171
  const subscription = type === 'subscribe';
140
172
  const path = message.path;
141
- const isStream = typeof path === 'string' && /^\/api\/(?:usage-events|turn-events|shared-recovery\/events|threads\/[^/?]+\/events)(?:\?|$)/.test(path);
142
- if (active.size >= 32 || (subscription && [...active.values()].filter(r => r.subscription).length >= 4)) {
173
+ const isStream = typeof path === 'string' && /^\/api\/(?:usage-events|thread-events|turn-events|shared-recovery\/events|threads\/[^/?]+\/(?:events|dots-activity-events))(?:\?|$)/.test(path);
174
+ // Native recovery, usage, catalog, current chat, and the main Dot's sidebar
175
+ // share one station connection (including when viewing a delegated task).
176
+ if (active.size >= 32 || (subscription && [...active.values()].filter(r => r.subscription).length >= 5)) {
143
177
  send({ type: 'response', id, status: 429, body: { error: '同时进行的请求过多' } }); return;
144
178
  }
145
179
  if (typeof path !== 'string' || !path.startsWith('/api/') || path.includes('#') ||
@@ -149,58 +183,69 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
149
183
  (message.body !== undefined && (!message.body || typeof message.body !== 'object' || Array.isArray(message.body)))) {
150
184
  send({ type: 'response', id, status: 400, body: { error: '无效的连接请求' } }); return;
151
185
  }
152
- const reply = new EventEmitter();
153
- let waiting, batch = 0;
154
- const sendTimeline = async (status, body, event) => {
155
- batch = 0;
156
- try {
157
- for (const pieces of timelineSync.timelineBatches(body)) {
158
- if (reply.writableEnded) return;
159
- await new Promise((resolve, reject) => {
160
- const timer = setTimeout(() => reject(new Error('历史接收确认超时')), 20000);
161
- waiting = { resolve, reject, timer, batch: ++batch };
162
- send({ type: event ? 'event-part' : 'response-part', id, batch, pieces });
163
- });
164
- }
165
- if (!reply.writableEnded) {
166
- send(event ? { type: 'event', id, event, streamed: true } : { type: 'response', id, status, streamed: true });
167
- if (!event) reply.end();
168
- }
169
- } catch (error) {
170
- if (!reply.writableEnded) {
171
- send(event ? { type: 'event', id, event: 'bridge-error', data: { error: error.message } }
172
- : { type: 'response', id, status: 500, body: { error: error.message } });
173
- reply.end();
174
- }
175
- }
176
- };
177
- Object.assign(reply, {
186
+ const reply = new EventEmitter();
187
+ let waiting, batch = 0;
188
+ const sendPart = (pieces, pageEnd = false) => new Promise((resolve, reject) => {
189
+ const timer = setTimeout(() => reject(new Error('历史接收确认超时')), 20000);
190
+ waiting = { resolve, reject, timer, batch: ++batch };
191
+ send({ type: subscription ? 'event-part' : 'response-part', id, batch, pieces, ...(pageEnd ? { pageEnd: true } : {}) });
192
+ });
193
+ const sendTimeline = async (status, body, event) => {
194
+ if (event) batch = 0;
195
+ try {
196
+ for (const pieces of timelineSync.timelineBatches(body)) {
197
+ if (reply.writableEnded) return;
198
+ await sendPart(pieces);
199
+ }
200
+ if (!reply.writableEnded) {
201
+ send(event ? { type: 'event', id, event, streamed: true } : { type: 'response', id, status, streamed: true });
202
+ if (!event) reply.end();
203
+ }
204
+ } catch (error) {
205
+ if (!reply.writableEnded) {
206
+ send(event ? { type: 'event', id, event: 'bridge-error', data: { error: error.message } }
207
+ : { type: 'response', id, status: 500, body: { error: error.message } });
208
+ reply.end();
209
+ }
210
+ }
211
+ };
212
+ Object.assign(reply, {
178
213
  subscription, headersSent: false, writableEnded: false, destroyed: false,
179
- json(status, body) {
180
- if (this.writableEnded) return;
181
- if (status < 400 && body?.schemaVersion === 1 && Array.isArray(body.turns)) {
182
- void sendTimeline(status, body); return;
183
- }
184
- send({ type: 'response', id, status, body }); this.end();
185
- },
186
- acknowledge(sequence) {
187
- if (!waiting || sequence !== waiting.batch) return;
188
- const completed = waiting; waiting = null; clearTimeout(completed.timer);
189
- this.emit('progress'); completed.resolve();
190
- },
214
+ async historyPage(page) {
215
+ const parts = timelineSync.timelineBatches(page);
216
+ let part = parts.next();
217
+ while (!part.done) {
218
+ if (this.writableEnded) throw new Error('历史读取已取消');
219
+ const next = parts.next();
220
+ await sendPart(part.value, next.done);
221
+ part = next;
222
+ }
223
+ },
224
+ json(status, body) {
225
+ if (this.writableEnded) return;
226
+ if (status < 400 && body?.schemaVersion === 1 && Array.isArray(body.turns)) {
227
+ void sendTimeline(status, body); return;
228
+ }
229
+ send({ type: 'response', id, status, body }); this.end();
230
+ },
231
+ acknowledge(sequence) {
232
+ if (!waiting || sequence !== waiting.batch) return;
233
+ const completed = waiting; waiting = null; clearTimeout(completed.timer);
234
+ this.emit('progress'); completed.resolve();
235
+ },
191
236
  startEvents() {
192
237
  if (this.writableEnded) return;
193
238
  this.headersSent = true; send({ type: 'subscribed', id });
194
239
  },
195
- event(event, data) {
196
- if (this.writableEnded) return;
197
- if (event === 'update' && data?.schemaVersion === 1) return sendTimeline(200, data, event);
198
- send({ type: 'event', id, event, data });
199
- },
240
+ event(event, data) {
241
+ if (this.writableEnded) return;
242
+ if (event === 'update' && data?.schemaVersion === 1) return sendTimeline(200, data, event);
243
+ send({ type: 'event', id, event, data });
244
+ },
200
245
  end() {
201
246
  if (this.writableEnded) return;
202
- this.writableEnded = this.destroyed = true; active.delete(id);
203
- if (waiting) { clearTimeout(waiting.timer); waiting.reject(new Error('历史读取已取消')); waiting = null; }
247
+ this.writableEnded = this.destroyed = true; active.delete(id);
248
+ if (waiting) { clearTimeout(waiting.timer); waiting.reject(new Error('历史读取已取消')); waiting = null; }
204
249
  if (subscription) send({ type: 'end', id });
205
250
  this.emit('close');
206
251
  },
@@ -210,9 +255,9 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
210
255
  catch (error) { reply.json(500, { error: error.message }); }
211
256
  });
212
257
  });
213
- return { close() {
214
- stopWatching?.();
215
- for (const ws of wss.clients) ws.terminate(); wss.close();
216
- for (const ws of uploadWss.clients) ws.terminate(); uploadWss.close();
217
- } };
218
- }
258
+ return { close() {
259
+ stopWatching?.();
260
+ for (const ws of wss.clients) ws.terminate(); wss.close();
261
+ for (const ws of uploadWss.clients) ws.terminate(); uploadWss.close();
262
+ } };
263
+ }