@walkhi/code-relax 0.1.0-beta.2 → 0.1.0-beta.20

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 (120) hide show
  1. package/README.md +55 -71
  2. package/dist/bin/codex-remote.mjs +150 -126
  3. package/dist/bin/manage-relay-admin.mjs +20 -0
  4. package/dist/bin/migrate-relay-db.mjs +107 -0
  5. package/dist/bin/self-relay-admin.mjs +8 -0
  6. package/dist/bin/self-relay-demo.mjs +2 -2
  7. package/dist/bin/self-relay-server.mjs +18 -10
  8. package/dist/shared/app-server-events.cjs +42 -15
  9. package/dist/shared/file-mentions.cjs +35 -0
  10. package/dist/shared/server-requests.cjs +86 -58
  11. package/dist/skills/code-relax/SKILL.md +7 -6
  12. package/dist/src/desktop-launcher.mjs +16 -21
  13. package/dist/src/file-upload-session.mjs +124 -0
  14. package/dist/src/lan-socket-server.mjs +52 -9
  15. package/dist/src/managed-app-server.mjs +44 -13
  16. package/dist/src/message-images.mjs +0 -18
  17. package/dist/src/onboarding.mjs +9 -11
  18. package/dist/src/platform/README.md +1 -1
  19. package/dist/src/platform/windows/background-process.mjs +1 -1
  20. package/dist/src/platform/windows/codex-runtime.mjs +251 -0
  21. package/dist/src/platform/windows/desktop-host.mjs +17 -3
  22. package/dist/src/platform/windows/desktop-info.ps1 +2 -4
  23. package/dist/src/platform/windows/desktop-runtime.ps1 +10 -0
  24. package/dist/src/platform/windows/desktop-shortcuts.ps1 +7 -14
  25. package/dist/src/platform/windows/launch-worker.mjs +42 -0
  26. package/dist/src/platform/windows/resident-start.js +20 -0
  27. package/dist/src/platform/windows/resident-startup.ps1 +76 -20
  28. package/dist/src/platform/windows/service-host.mjs +4 -16
  29. package/dist/src/platform/windows/start-hidden-console.ps1 +10 -5
  30. package/dist/src/platform/windows/stop-desktop.ps1 +7 -4
  31. package/dist/src/platform/windows/user-environment.mjs +1 -1
  32. package/dist/src/runtime-discovery.mjs +43 -0
  33. package/dist/src/self-relay/admin-cloud.mjs +101 -0
  34. package/dist/src/self-relay/admin-page.mjs +79 -0
  35. package/dist/src/self-relay/admin-server.mjs +66 -0
  36. package/dist/src/self-relay/admin-state.mjs +82 -19
  37. package/dist/src/self-relay/admin-style.mjs +9 -0
  38. package/dist/src/self-relay/admin-usage-page.mjs +28 -0
  39. package/dist/src/self-relay/connector.mjs +243 -30
  40. package/dist/src/self-relay/demo.mjs +3 -4
  41. package/dist/src/self-relay/email-accounts.mjs +141 -0
  42. package/dist/src/self-relay/lifecycle.mjs +99 -142
  43. package/dist/src/self-relay/mysql-state.mjs +104 -0
  44. package/dist/src/self-relay/p2p-probe.mjs +1 -1
  45. package/dist/src/self-relay/server.mjs +335 -59
  46. package/dist/src/self-relay/usage-diagnostics.mjs +46 -0
  47. package/dist/src/self-relay/usage-meter.mjs +125 -0
  48. package/dist/src/server.mjs +731 -1439
  49. package/dist/src/service-control.mjs +4 -4
  50. package/dist/src/service-doctor.mjs +27 -29
  51. package/dist/src/service-health.mjs +2 -2
  52. package/dist/src/service-install.mjs +1 -4
  53. package/dist/src/service-lifecycle.mjs +19 -19
  54. package/dist/src/shared-app-server.mjs +277 -270
  55. package/dist/src/shared-catalog.mjs +1 -11
  56. package/dist/src/shared-recovery.mjs +11 -9
  57. package/dist/src/shared-thread-stream.mjs +1 -1
  58. package/dist/src/skill-install.mjs +0 -13
  59. package/package.json +17 -18
  60. package/tools/postinstall.mjs +69 -15
  61. package/dist/src/app-server-client.mjs +0 -468
  62. package/dist/src/app-server-tasks.mjs +0 -358
  63. package/dist/src/debug-ui.mjs +0 -131
  64. package/dist/src/platform/windows/desktop-monitor.mjs +0 -34
  65. package/dist/src/platform/windows/desktop-shortcut-run.ps1 +0 -5
  66. package/dist/src/platform/windows/desktop-tools.mjs +0 -48
  67. package/dist/src/platform/windows/launch-worker.ps1 +0 -20
  68. package/dist/src/thread-catalog.mjs +0 -47
  69. package/dist/tools/find-desktop-pipe.mjs +0 -10
  70. package/dist/web/activity-view.js +0 -283
  71. package/dist/web/approval-ui.js +0 -188
  72. package/dist/web/capabilities.js +0 -57
  73. package/dist/web/chat-transport.js +0 -202
  74. package/dist/web/chat.css +0 -782
  75. package/dist/web/chat.js +0 -3830
  76. package/dist/web/client-platform.js +0 -11
  77. package/dist/web/community-view.js +0 -20
  78. package/dist/web/community.css +0 -77
  79. package/dist/web/community.html +0 -37
  80. package/dist/web/composer-controller.js +0 -101
  81. package/dist/web/conversation-controller.js +0 -99
  82. package/dist/web/disclosure-state-controller.js +0 -95
  83. package/dist/web/draft-controller.js +0 -103
  84. package/dist/web/harmony-platform.js +0 -52
  85. package/dist/web/highlight-powershell.min.js +0 -39
  86. package/dist/web/highlight.min.js +0 -1262
  87. package/dist/web/history-cache.js +0 -240
  88. package/dist/web/history-controller.js +0 -167
  89. package/dist/web/index.html +0 -325
  90. package/dist/web/lan-session.js +0 -122
  91. package/dist/web/link-action-controller.js +0 -212
  92. package/dist/web/message-send-controller.js +0 -177
  93. package/dist/web/message-view.js +0 -98
  94. package/dist/web/p2p-data-channel.js +0 -50
  95. package/dist/web/p2p-diagnostics.js +0 -28
  96. package/dist/web/p2p-probe.js +0 -123
  97. package/dist/web/page-resume.js +0 -28
  98. package/dist/web/pending-message-store.js +0 -108
  99. package/dist/web/queue-controller.js +0 -82
  100. package/dist/web/resources.json +0 -1
  101. package/dist/web/self-relay-session.js +0 -129
  102. package/dist/web/server-requests.js +0 -67
  103. package/dist/web/station-connection-controller.js +0 -75
  104. package/dist/web/task-list-view.js +0 -296
  105. package/dist/web/thread-attention-controller.js +0 -124
  106. package/dist/web/thread-context-controller.js +0 -30
  107. package/dist/web/thread-list-controller.js +0 -61
  108. package/dist/web/thread-list-sync.js +0 -86
  109. package/dist/web/thread-title-controller.js +0 -57
  110. package/dist/web/timeline-formatters.js +0 -249
  111. package/dist/web/timeline-reducer.js +0 -81
  112. package/dist/web/timeline-renderer.js +0 -161
  113. package/dist/web/timeline-scroll-controller.js +0 -50
  114. package/dist/web/timeline-sync.js +0 -67
  115. package/dist/web/usage-controller.js +0 -258
  116. package/dist/web/vendor/README.md +0 -14
  117. package/dist/web/vendor/highlight-powershell.min.js +0 -39
  118. package/dist/web/vendor/highlight.LICENSE.txt +0 -29
  119. package/dist/web/vendor/highlight.min.js +0 -1262
  120. package/dist/web/vendor/lucide.LICENSE.txt +0 -17
@@ -1,16 +1,24 @@
1
- import http from 'node:http';
1
+ import http from 'node:http';
2
+ import fs from 'node:fs';
3
+ import { isIP } from 'node:net';
2
4
  import { deviceStore, validId } from './device-store.mjs';
3
- import { randomUUID } from 'node:crypto';
5
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
4
6
  import { WebSocketServer } from 'ws';
5
7
  import { secret, digest, matches, validSecret, send, decode } from './wire.mjs';
6
8
  import { readDebugAllowlist, debugAllowed } from './debug-allowlist.mjs';
7
9
  import { validProbeSignal } from './p2p-probe.mjs';
8
10
  import { relayAdminState } from './admin-state.mjs';
11
+ import { openRelayMysql } from './mysql-state.mjs';
12
+ import { adminPage } from './admin-page.mjs';
13
+ import { openEmailAccounts } from './email-accounts.mjs';
14
+ import { usageMeter, usageReport } from './usage-meter.mjs';
15
+ import { usageDiagnostics } from './usage-diagnostics.mjs';
9
16
 
10
17
  // Always loopback; a TLS reverse proxy supplies the private-trial Internet endpoint.
11
18
  export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlMs = 120000,
12
19
  maxConnections = 32, maxClientsPerHost = 16, maxPayload = 8 * 1024 * 1024, stateFile,
13
- maxRelaySessions = 8, adminStateFile, publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
20
+ maxRelaySessions = 8, adminStateFile, mysqlConfigFile, accountConfigFile, usageDiagnosticKeyFile,
21
+ publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
14
22
  if (!validSecret(hostKey)) throw new Error('Invalid host credential');
15
23
  if (!Number.isSafeInteger(maxClientsPerHost) || maxClientsPerHost < 1 || maxClientsPerHost > 64) throw new Error('Invalid mobile device limit');
16
24
  if (!Number.isSafeInteger(maxRelaySessions) || maxRelaySessions < 1 || maxRelaySessions > 256) throw new Error('Invalid relay session limit');
@@ -20,24 +28,39 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
20
28
  const nativeOrigins = publicRelayUrl ? ['http:', 'https:'].map(protocol => `${protocol}//${new URL(publicRelayUrl).host}`) : [];
21
29
  if (appDownloadUrl && !/^https:\/\/[^\s]+$/.test(appDownloadUrl)) throw new Error('Invalid app download URL');
22
30
  const hostHash = digest(hostKey), rooms = new Map();
23
- const store = deviceStore(stateFile);
31
+ const database = mysqlConfigFile ? await openRelayMysql(mysqlConfigFile, maxRelaySessions) : null;
32
+ if (accountConfigFile && !database?.schemaVersions.includes('003_relay_usage')) throw new Error('Relay account and usage schema is required');
33
+ const accounts = accountConfigFile ? openEmailAccounts(database.pool, accountConfigFile) : null;
34
+ const meter = accounts ? usageMeter(database.pool) : null;
35
+ const diagnostics = database?.schemaVersions.includes('005_relay_usage_diagnostics')
36
+ ? usageDiagnostics(database.pool, error => log({ event: 'usage-diagnostic-error', message: error.message })) : null;
37
+ const diagnosticKey = usageDiagnosticKeyFile ? fs.readFileSync(usageDiagnosticKeyFile, 'utf8').trim() : '';
38
+ if (diagnosticKey && !/^[A-Za-z0-9_-]{43}$/.test(diagnosticKey)) throw new Error('Invalid usage diagnostic key');
39
+ const store = database?.devices || deviceStore(stateFile);
24
40
  const debugAllowlist = readDebugAllowlist(debugAllowlistFile);
25
- const adminState = relayAdminState(adminStateFile, maxRelaySessions);
41
+ const adminState = database?.admin || relayAdminState(adminStateFile, maxRelaySessions);
26
42
  let activeRelaySessions = 0;
27
- for (const record of store.entries()) rooms.set(record.id, { ...record, durable: true, connections: new Map(), observers: new Map() });
43
+ for (const record of store.entries()) rooms.set(record.id, { ...record, durable: true, connections: new Map(), observers: new Map(), uploadClients: new Map() });
44
+ let commitTail = Promise.resolve();
28
45
  function commit(room, changes) {
46
+ const operation = commitTail.then(async () => {
47
+ changes = typeof changes === 'function' ? changes(room) : changes;
29
48
  const next = { ...room, ...changes, updatedAt: new Date().toISOString() };
30
49
  if (room.durable) {
31
50
  const { id, hostHash, clients, revision, status, createdAt, updatedAt, officialComputer } = next;
32
- store.save({ id, hostHash, clients, revision, status, createdAt, updatedAt, ...(officialComputer ? { officialComputer } : {}) });
51
+ await store.save({ id, hostHash, clients, revision, status, createdAt, updatedAt, ...(officialComputer ? { officialComputer } : {}) });
33
52
  }
34
- Object.assign(room, changes, { updatedAt: next.updatedAt });
53
+ Object.assign(room, changes, { updatedAt: next.updatedAt });
54
+ });
55
+ commitTail = operation.catch(() => {});
56
+ return operation;
35
57
  }
36
58
  function invitation(room) {
37
59
  const code = secret(); room.pairingHash = digest(code); room.expires = Date.now() + pairingTtlMs;
38
60
  return { code, expires: room.expires };
39
61
  }
40
62
  let listenUrl = '';
63
+ let adminTail = Promise.resolve();
41
64
  function relayUrl(request) {
42
65
  if (publicRelayUrl) return relayPublicAddress(publicRelayUrl);
43
66
  const host = request.headers.host || new URL(listenUrl).host;
@@ -51,7 +74,14 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
51
74
  const server = http.createServer((request, response) => {
52
75
  const target = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
53
76
  if (target.pathname === '/admin' || target.pathname.startsWith('/admin/')) {
54
- void handleAdmin(request, response, target); return;
77
+ adminTail = adminTail.then(() => handleAdmin(request, response, target)).catch(() => {
78
+ if (!response.headersSent) adminReply(response, 500, { error: 'Admin operation failed' });
79
+ });
80
+ return;
81
+ }
82
+ if (accounts && target.pathname.startsWith('/account/')) {
83
+ void handleAccount(request, response, target).catch(() => accountReply(response, 500, { error: 'Account operation failed' }));
84
+ return;
55
85
  }
56
86
  if (request.method === 'GET' && target.pathname === '/announcement') {
57
87
  const body = JSON.stringify({ announcement: adminState.read().announcement });
@@ -82,30 +112,123 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
82
112
  'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'" });
83
113
  response.end(value);
84
114
  }
115
+ function accountReply(response, status, body) {
116
+ if (response.headersSent || response.destroyed) return;
117
+ const value = JSON.stringify(body);
118
+ response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(value),
119
+ 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
120
+ response.end(value);
121
+ }
122
+ async function handleAccount(request, response, target) {
123
+ try {
124
+ if (target.pathname === '/account/config' && request.method === 'GET') {
125
+ accountReply(response, 200, { captchaAppId: accounts.captchaAppId }); return;
126
+ }
127
+ const bearer = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(request.headers.authorization || '')?.[1];
128
+ if (target.pathname === '/account/me' && request.method === 'GET') {
129
+ const user = await accounts.current(bearer);
130
+ accountReply(response, 200, { user, usage: await accounts.usage(user.id) }); return;
131
+ }
132
+ if (target.pathname === '/account/logout' && request.method === 'POST') {
133
+ accountReply(response, 200, await accounts.logout(bearer)); return;
134
+ }
135
+ if (request.method !== 'POST' || request.headers['content-type'] !== 'application/json') {
136
+ accountReply(response, 404, { error: 'Account route not found' }); return;
137
+ }
138
+ const body = JSON.parse(await readBody(request, 4096));
139
+ if (!body || typeof body !== 'object' || Array.isArray(body)) { accountReply(response, 400, { error: 'Invalid request body' }); return; }
140
+ if (target.pathname === '/account/email/request') {
141
+ const ip = request.headers['x-real-ip'] || request.socket.remoteAddress || '';
142
+ if (typeof ip !== 'string' || !isIP(ip)) { accountReply(response, 400, { error: 'Invalid client address' }); return; }
143
+ accountReply(response, 202, await accounts.requestCode(body, ip));
144
+ } else if (target.pathname === '/account/email/verify') {
145
+ accountReply(response, 200, await accounts.verifyCode(body));
146
+ } else accountReply(response, 404, { error: 'Account route not found' });
147
+ } catch (failure) {
148
+ accountReply(response, failure.status || 503, { error: failure.status ? failure.message : 'Account service unavailable' });
149
+ }
150
+ }
85
151
  async function handleAdmin(request, response, target) {
86
152
  if (request.method === 'GET' && target.pathname === '/admin') {
87
153
  adminReply(response, 200, adminPage(), 'text/html; charset=utf-8'); return;
88
154
  }
155
+ if (target.pathname.startsWith('/admin/api/')) {
156
+ const token = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(request.headers.authorization || '')?.[1];
157
+ const diagnosticRoute = target.pathname === '/admin/api/usage-diagnostics' ||
158
+ target.pathname === '/admin/api/usage-diagnostics/drain';
159
+ const keyAccepted = diagnosticRoute && diagnosticKey && token &&
160
+ timingSafeEqual(Buffer.from(token), Buffer.from(diagnosticKey));
161
+ let email;
162
+ if (!keyAccepted) {
163
+ try { email = (await accounts.current(token)).email; }
164
+ catch { adminReply(response, 401, { error: '请先登录管理员账号' }); return; }
165
+ const [allowed] = await database.pool.execute('SELECT email FROM relay_admin_emails WHERE email=?', [email]);
166
+ if (!allowed.length) { adminReply(response, 403, { error: '此账号没有后台管理权限' }); return; }
167
+ }
168
+ if (target.pathname === '/admin/api/session' && request.method === 'GET') {
169
+ adminReply(response, 200, { email }); return;
170
+ }
171
+ }
89
172
  if (request.method === 'GET' && target.pathname === '/admin/api/status') {
90
173
  const state = adminState.read();
91
174
  adminReply(response, 200, { maxRelaySessions: state.maxRelaySessions, activeRelaySessions,
92
175
  activeExemptRelaySessions: [...rooms.values()].reduce((sum, room) => sum + [...room.connections.values()].filter(connection => connection.relayExempt).length, 0),
93
176
  websocketConnections: wss.clients.size, onlineComputers: [...rooms.values()].filter(room => room.host).length,
94
- activePhones: [...rooms.values()].reduce((sum, room) => sum + room.connections.size, 0), announcement: state.announcement }); return;
177
+ activePhones: [...rooms.values()].reduce((sum, room) => sum + room.connections.size, 0),
178
+ processRssBytes: process.memoryUsage().rss, processUptimeSeconds: process.uptime(),
179
+ announcement: state.announcement, announcements: state.announcements }); return;
180
+ }
181
+ if (request.method === 'GET' && target.pathname === '/admin/api/usage' && meter) {
182
+ const page = Number(target.searchParams.get('page') || '1');
183
+ const user = target.searchParams.get('user') || '';
184
+ if (!Number.isSafeInteger(page) || page < 1 || page > 100000 || (user && !/^[0-9a-f-]{36}$/.test(user))) {
185
+ adminReply(response, 400, { error: 'Invalid usage query' }); return;
186
+ }
187
+ adminReply(response, 200, await usageReport(database.pool, page, user)); return;
188
+ }
189
+ if (diagnostics && target.pathname === '/admin/api/usage-diagnostics') {
190
+ if (request.method === 'GET') adminReply(response, 200, await diagnostics.read());
191
+ else if (request.method === 'DELETE') adminReply(response, 200, await diagnostics.clear());
192
+ else adminReply(response, 405, { error: 'Unsupported diagnostic request' });
193
+ return;
194
+ }
195
+ if (diagnostics && target.pathname === '/admin/api/usage-diagnostics/drain') {
196
+ if (request.method === 'POST') adminReply(response, 200, await diagnostics.drain());
197
+ else adminReply(response, 405, { error: 'Unsupported diagnostic request' });
198
+ return;
95
199
  }
96
200
  if (!['PUT', 'DELETE'].includes(request.method || '') || request.headers['content-type'] !== 'application/json' ||
97
201
  request.headers['x-code-relax-admin'] !== '1') { adminReply(response, 405, { error: 'Unsupported admin request' }); return; }
98
202
  try {
99
203
  const body = request.method === 'DELETE' ? {} : JSON.parse(await readBody(request, 4096));
100
204
  if (target.pathname === '/admin/api/settings' && request.method === 'PUT') {
101
- const state = adminState.setMaxRelaySessions(body.maxRelaySessions);
205
+ const state = await adminState.setMaxRelaySessions(body.maxRelaySessions);
102
206
  log({ event: 'admin-relay-limit-updated', maxRelaySessions: state.maxRelaySessions });
103
- } else if (target.pathname === '/admin/api/announcement' && request.method === 'PUT') {
104
- const state = adminState.publish(body.title, body.message); broadcastAnnouncement(state.announcement);
105
- log({ event: 'admin-announcement-published', announcementId: state.announcement.id });
106
- } else if (target.pathname === '/admin/api/announcement' && request.method === 'DELETE') {
107
- const state = adminState.withdraw(); broadcastAnnouncement(state.announcement);
108
- log({ event: 'admin-announcement-withdrawn' });
207
+ } else if (target.pathname === '/admin/api/announcements' && request.method === 'PUT') {
208
+ const state = await adminState.createAnnouncement(body.title, body.message, body.publish === true);
209
+ if (body.publish === true) {
210
+ broadcastAnnouncement(state.announcement);
211
+ log({ event: 'admin-announcement-published', announcementId: state.announcement.id });
212
+ } else log({ event: 'admin-announcement-draft-created', announcementId: state.announcements[0].id });
213
+ } else if (target.pathname.startsWith('/admin/api/announcements/')) {
214
+ const parts = target.pathname.slice('/admin/api/announcements/'.length).split('/');
215
+ const id = parts[0];
216
+ if (!/^[\w-]{16,128}$/.test(id)) { adminReply(response, 404, { error: 'Admin route not found' }); return; }
217
+ if (parts.length === 1 && request.method === 'PUT') {
218
+ await adminState.updateDraft(id, body.title, body.message);
219
+ log({ event: 'admin-announcement-draft-updated', announcementId: id });
220
+ } else if (parts.length === 1 && request.method === 'DELETE') {
221
+ await adminState.deleteAnnouncement(id);
222
+ log({ event: 'admin-announcement-deleted', announcementId: id });
223
+ } else if (parts.length === 2 && parts[1] === 'publish' && request.method === 'PUT') {
224
+ const state = await adminState.publishDraft(id, body.title, body.message);
225
+ broadcastAnnouncement(state.announcement);
226
+ log({ event: 'admin-announcement-published', announcementId: id });
227
+ } else if (parts.length === 2 && parts[1] === 'withdraw' && request.method === 'PUT') {
228
+ const state = await adminState.withdraw(id);
229
+ broadcastAnnouncement(state.announcement);
230
+ log({ event: 'admin-announcement-withdrawn', announcementId: id });
231
+ } else { adminReply(response, 404, { error: 'Admin route not found' }); return; }
109
232
  } else { adminReply(response, 404, { error: 'Admin route not found' }); return; }
110
233
  adminReply(response, 200, { ok: true });
111
234
  } catch (error) { adminReply(response, 400, { error: error.message }); }
@@ -168,26 +291,64 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
168
291
  }).catch(() => connection.ws.close(1011, 'Relay delivery failed'));
169
292
  };
170
293
  ws.on('error', () => {}); // close owns routing cleanup.
171
- ws.on('close', (code, reason) => {
294
+ ws.on('close', (code, reason) => {
172
295
  log({ event: 'closed', role: role || 'unauthenticated', device: room?.id, code, reason: reason.toString() });
173
296
  clearTimeout(authTimer); clearInterval(heartbeat);
174
297
  if (role === 'host' && room?.host === ws) {
298
+ for (const task of room.usageTasks?.values() || []) {
299
+ void meter?.lost(task).catch(error => log({ event: 'usage-error', message: error.message }));
300
+ }
301
+ room.usageTasks?.clear(); room.usageArms?.clear();
175
302
  room.host = undefined; room.hostName = undefined; room.lanCandidates = [];
303
+ room.uploadHost?.close(4410, 'Computer offline');
304
+ for (const client of room.uploadClients?.values() || []) client.ws.close(4410, 'Computer offline');
176
305
  for (const observer of room.observers.values()) send(observer.ws, { type: 'observing', online: false });
177
306
  if (!room.durable) rooms.delete(room.id);
178
307
  for (const connection of room.connections.values()) connection.ws.close(4410, 'Computer offline; authorization retained');
179
308
  } else if (role === 'client' && room?.connections.get(clientId)?.ws === ws) {
180
- releaseRelay(room.connections.get(clientId));
309
+ const connection = room.connections.get(clientId);
310
+ releaseRelay(connection);
181
311
  room.connections.delete(clientId);
182
312
  if (room.host) send(room.host, { type: 'detach', session });
183
313
  } else if (role === 'observer' && room?.observers.get(clientId)?.ws === ws) {
184
314
  room.observers.delete(clientId);
185
- }
315
+ } else if (role === 'file-host' && room?.uploadHost === ws) {
316
+ room.uploadHost = undefined;
317
+ for (const client of room.uploadClients?.values() || []) client.ws.close(4410, 'File receiver offline');
318
+ } else if (role === 'file-client' && room?.uploadClients?.get(clientId)?.ws === ws) {
319
+ room.uploadClients.delete(clientId);
320
+ if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session });
321
+ }
186
322
  });
187
- ws.on('message', (bytes, binary) => {
188
- try {
189
- const message = decode(bytes, binary);
190
- if (!role) {
323
+ let messageTail = Promise.resolve();
324
+ ws.on('message', (bytes, binary) => {
325
+ messageTail = messageTail.then(async () => {
326
+ try {
327
+ const message = decode(bytes, binary);
328
+ if (!role) {
329
+ if (message.type === 'file-host') {
330
+ room = rooms.get(message.deviceId);
331
+ if (!room?.host || room.status !== 'active' || !matches(message.deviceSecret, room.hostHash)) return fail();
332
+ role = 'file-host'; clearTimeout(authTimer);
333
+ room.uploadClients ||= new Map();
334
+ room.uploadHost?.close(4409, 'File receiver replaced');
335
+ room.uploadHost = ws;
336
+ send(ws, { type: 'file-host-ready' }); return;
337
+ }
338
+ if (message.type === 'file-client') {
339
+ room = rooms.get(message.room);
340
+ const client = room?.clients.find(item => item.id === message.clientId);
341
+ if (!room?.host || !room.uploadHost || room.status !== 'active' || !client ||
342
+ !matches(message.credential, client.hash)) return fail();
343
+ room.uploadClients ||= new Map();
344
+ if (room.uploadClients.has(message.clientId) ||
345
+ [...rooms.values()].reduce((sum, item) => sum + (item.uploadClients?.size || 0), 0) >= 2) {
346
+ return fail(4429, 'Too many file uploads');
347
+ }
348
+ role = 'file-client'; clientId = message.clientId; session = randomUUID(); clearTimeout(authTimer);
349
+ room.uploadClients.set(clientId, { ws, session });
350
+ send(room.uploadHost, { type: 'file-open', session }); return;
351
+ }
191
352
  if (message.type === 'host') {
192
353
  const durable = message.deviceId !== undefined;
193
354
  if (durable && (!validId(message.deviceId) || !validSecret(message.deviceSecret))) return fail();
@@ -198,20 +359,30 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
198
359
  hostName = normalizeHostName(message.hostName);
199
360
  }
200
361
  catch { return fail(1008, 'Invalid LAN candidates'); }
201
- room = durable ? rooms.get(message.deviceId) : undefined;
202
- if (room && (room.status !== 'active' || !matches(message.deviceSecret, room.hostHash))) return fail();
203
- if (!room) {
204
- if (!matches(message.key, hostHash)) return fail();
205
- if (rooms.size >= 256) return fail(4429, 'Device limit reached');
362
+ room = durable ? rooms.get(message.deviceId) : undefined;
363
+ if (room && (room.status !== 'active' || !matches(message.deviceSecret, room.hostHash))) return fail();
364
+ if (!room) {
365
+ // Durable computers prove future ownership with their generated device secret.
366
+ // The legacy deployment key remains accepted, but is no longer required for first registration.
367
+ if (!durable && !matches(message.key, hostHash)) return fail();
368
+ if (rooms.size >= 256) return fail(4429, 'Device limit reached');
206
369
  const now = new Date().toISOString();
207
370
  room = { id: durable ? message.deviceId : randomUUID(), durable,
208
- revision: 0, status: 'active', clients: [], connections: new Map(), observers: new Map(), createdAt: now, updatedAt: now,
371
+ revision: 0, status: 'active', clients: [], connections: new Map(), observers: new Map(), uploadClients: new Map(), createdAt: now, updatedAt: now,
209
372
  ...(durable ? { hostHash: digest(message.deviceSecret) } : {}) };
210
- commit(room, {}); rooms.set(room.id, room);
373
+ await commit(room, {}); rooms.set(room.id, room);
211
374
  }
212
375
  const oldHost = room.host;
213
376
  room.observers ||= new Map();
377
+ if (oldHost && oldHost !== ws) {
378
+ for (const task of room.usageTasks?.values() || []) {
379
+ try { await meter?.lost(task); } catch (error) { log({ event: 'usage-error', message: error.message }); }
380
+ }
381
+ room.usageTasks?.clear(); room.usageArms?.clear();
382
+ }
214
383
  room.host = ws; room.hostName = hostName; room.lanCandidates = lanCandidates;
384
+ room.usageTasks ||= new Map(); room.usageArms ||= new Map();
385
+ if (oldHost && oldHost !== ws) room.uploadHost?.close(4409, 'Computer connection replaced');
215
386
  room.p2pProbe = message.p2pProbe === 1; room.p2pProbeRepeat = message.p2pProbeRepeat === true;
216
387
  role = 'host'; clearTimeout(authTimer);
217
388
  if (oldHost && oldHost !== ws) {
@@ -230,7 +401,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
230
401
  room = rooms.get(message.room);
231
402
  if (!room || room.status !== 'active') return fail();
232
403
  let credential;
233
- if (message.type === 'pair' && room.pairingHash && Date.now() < room.expires && matches(message.code, room.pairingHash)) {
404
+ if (message.type === 'pair' && !room.pairingInProgress && room.pairingHash && Date.now() < room.expires && matches(message.code, room.pairingHash)) {
234
405
  if (!room.host) return fail(4410, 'Computer offline; authorization retained');
235
406
  credential = message.credential || secret();
236
407
  clientId = message.clientId || (!room.durable ? randomUUID() : undefined);
@@ -239,8 +410,11 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
239
410
  if (!existing && room.clients.length >= maxClientsPerHost) return fail(4429, 'Mobile device limit reached');
240
411
  const now = new Date().toISOString();
241
412
  const client = { id: clientId, hash: digest(credential), createdAt: existing?.createdAt || now, updatedAt: now };
242
- commit(room, { clients: [...room.clients.filter(item => item.id !== clientId), client], revision: room.revision + 1 });
243
- room.pairingHash = undefined;
413
+ room.pairingInProgress = true;
414
+ try {
415
+ await commit(room, current => ({ clients: [...current.clients.filter(item => item.id !== clientId), client], revision: current.revision + 1 }));
416
+ room.pairingHash = undefined;
417
+ } finally { room.pairingInProgress = false; }
244
418
  send(room.host, { type: 'authorization-changed', clients: room.clients, revision: room.revision });
245
419
  } else {
246
420
  clientId = message.clientId;
@@ -266,6 +440,11 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
266
440
  return;
267
441
  }
268
442
  if (!room.host) return fail(4410, 'Computer offline; authorization retained');
443
+ let userId;
444
+ if (accounts && message.accountToken !== undefined) {
445
+ try { userId = (await accounts.current(message.accountToken)).id; }
446
+ catch { return fail(4431, 'Account session invalid'); }
447
+ }
269
448
  const oldConnection = room.connections.get(clientId);
270
449
  role = 'client'; clearTimeout(authTimer); session = randomUUID();
271
450
  if (oldConnection?.ws && oldConnection.ws !== ws) {
@@ -274,9 +453,11 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
274
453
  oldConnection.ws.close(4409, 'Phone connection replaced by same identity');
275
454
  }
276
455
  room.connections.set(clientId, { ws, session, p2pProbe: room.p2pProbe && message.p2pProbe === 1,
456
+ userId,
277
457
  repeat: room.p2pProbeRepeat, signals: 0, probeStarted: 0, outbound: Promise.resolve(), queuedBytes: 0,
278
458
  relayAdmitted: false, relayExempt: false,
279
459
  capacityExempt: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
460
+ diagnostics?.record('client', userId ? 'account-linked' : 'anonymous');
280
461
  send(ws, { type: 'paired', protocolVersion: 7, room: room.id, clientId, revision: room.revision, ...(credential ? { credential } : {}) });
281
462
  send(ws, { type: 'computer-info', hostName: room.hostName });
282
463
  if (room.lanCandidates?.length) send(ws, { type: 'lan-candidates', candidates: room.lanCandidates });
@@ -286,6 +467,86 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
286
467
  }
287
468
  if (role === 'host') {
288
469
  if (room.host !== ws) return;
470
+ if (meter && ['usage-arm', 'usage-message', 'usage-cancel', 'usage-turn', 'usage-observer-lost'].includes(message.type)) {
471
+ if (message.type === 'usage-observer-lost') {
472
+ for (const task of room.usageTasks.values()) {
473
+ try { await meter.lost(task); } catch (error) { log({ event: 'usage-error', message: error.message }); }
474
+ }
475
+ room.usageTasks.clear();
476
+ return;
477
+ }
478
+ const armKey = `${message.session}:${message.requestId}`;
479
+ const connection = [...room.connections.values()].find(item => item.session === message.session);
480
+ if (message.type === 'usage-arm') {
481
+ diagnostics?.record('arm', connection?.userId ? 'account-linked' : 'anonymous');
482
+ if (!connection?.userId || !Number.isSafeInteger(message.requestId) || message.requestId < 1 ||
483
+ (message.threadId !== null && (!plainText(message.threadId, 128, false)))) {
484
+ diagnostics?.record('arm-rejected', !connection?.userId ? 'account-missing' : 'invalid-request');
485
+ return;
486
+ }
487
+ for (const [key, arm] of room.usageArms) if (Date.now() - arm.at > 120000) room.usageArms.delete(key);
488
+ if (room.usageArms.size >= 256) return;
489
+ room.usageArms.set(armKey, { userId: connection.userId,
490
+ clientId: [...room.connections.entries()].find(([, item]) => item === connection)?.[0],
491
+ session: message.session, at: Date.now() });
492
+ diagnostics?.record('arm-accepted', 'ready');
493
+ return;
494
+ }
495
+ if (message.type === 'usage-cancel') {
496
+ diagnostics?.record('message-cancelled', room.usageArms.has(armKey) ? 'armed' : 'arm-missing');
497
+ room.usageArms.delete(armKey); return;
498
+ }
499
+ if (message.type === 'usage-message') {
500
+ const arm = room.usageArms.get(armKey);
501
+ diagnostics?.record('message', arm ? 'armed' : 'arm-missing');
502
+ room.usageArms.delete(armKey);
503
+ if (!arm || Date.now() - arm.at > 120000 || !plainText(message.messageKey, 128, false)) {
504
+ diagnostics?.record('message-rejected', !arm ? 'arm-missing' : 'expired-or-invalid');
505
+ return;
506
+ }
507
+ try {
508
+ await meter.action(arm, room.id, arm.clientId, 'message', message.messageKey);
509
+ diagnostics?.record('message-counted', 'confirmed');
510
+ if (['start', 'steer'].includes(message.turnKind) &&
511
+ plainText(message.threadId, 80, false) && plainText(message.turnId, 80, false)) {
512
+ const key = `${message.threadId}:${message.turnId}`;
513
+ if (key.length <= 128) {
514
+ const counted = await meter.action(arm, room.id, arm.clientId, 'turn', key);
515
+ if (counted) diagnostics?.record('turn-counted', 'confirmed');
516
+ if (!room.usageTasks.has(key) && message.observerReady === true && message.completed !== true &&
517
+ (message.turnKind === 'start' || message.active === true)) {
518
+ const task = await meter.turnOrigin(room.id, key);
519
+ if (task) {
520
+ await meter.pulse(task, true, room.id, task.clientId, key);
521
+ room.usageTasks.set(key, task);
522
+ }
523
+ }
524
+ }
525
+ }
526
+ } catch (error) { diagnostics?.record('message-error', 'database'); log({ event: 'usage-error', message: error.message }); }
527
+ return;
528
+ }
529
+ if (!['turn/started', 'turn/completed', 'heartbeat', 'resume'].includes(message.method) ||
530
+ !plainText(message.threadId, 80, false) || !plainText(message.turnId, 80, false)) return;
531
+ const key = `${message.threadId}:${message.turnId}`;
532
+ if (key.length > 128) return;
533
+ let task = room.usageTasks.get(key);
534
+ try {
535
+ if (message.method === 'resume' && !task) {
536
+ task = await meter.turnOrigin(room.id, key);
537
+ if (!task) { diagnostics?.record('turn-resume', 'origin-missing'); return; }
538
+ await meter.pulse(task, true, room.id, task.clientId, `${key}:${randomUUID()}`);
539
+ diagnostics?.record('turn-resume', 'confirmed');
540
+ room.usageTasks.set(key, task);
541
+ } else if (task && message.method === 'heartbeat') await meter.pulse(task, true, room.id, task.clientId);
542
+ else if (task && message.method === 'turn/completed') {
543
+ await meter.pulse(task, false, room.id, task.clientId);
544
+ diagnostics?.record('turn-completed', 'confirmed');
545
+ room.usageTasks.delete(key);
546
+ }
547
+ } catch (error) { log({ event: 'usage-error', message: error.message }); }
548
+ return;
549
+ }
289
550
  if (message.type === 'lan-candidates') {
290
551
  room.lanCandidates = normalizeLanCandidates(message.candidates);
291
552
  for (const connection of room.connections.values()) send(connection.ws, { type: 'lan-candidates', candidates: room.lanCandidates });
@@ -299,7 +560,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
299
560
  send(ws, { type: 'pair-code', commandId: message.commandId, room: room.id, ...invitation(room) }); return;
300
561
  }
301
562
  if (message.type === 'revoke') {
302
- commit(room, { clients: [], revision: room.revision + 1 });
563
+ await commit(room, current => ({ clients: [], revision: current.revision + 1 }));
303
564
  room.pairingHash = undefined;
304
565
  for (const connection of room.connections.values()) {
305
566
  releaseRelay(connection);
@@ -307,6 +568,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
307
568
  connection.ws.close(4401, 'Device authorization revoked');
308
569
  }
309
570
  room.connections.clear();
571
+ for (const client of room.uploadClients?.values() || []) client.ws.close(4401, 'Device authorization revoked');
572
+ room.uploadClients?.clear();
310
573
  for (const observer of room.observers.values()) observer.ws.close(4401, 'Device authorization revoked');
311
574
  room.observers.clear();
312
575
  send(ws, { type: 'authorization-changed', clients: [], revision: room.revision });
@@ -318,28 +581,49 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
318
581
  else if (message.type === 'ready') queueClientMessage(connection, { type: 'ready', p2pProbe: connection.p2pProbe ? 1 : 0,
319
582
  p2pProbeRepeat: connection.p2pProbe && connection.repeat });
320
583
  else if (message.type === 'p2p-probe' && connection.p2pProbe && validProbeSignal(message)) {
321
- if (connection.probeId !== message.probeId) return;
322
- if (++connection.signals > 150) return fail(1008, 'Probe signal limit');
584
+ const diagnostic = message.purpose === 'diagnostic';
585
+ if (message.purpose && !diagnostic) return fail(1008, 'Invalid probe purpose');
586
+ if ((diagnostic ? connection.diagnosticProbeId : connection.probeId) !== message.probeId) return;
587
+ if (++connection[diagnostic ? 'diagnosticSignals' : 'signals'] > 150) return fail(1008, 'Probe signal limit');
323
588
  send(connection.ws, { ...message, session: undefined });
324
589
  }
325
590
  else if (message.type === 'detach') connection.ws.close(1011, 'Local Bridge unavailable');
326
591
  else fail();
592
+ } else if (role === 'file-host' && room.uploadHost === ws) {
593
+ const client = [...room.uploadClients.values()].find(item => item.session === message.session);
594
+ if (!client) return;
595
+ if (message.type === 'file-frame' && typeof message.payload === 'string' && message.payload.length <= 512 * 1024) {
596
+ if (client.ws.bufferedAmount > 1024 * 1024) return client.ws.close(1013, 'File receiver too slow');
597
+ send(client.ws, { type: 'file-frame', payload: message.payload });
598
+ } else if (message.type === 'file-close') client.ws.close(1011, 'File receiver unavailable');
599
+ else fail(1008, 'Invalid file frame');
600
+ } else if (role === 'file-client' && room.uploadClients?.get(clientId)?.ws === ws) {
601
+ if (message.type !== 'file-frame' || typeof message.payload !== 'string' || message.payload.length > 512 * 1024) return fail(1008, 'Invalid file frame');
602
+ if (room.uploadHost?.bufferedAmount > 1024 * 1024) return fail(1013, 'File receiver too slow');
603
+ send(room.uploadHost, { type: 'file-frame', session, payload: message.payload });
327
604
  } else if (role === 'observer' && room.observers.get(clientId)?.ws === ws && message.type === 'official-computer') {
328
605
  const computer = normalizeOfficialComputer(message);
329
- commit(room, { officialComputer: computer });
606
+ await commit(room, { officialComputer: computer });
330
607
  send(ws, { type: 'official-computer-saved', computer });
331
608
  } else if (role === 'client' && room.connections.get(clientId)?.ws === ws && message.type === 'p2p-probe') {
332
609
  const connection = room.connections.get(clientId);
333
610
  if (!connection.p2pProbe || !validProbeSignal(message)) return fail(1008, 'Invalid probe signal');
611
+ const diagnostic = message.purpose === 'diagnostic';
612
+ if (message.purpose && !diagnostic) return fail(1008, 'Invalid probe purpose');
613
+ const idField = diagnostic ? 'diagnosticProbeId' : 'probeId';
614
+ const signalsField = diagnostic ? 'diagnosticSignals' : 'signals';
615
+ const startedField = diagnostic ? 'diagnosticProbeStarted' : 'probeStarted';
334
616
  if (message.kind === 'start') {
335
- if (connection.probeId && (!connection.repeat || Date.now() - connection.probeStarted < 2000)) {
336
- send(ws, { type: 'p2p-probe', version: 1, probeId: message.probeId, kind: 'close' }); return;
617
+ if (connection[idField] && (!connection.repeat || Date.now() - connection[startedField] < 2000)) {
618
+ send(ws, { type: 'p2p-probe', version: 1, probeId: message.probeId,
619
+ ...(diagnostic ? { purpose: 'diagnostic' } : {}), kind: 'close' }); return;
337
620
  }
338
- connection.probeId = message.probeId; connection.signals = 0; connection.probeStarted = Date.now();
621
+ connection[idField] = message.probeId; connection[signalsField] = 0; connection[startedField] = Date.now();
339
622
  }
340
- if (connection.probeId !== message.probeId) return;
341
- if (++connection.signals > 150) return fail(1008, 'Probe signal limit');
623
+ if (connection[idField] !== message.probeId) return;
624
+ if (++connection[signalsField] > 150) return fail(1008, 'Probe signal limit');
342
625
  send(room.host, { ...message, session });
626
+ if (diagnostic && message.kind === 'close') connection.diagnosticProbeId = undefined;
343
627
  } else if (room.connections.get(clientId)?.ws === ws && message.type === 'p2p-route' && message.direct === 1) {
344
628
  releaseRelay(room.connections.get(clientId));
345
629
  } else if (room.connections.get(clientId)?.ws === ws && message.type === 'data' && typeof message.payload === 'string') {
@@ -349,17 +633,19 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
349
633
  }
350
634
  send(room.host, { type: 'data', session, payload: message.payload });
351
635
  } else fail();
352
- } catch (error) { log({ event: 'request-error', role, message: 'Relay operation failed' }); ws.close(1011, 'Relay operation failed'); }
353
- });
636
+ } catch (error) { log({ event: 'request-error', role, message: 'Relay operation failed' }); ws.close(1011, 'Relay operation failed'); }
637
+ }).catch(() => ws.close(1011, 'Relay operation failed'));
638
+ });
354
639
  });
355
640
  await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
356
641
  listenUrl = `ws://127.0.0.1:${server.address().port}/relay`;
357
642
  return {
358
643
  url: listenUrl, hostKey,
359
- async close() {
360
- for (const ws of wss.clients) ws.terminate();
361
- await new Promise(resolve => wss.close(resolve));
362
- await new Promise(resolve => server.close(resolve));
644
+ async close() {
645
+ for (const ws of wss.clients) ws.terminate();
646
+ await new Promise(resolve => wss.close(resolve));
647
+ await new Promise(resolve => server.close(resolve));
648
+ if (database) await database.close();
363
649
  },
364
650
  };
365
651
  }
@@ -454,13 +740,3 @@ function readBody(request, limit) {
454
740
  request.on('end', () => resolve(value)); request.on('error', reject);
455
741
  });
456
742
  }
457
-
458
- function adminPage() {
459
- return `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Code Relax 管理</title><style>
460
- body{margin:0;background:#0d1210;color:#eef4f0;font:15px system-ui,sans-serif}main{max-width:760px;margin:40px auto;padding:0 20px}section{background:#151d19;border:1px solid #2a3931;border-radius:14px;padding:18px;margin:16px 0}h1{font-size:24px}h2{font-size:17px}label{display:block;margin:12px 0 6px;color:#aab7af}input,textarea{box-sizing:border-box;width:100%;padding:11px;border:1px solid #43564b;border-radius:9px;background:#0d1210;color:#eef4f0}textarea{min-height:120px;resize:vertical}button{margin:12px 8px 0 0;padding:10px 16px;border:0;border-radius:9px;background:#2c7653;color:white}button.secondary{background:#39443e}pre{white-space:pre-wrap;color:#b9c8bf}.row{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.metric{padding:12px;background:#0d1210;border-radius:9px}.metric b{display:block;font-size:22px}</style><main><h1>Code Relax 管理</h1><section><h2>运行状态</h2><div id="metrics" class="row"></div><button onclick="load()">刷新</button></section><section><h2>服务器转发容量</h2><label>最大并发 WSS 转发会话</label><input id="limit" type="number" min="1" max="256"><button onclick="saveLimit()">保存</button></section><section><h2>系统公告</h2><label>标题</label><input id="title" maxlength="80"><label>正文</label><textarea id="message" maxlength="2000"></textarea><button onclick="publish()">发布</button><button class="secondary" onclick="withdraw()">撤下</button><pre id="current"></pre></section><pre id="result"></pre><script>
461
- const request=async(path,options={})=>{const response=await fetch(path,options);const body=await response.json();if(!response.ok)throw new Error(body.error||('HTTP '+response.status));return body};
462
- async function load(){try{const s=await request('/admin/api/status');limit.value=s.maxRelaySessions;metrics.innerHTML='<div class="metric"><b>'+s.activeRelaySessions+'</b>受限转发 / '+s.maxRelaySessions+'</div><div class="metric"><b>'+s.activeExemptRelaySessions+'</b>白名单转发</div><div class="metric"><b>'+s.websocketConnections+'</b>WebSocket</div><div class="metric"><b>'+s.onlineComputers+'</b>在线工作站</div><div class="metric"><b>'+s.activePhones+'</b>活动手机</div>';current.textContent=s.announcement?s.announcement.title+'\\n'+s.announcement.message+'\\n'+s.announcement.publishedAt:'当前无公告';}catch(e){result.textContent=e.message}}
463
- const write=(method,path,body)=>request(path,{method,headers:{'Content-Type':'application/json','X-Code-Relax-Admin':'1'},body:JSON.stringify(body)}).then(()=>{result.textContent='已保存';return load()}).catch(e=>result.textContent=e.message);
464
- function saveLimit(){return write('PUT','/admin/api/settings',{maxRelaySessions:Number(limit.value)})}function publish(){return write('PUT','/admin/api/announcement',{title:title.value,message:message.value})}function withdraw(){return write('DELETE','/admin/api/announcement',{})}load();
465
- </script></main></html>`;
466
- }
@@ -0,0 +1,46 @@
1
+ const MAX_BATCH = 200;
2
+
3
+ export function usageDiagnostics(pool, onError = () => {}) {
4
+ let tail = Promise.resolve();
5
+ function serialize(action) {
6
+ const result = tail.then(action);
7
+ tail = result.catch(error => { onError(error); });
8
+ return result;
9
+ }
10
+ function record(event, detail) {
11
+ if (!/^[a-z-]{1,32}$/.test(event) || !/^[a-z-]{1,64}$/.test(detail)) throw new Error('Invalid usage diagnostic');
12
+ void serialize(async () => {
13
+ await pool.execute('INSERT INTO relay_usage_diagnostics (created_at,event,detail) VALUES (UTC_TIMESTAMP(3),?,?)', [event, detail]);
14
+ await pool.execute('DELETE FROM relay_usage_diagnostics WHERE created_at < UTC_TIMESTAMP(3) - INTERVAL 1 DAY');
15
+ }).catch(() => {});
16
+ }
17
+ async function read() {
18
+ return serialize(async () => {
19
+ const [rows] = await pool.query(`SELECT id,created_at,event,detail FROM relay_usage_diagnostics
20
+ ORDER BY id LIMIT ${MAX_BATCH + 1}`);
21
+ return { rows: rows.slice(0, MAX_BATCH), hasMore: rows.length > MAX_BATCH };
22
+ });
23
+ }
24
+ async function clear() {
25
+ return serialize(async () => {
26
+ const [result] = await pool.query('DELETE FROM relay_usage_diagnostics');
27
+ return { cleared: result.affectedRows };
28
+ });
29
+ }
30
+ async function drain() {
31
+ return serialize(async () => {
32
+ const db = await pool.getConnection();
33
+ try {
34
+ await db.beginTransaction();
35
+ const [rows] = await db.query(`SELECT id,created_at,event,detail FROM relay_usage_diagnostics
36
+ ORDER BY id LIMIT ${MAX_BATCH + 1} FOR UPDATE`);
37
+ const selected = rows.slice(0, MAX_BATCH);
38
+ if (selected.length) await db.execute('DELETE FROM relay_usage_diagnostics WHERE id <= ?', [selected.at(-1).id]);
39
+ await db.commit();
40
+ return { rows: selected, hasMore: rows.length > MAX_BATCH };
41
+ } catch (error) { await db.rollback(); throw error; }
42
+ finally { db.release(); }
43
+ });
44
+ }
45
+ return { record, read, clear, drain };
46
+ }