@walkhi/code-relax 0.1.0-beta.1 → 0.1.0-beta.3

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 (63) hide show
  1. package/README.md +5 -5
  2. package/dist/bin/self-relay-server.mjs +5 -2
  3. package/dist/shared/app-server-events.cjs +45 -19
  4. package/dist/shared/p2p-data-channel.cjs +50 -0
  5. package/dist/src/app-server-tasks.mjs +28 -25
  6. package/dist/src/lan-socket-server.mjs +8 -2
  7. package/dist/src/platform/windows/IsolatedProcess.cs +7 -3
  8. package/dist/src/platform/windows/background-process.mjs +7 -2
  9. package/dist/src/platform/windows/launch-worker.mjs +42 -0
  10. package/dist/src/platform/windows/start-hidden-console.ps1 +18 -8
  11. package/dist/src/self-relay/admin-state.mjs +61 -0
  12. package/dist/src/self-relay/client.mjs +2 -0
  13. package/dist/src/self-relay/connector.mjs +23 -8
  14. package/dist/src/self-relay/demo.mjs +2 -2
  15. package/dist/src/self-relay/lifecycle.mjs +1 -1
  16. package/dist/src/self-relay/p2p-probe.mjs +123 -83
  17. package/dist/src/self-relay/server.mjs +137 -11
  18. package/dist/src/server.mjs +337 -255
  19. package/dist/src/shared-app-server.mjs +180 -24
  20. package/dist/src/shared-catalog.mjs +4 -14
  21. package/dist/src/thread-catalog.mjs +12 -7
  22. package/dist/web/activity-view.js +283 -0
  23. package/dist/web/capabilities.js +2 -2
  24. package/dist/web/chat-transport.js +15 -9
  25. package/dist/web/chat.css +139 -93
  26. package/dist/web/chat.js +1451 -2770
  27. package/dist/web/community-view.js +20 -0
  28. package/dist/web/community.css +77 -0
  29. package/dist/web/community.html +37 -0
  30. package/dist/web/composer-controller.js +101 -0
  31. package/dist/web/conversation-controller.js +99 -0
  32. package/dist/web/disclosure-state-controller.js +95 -0
  33. package/dist/web/draft-controller.js +103 -0
  34. package/dist/web/harmony-platform.js +3 -2
  35. package/dist/web/history-cache.js +112 -18
  36. package/dist/web/history-controller.js +167 -0
  37. package/dist/web/index.html +141 -38
  38. package/dist/web/link-action-controller.js +212 -0
  39. package/dist/web/message-send-controller.js +177 -0
  40. package/dist/web/message-view.js +98 -0
  41. package/dist/web/p2p-data-channel.js +50 -0
  42. package/dist/web/p2p-probe.js +67 -27
  43. package/dist/web/page-resume.js +28 -0
  44. package/dist/web/pending-message-store.js +108 -0
  45. package/dist/web/queue-controller.js +82 -0
  46. package/dist/web/resources.json +1 -1
  47. package/dist/web/self-relay-session.js +28 -18
  48. package/dist/web/station-connection-controller.js +75 -0
  49. package/dist/web/task-list-view.js +296 -0
  50. package/dist/web/thread-attention-controller.js +124 -0
  51. package/dist/web/thread-context-controller.js +30 -0
  52. package/dist/web/thread-list-controller.js +61 -0
  53. package/dist/web/thread-list-sync.js +86 -0
  54. package/dist/web/thread-title-controller.js +57 -0
  55. package/dist/web/timeline-formatters.js +249 -0
  56. package/dist/web/timeline-reducer.js +81 -0
  57. package/dist/web/timeline-renderer.js +161 -0
  58. package/dist/web/timeline-scroll-controller.js +50 -0
  59. package/dist/web/usage-controller.js +258 -0
  60. package/dist/web/vendor/lucide.LICENSE.txt +17 -0
  61. package/package.json +1 -1
  62. package/tools/postinstall.mjs +111 -2
  63. package/dist/src/platform/windows/launch-worker.ps1 +0 -13
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { EventEmitter } from 'node:events';
3
3
  import WebSocket from 'ws';
4
- import { decode, loopbackUrl, relayAddress, send } from './wire.mjs';
4
+ import { decode, loopbackUrl, relayAddress, send } from './wire.mjs';
5
5
  import { createP2pProbe, validProbeSignal } from './p2p-probe.mjs';
6
6
  import { createWindowsRouteHelper } from './windows-route-helper.mjs';
7
7
 
@@ -59,7 +59,8 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
59
59
  if (message.type === 'attach') {
60
60
  events.emit('attached', { session: message.session, clientId: message.clientId });
61
61
  dropLocal(message.session);
62
- const current = { socket: undefined, ready: false, timer: undefined, p2pProbe: p2pProbe && message.p2pProbe === 1 };
62
+ const current = { socket: undefined, ready: false, timer: undefined, p2pProbe: p2pProbe && message.p2pProbe === 1,
63
+ bridgeOutbound: Promise.resolve() };
63
64
  locals.set(message.session, current);
64
65
  let connection;
65
66
  try {
@@ -69,7 +70,13 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
69
70
  send(remote, { type: 'detach', session: message.session }); dropLocal(message.session); events.emit('detached', { session: message.session });
70
71
  events.emit('diagnostic', 'Local Bridge connection not ready'); return;
71
72
  }
72
- const socket = current.socket = new WebSocket(connection.url, { maxPayload: 64 * 1024 * 1024, perMessageDeflate: false });
73
+ const socket = current.socket = new WebSocket(connection.url, { maxPayload: 64 * 1024 * 1024, perMessageDeflate: false });
74
+ current.forwardToBridge = payload => {
75
+ if (locals.get(message.session) !== current || !current.ready) throw new Error('Local Bridge connection not ready');
76
+ const request = decode(payload, false);
77
+ if (!['request', 'subscribe', 'unsubscribe'].includes(request.type)) throw new Error('Unsupported LAN message');
78
+ send(socket, request);
79
+ };
73
80
  current.timer = setTimeout(() => socket.terminate(), 5000);
74
81
  socket.on('open', () => send(socket, { type: 'auth', token: connection.token }));
75
82
  socket.on('error', error => events.emit('diagnostic', `Local Bridge: ${error.message}`));
@@ -84,7 +91,15 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
84
91
  if (!current.ready) {
85
92
  if (response.type !== 'ready') throw new Error('Local Bridge authentication failed');
86
93
  clearTimeout(current.timer); current.ready = true; send(remote, { type: 'ready', session: message.session });
87
- } else send(remote, { type: 'data', session: message.session, payload: data.toString() });
94
+ } else {
95
+ const payload = data.toString();
96
+ current.bridgeOutbound = current.bridgeOutbound.then(async () => {
97
+ const probe = await current.probe;
98
+ if (locals.get(message.session) !== current || !current.ready) return;
99
+ if (!probe?.send?.(payload)) send(remote, { type: 'data', session: message.session, payload });
100
+ });
101
+ void current.bridgeOutbound.catch(error => { events.emit('diagnostic', error.message); socket.terminate(); });
102
+ }
88
103
  } catch (error) { events.emit('diagnostic', error.message); socket.terminate(); }
89
104
  });
90
105
  } else if (message.type === 'p2p-probe' && locals.has(message.session)) {
@@ -101,7 +116,9 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
101
116
  signal: signal => {
102
117
  if (locals.get(message.session) === current && current.probeId === probeId) send(remote, { type: 'p2p-probe', session: message.session,
103
118
  version: 1, probeId, ...signal });
104
- }, report: (state, diagnostic) => events.emit('p2p-probe', { state, diagnostic })
119
+ },
120
+ onData: payload => current.forwardToBridge(payload),
121
+ report: (state, diagnostic) => events.emit('p2p-probe', { state, diagnostic })
105
122
  })).catch(() => {
106
123
  events.emit('diagnostic', p2pWindowsRouteHelper ? 'Windows RTC 路由辅助不可用' : 'WebRTC probe initialization failed');
107
124
  return { receive() {}, close() {} };
@@ -121,9 +138,7 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
121
138
  else if (message.type === 'data' && locals.has(message.session)) {
122
139
  const current = locals.get(message.session);
123
140
  if (!current.ready) { send(remote, { type: 'detach', session: message.session }); dropLocal(message.session); return; }
124
- const request = decode(message.payload, false);
125
- if (!['request', 'subscribe', 'unsubscribe'].includes(request.type)) throw new Error('Unsupported LAN message');
126
- send(current.socket, request);
141
+ current.forwardToBridge(message.payload);
127
142
  }
128
143
  } catch (error) { events.emit('diagnostic', error.message); remote.close(1008, 'Invalid relay message'); }
129
144
  });
@@ -6,7 +6,7 @@ import { connectBridgeRelay } from './connector.mjs';
6
6
  import { connectRelayClient } from './client.mjs';
7
7
  import { secret } from './wire.mjs';
8
8
 
9
- export async function startRelayDemo({ mode = 'lan-shared', pairingTtlMs, dispatch, remoteRelay, hostName = 'TEST-PC', lanCandidates = [] } = {}) {
9
+ export async function startRelayDemo({ mode = 'lan-shared', pairingTtlMs, dispatch, remoteRelay, hostName = 'TEST-PC', lanCandidates = [], relayOptions = {} } = {}) {
10
10
  if (!['lan-legacy', 'lan-shared'].includes(mode)) throw new Error('Invalid demo mode');
11
11
  const token = secret(), calls = [];
12
12
  const bridge = http.createServer((_request, response) => { response.writeHead(404); response.end(); });
@@ -22,7 +22,7 @@ export async function startRelayDemo({ mode = 'lan-shared', pairingTtlMs, dispat
22
22
  reply.json(404, { error: '模拟 Bridge 不执行真实任务' });
23
23
  } });
24
24
  await new Promise(resolve => bridge.listen(0, '127.0.0.1', resolve));
25
- const relay = remoteRelay ? { ...remoteRelay, async close() {} } : await startSelfRelay({ pairingTtlMs });
25
+ const relay = remoteRelay ? { ...remoteRelay, async close() {} } : await startSelfRelay({ pairingTtlMs, ...relayOptions });
26
26
  const bridgeUrl = `ws://127.0.0.1:${bridge.address().port}/api/socket`;
27
27
  const identity = remoteRelay ? { id: randomUUID(), secret: secret() } : undefined;
28
28
  const clientId = randomUUID(), credential = secret();
@@ -219,7 +219,7 @@ async function worker(stateRoot, instanceId, refresh = false, requiredMode = '')
219
219
  if (![1008, 4401, 4406, 4409, 4429].includes(event.code)) scheduleRetry();
220
220
  });
221
221
  const registration = await current.ready;
222
- if (registration.protocolVersion !== 3 || registration.room !== identity.id) {
222
+ if (registration.protocolVersion !== 7 || registration.room !== identity.id) {
223
223
  current.events.removeAllListeners(); current.close(); throw Object.assign(new Error('中继服务器尚未升级设备授权协议,未使用临时配对降级。'), { code: 4401 });
224
224
  }
225
225
  if (!identity.registered) {
@@ -1,83 +1,123 @@
1
- import { randomUUID } from 'node:crypto';
2
- import P2pDiagnostics from '../../shared/p2p-diagnostics.cjs';
3
-
4
- // Reserve this narrow range so a full-TUN client can identify only the P2P UDP flow.
5
- const PROBE_UDP_PORT_RANGE = Object.freeze({ begin: 42000, end: 42031 });
6
-
7
- // Capability probe only: never accepts or forwards Bridge business messages.
8
- export async function createP2pProbe({ signal, report = () => {}, iceServers = [] }) {
9
- if (!validProbeIceServers(iceServers)) throw new Error('Invalid probe STUN configuration');
10
- const { default: rtc } = await import('node-datachannel');
11
- const peer = new rtc.PeerConnection('code-relax-probe', {
12
- iceServers,
13
- maxMessageSize: 4096,
14
- portRangeBegin: PROBE_UDP_PORT_RANGE.begin,
15
- portRangeEnd: PROBE_UDP_PORT_RANGE.end
16
- });
17
- const diagnostic = new P2pDiagnostics();
18
- let channel, closed = false, answered = false, candidates = [], received = 0, verified = false;
19
- const nonce = randomUUID();
20
- const timer = setTimeout(() => finish(verified ? 'closed' : 'timeout'), 15000);
21
- function finish(state) {
22
- if (closed) return;
23
- closed = true; clearTimeout(timer); candidates = [];
24
- const summary = diagnostic.snapshot(state);
25
- channel?.close(); peer.close(); report(state, summary);
26
- }
27
- peer.onIceStateChange(state => { if (!closed) diagnostic.ice = state; });
28
- peer.onGatheringStateChange(state => { if (!closed) diagnostic.gathering = state; });
29
- peer.onStateChange(state => { if (!closed && (state === 'failed' || state === 'closed')) { diagnostic.error = 'peer'; finish('failed'); } });
30
- peer.onLocalDescription((sdp, type) => { if (!closed) { diagnostic.offer = type === 'offer'; signal({ kind: type, sdp, iceServers }); } });
31
- peer.onLocalCandidate((candidate, mid) => { if (!closed) { diagnostic.candidate('local', candidate); signal({ kind: 'candidate', candidate, mid }); } });
32
- channel = peer.createDataChannel('code-relax-probe-v1');
33
- channel.onOpen(() => { if (!closed) { diagnostic.open = true; channel.sendMessage(JSON.stringify({ type: 'ping', nonce })); } });
34
- channel.onError(() => { if (!closed) diagnostic.error = 'channel'; finish('failed'); });
35
- channel.onClosed(() => finish('closed'));
36
- channel.onMessage(value => {
37
- try {
38
- if (closed) return;
39
- if (typeof value !== 'string' || value.length > 4096 || ++received > 4) throw new Error('Invalid probe frame');
40
- const message = JSON.parse(value);
41
- if (message.type === 'ping' && /^[\w-]{16,128}$/.test(message.nonce)) {
42
- diagnostic.ping = true;
43
- channel.sendMessage(JSON.stringify({ type: 'pong', nonce: message.nonce }));
44
- } else if (message.type === 'pong' && message.nonce === nonce) {
45
- diagnostic.pong = true; verified = true; report('verified', diagnostic.snapshot('verified'));
46
- } else throw new Error('Invalid probe response');
47
- } catch { diagnostic.error = 'frame'; finish('failed'); }
48
- });
49
- return {
50
- receive(message) {
51
- if (closed) return;
52
- if (message.kind === 'answer' && !answered) {
53
- try { peer.setRemoteDescription(message.sdp, 'answer'); }
54
- catch { diagnostic.error = 'description'; finish('failed'); return; }
55
- answered = true; diagnostic.answer = true;
56
- for (const item of candidates) peer.addRemoteCandidate(item.candidate, item.mid);
57
- candidates = [];
58
- } else if (message.kind === 'candidate') {
59
- diagnostic.candidate('remote', message.candidate);
60
- if (answered) peer.addRemoteCandidate(message.candidate, message.mid);
61
- else if (candidates.length < 64) candidates.push(message);
62
- else finish('failed');
63
- } else if (message.kind === 'close') finish('closed');
64
- else finish('failed');
65
- },
66
- close() { finish('closed'); }
67
- };
68
- }
69
-
70
- export function validProbeSignal(value) {
71
- if (!value || value.version !== 1 || !/^[\w-]{16,128}$/.test(value.probeId || '')) return false;
72
- if (['start', 'close'].includes(value.kind)) return true;
73
- if (['offer', 'answer'].includes(value.kind)) return typeof value.sdp === 'string' && value.sdp.length <= 65536 &&
74
- (value.iceServers === undefined || validProbeIceServers(value.iceServers));
75
- if (value.kind === 'candidate') return typeof value.candidate === 'string' && value.candidate.length <= 2048 &&
76
- typeof value.mid === 'string' && value.mid.length <= 64;
77
- return false;
78
- }
79
-
80
- export function validProbeIceServers(value) {
81
- return Array.isArray(value) && value.length <= 4 && value.every(item =>
82
- typeof item === 'string' && /^stun:[a-zA-Z0-9.-]+:[0-9]{1,5}$/.test(item) && Number(item.split(':').at(-1)) <= 65535);
83
- }
1
+ import { randomUUID } from 'node:crypto';
2
+ import P2pDiagnostics from '../../shared/p2p-diagnostics.cjs';
3
+ import P2pDataFramer from '../../shared/p2p-data-channel.cjs';
4
+
5
+ const PROBE_UDP_PORT_RANGE = Object.freeze({ begin: 42000, end: 42031 });
6
+ const HIGH_WATER = 1024 * 1024;
7
+ const LOW_WATER = 512 * 1024;
8
+ const MAX_QUEUED = 16 * 1024 * 1024;
9
+
10
+ export async function createP2pProbe({ signal, report = () => {}, onData = () => {}, iceServers = [] }) {
11
+ if (!validProbeIceServers(iceServers)) throw new Error('Invalid probe STUN configuration');
12
+ const { default: rtc } = await import('node-datachannel');
13
+ const peer = new rtc.PeerConnection('code-relax-probe', {
14
+ iceServers,
15
+ maxMessageSize: 65536,
16
+ portRangeBegin: PROBE_UDP_PORT_RANGE.begin,
17
+ portRangeEnd: PROBE_UDP_PORT_RANGE.end
18
+ });
19
+ const diagnostic = new P2pDiagnostics();
20
+ const framer = new P2pDataFramer();
21
+ const dataPrefix = randomUUID().replaceAll('-', '');
22
+ let channel, closed = false, answered = false, candidates = [], verified = false, remoteDirect = false;
23
+ let pingReceived = false, pongReceived = false, dataSerial = 0, queuedBytes = 0;
24
+ const outbound = [];
25
+ const nonce = randomUUID();
26
+ const timer = setTimeout(() => finish(verified ? 'closed' : 'timeout'), 15000);
27
+ function finish(state) {
28
+ if (closed) return;
29
+ closed = true; clearTimeout(timer); candidates = []; queuedBytes = 0; outbound.length = 0; framer.reset();
30
+ const summary = diagnostic.snapshot(state);
31
+ channel?.close(); peer.close(); report(state, summary);
32
+ }
33
+ function verify() {
34
+ if (verified || !pingReceived || !pongReceived) return;
35
+ verified = true; clearTimeout(timer);
36
+ report(remoteDirect ? 'direct' : 'verified', diagnostic.snapshot('verified'));
37
+ pump();
38
+ }
39
+ function pump() {
40
+ if (!verified || !remoteDirect || closed || !channel?.isOpen()) return;
41
+ try {
42
+ while (outbound.length && channel.bufferedAmount() < HIGH_WATER) {
43
+ const item = outbound[0], frame = framer.frame(item);
44
+ if (!channel.sendMessage(frame)) return;
45
+ if (framer.commit(item)) { outbound.shift(); queuedBytes -= item.bytes; }
46
+ }
47
+ } catch { diagnostic.error = 'channel'; finish('failed'); }
48
+ }
49
+ function sendData(payload) {
50
+ if (!verified || !remoteDirect || closed || !channel?.isOpen()) return false;
51
+ let item;
52
+ try { item = framer.create(payload, `${dataPrefix}-${++dataSerial}`); }
53
+ catch { return false; }
54
+ if (queuedBytes + item.bytes > MAX_QUEUED) return false;
55
+ outbound.push(item); queuedBytes += item.bytes; pump(); return true;
56
+ }
57
+ peer.onIceStateChange(state => { if (!closed) diagnostic.ice = state; });
58
+ peer.onGatheringStateChange(state => { if (!closed) diagnostic.gathering = state; });
59
+ peer.onStateChange(state => { if (!closed && (state === 'failed' || state === 'closed')) { diagnostic.error = 'peer'; finish('failed'); } });
60
+ peer.onLocalDescription((sdp, type) => { if (!closed) { diagnostic.offer = type === 'offer'; signal({ kind: type, sdp, iceServers, direct: 1 }); } });
61
+ peer.onLocalCandidate((candidate, mid) => { if (!closed) { diagnostic.candidate('local', candidate); signal({ kind: 'candidate', candidate, mid }); } });
62
+ channel = peer.createDataChannel('code-relax-probe-v1');
63
+ channel.setBufferedAmountLowThreshold(LOW_WATER);
64
+ channel.onBufferedAmountLow(pump);
65
+ channel.onOpen(() => { if (!closed) { diagnostic.open = true; channel.sendMessage(JSON.stringify({ type: 'ping', nonce })); } });
66
+ channel.onError(() => { if (!closed) diagnostic.error = 'channel'; finish('failed'); });
67
+ channel.onClosed(() => finish('closed'));
68
+ channel.onMessage(value => {
69
+ try {
70
+ if (closed || typeof value !== 'string' || value.length > 65536) throw new Error('Invalid P2P frame');
71
+ const message = JSON.parse(value);
72
+ const data = framer.accept(message);
73
+ if (data.handled) {
74
+ if (!verified || !remoteDirect) throw new Error('P2P transport not verified');
75
+ if (data.complete) onData(data.payload);
76
+ return;
77
+ }
78
+ if (message.type === 'ping' && /^[\w-]{16,128}$/.test(message.nonce)) {
79
+ diagnostic.ping = true; pingReceived = true;
80
+ channel.sendMessage(JSON.stringify({ type: 'pong', nonce: message.nonce }));
81
+ } else if (message.type === 'pong' && message.nonce === nonce) {
82
+ diagnostic.pong = true; pongReceived = true;
83
+ } else throw new Error('Invalid P2P response');
84
+ verify();
85
+ } catch { diagnostic.error = 'frame'; finish('failed'); }
86
+ });
87
+ return {
88
+ receive(message) {
89
+ if (closed) return;
90
+ if (message.kind === 'answer' && !answered) {
91
+ try { peer.setRemoteDescription(message.sdp, 'answer'); }
92
+ catch { diagnostic.error = 'description'; finish('failed'); return; }
93
+ answered = true; remoteDirect = message.direct === 1; diagnostic.answer = true;
94
+ for (const item of candidates) peer.addRemoteCandidate(item.candidate, item.mid);
95
+ candidates = [];
96
+ } else if (message.kind === 'candidate') {
97
+ diagnostic.candidate('remote', message.candidate);
98
+ if (answered) peer.addRemoteCandidate(message.candidate, message.mid);
99
+ else if (candidates.length < 64) candidates.push(message);
100
+ else finish('failed');
101
+ } else if (message.kind === 'close') finish('closed');
102
+ else finish('failed');
103
+ },
104
+ send: sendData,
105
+ close() { finish('closed'); }
106
+ };
107
+ }
108
+
109
+ export function validProbeSignal(value) {
110
+ if (!value || value.version !== 1 || !/^[\w-]{16,128}$/.test(value.probeId || '')) return false;
111
+ if (['start', 'close'].includes(value.kind)) return true;
112
+ if (['offer', 'answer'].includes(value.kind)) return typeof value.sdp === 'string' && value.sdp.length <= 65536 &&
113
+ (value.direct === undefined || value.direct === 1) &&
114
+ (value.iceServers === undefined || validProbeIceServers(value.iceServers));
115
+ if (value.kind === 'candidate') return typeof value.candidate === 'string' && value.candidate.length <= 2048 &&
116
+ typeof value.mid === 'string' && value.mid.length <= 64;
117
+ return false;
118
+ }
119
+
120
+ export function validProbeIceServers(value) {
121
+ return Array.isArray(value) && value.length <= 4 && value.every(item =>
122
+ typeof item === 'string' && /^stun:[a-zA-Z0-9.-]+:[0-9]{1,5}$/.test(item) && Number(item.split(':').at(-1)) <= 65535);
123
+ }
@@ -1,17 +1,19 @@
1
1
  import http from 'node:http';
2
2
  import { deviceStore, validId } from './device-store.mjs';
3
- import { randomUUID } from 'node:crypto';
3
+ import { randomUUID } from 'node:crypto';
4
4
  import { WebSocketServer } from 'ws';
5
5
  import { secret, digest, matches, validSecret, send, decode } from './wire.mjs';
6
6
  import { readDebugAllowlist, debugAllowed } from './debug-allowlist.mjs';
7
7
  import { validProbeSignal } from './p2p-probe.mjs';
8
-
8
+ import { relayAdminState } from './admin-state.mjs';
9
+
9
10
  // Always loopback; a TLS reverse proxy supplies the private-trial Internet endpoint.
10
11
  export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlMs = 120000,
11
12
  maxConnections = 32, maxClientsPerHost = 16, maxPayload = 8 * 1024 * 1024, stateFile,
12
- publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
13
+ maxRelaySessions = 8, adminStateFile, publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
13
14
  if (!validSecret(hostKey)) throw new Error('Invalid host credential');
14
15
  if (!Number.isSafeInteger(maxClientsPerHost) || maxClientsPerHost < 1 || maxClientsPerHost > 64) throw new Error('Invalid mobile device limit');
16
+ if (!Number.isSafeInteger(maxRelaySessions) || maxRelaySessions < 1 || maxRelaySessions > 256) throw new Error('Invalid relay session limit');
15
17
  if (publicRelayUrl) relayPublicAddress(publicRelayUrl);
16
18
  // Harmony's native WebSocket supplies the endpoint Origin automatically.
17
19
  // libwebsockets can use http even for WSS; trust only the configured endpoint.
@@ -20,6 +22,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
20
22
  const hostHash = digest(hostKey), rooms = new Map();
21
23
  const store = deviceStore(stateFile);
22
24
  const debugAllowlist = readDebugAllowlist(debugAllowlistFile);
25
+ const adminState = relayAdminState(adminStateFile, maxRelaySessions);
26
+ let activeRelaySessions = 0;
23
27
  for (const record of store.entries()) rooms.set(record.id, { ...record, durable: true, connections: new Map(), observers: new Map() });
24
28
  function commit(room, changes) {
25
29
  const next = { ...room, ...changes, updatedAt: new Date().toISOString() };
@@ -46,6 +50,13 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
46
50
  }
47
51
  const server = http.createServer((request, response) => {
48
52
  const target = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
53
+ if (target.pathname === '/admin' || target.pathname.startsWith('/admin/')) {
54
+ void handleAdmin(request, response, target); return;
55
+ }
56
+ if (request.method === 'GET' && target.pathname === '/announcement') {
57
+ const body = JSON.stringify({ announcement: adminState.read().announcement });
58
+ response.writeHead(200, pairingHeaders('application/json; charset=utf-8', body)); response.end(body); return;
59
+ }
49
60
  if (request.method !== 'GET' || !['/pair', '/pair/resolve'].includes(target.pathname)) {
50
61
  response.writeHead(404, { 'Cache-Control': 'no-store' }); response.end(); return;
51
62
  }
@@ -57,7 +68,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
57
68
  response.writeHead(410, pairingHeaders(json ? 'application/json; charset=utf-8' : 'text/html; charset=utf-8', body)); response.end(body); return;
58
69
  }
59
70
  if (target.pathname === '/pair/resolve' || /application\/json/i.test(request.headers.accept || '')) {
60
- const body = JSON.stringify({ version: 3, relayUrl: relayUrl(request), room: room.id, expires: room.expires });
71
+ const body = JSON.stringify({ version: 6, relayUrl: relayUrl(request), room: room.id, expires: room.expires });
61
72
  response.writeHead(200, pairingHeaders('application/json; charset=utf-8', body)); response.end(body); return;
62
73
  }
63
74
  const secure = request.socket.encrypted || request.headers['x-forwarded-proto'] === 'https';
@@ -65,6 +76,63 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
65
76
  const body = pairingPage({ deepLink: `codex-remote://pair?url=${encodeURIComponent(current.href)}`, expires: room.expires, appDownloadUrl });
66
77
  response.writeHead(200, pairingHeaders('text/html; charset=utf-8', body)); response.end(body);
67
78
  });
79
+ function adminReply(response, status, body, type = 'application/json; charset=utf-8') {
80
+ const value = typeof body === 'string' ? body : JSON.stringify(body);
81
+ response.writeHead(status, { 'Content-Type': type, 'Content-Length': Buffer.byteLength(value), 'Cache-Control': 'no-store',
82
+ '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
+ response.end(value);
84
+ }
85
+ async function handleAdmin(request, response, target) {
86
+ if (request.method === 'GET' && target.pathname === '/admin') {
87
+ adminReply(response, 200, adminPage(), 'text/html; charset=utf-8'); return;
88
+ }
89
+ if (request.method === 'GET' && target.pathname === '/admin/api/status') {
90
+ const state = adminState.read();
91
+ adminReply(response, 200, { maxRelaySessions: state.maxRelaySessions, activeRelaySessions,
92
+ activeExemptRelaySessions: [...rooms.values()].reduce((sum, room) => sum + [...room.connections.values()].filter(connection => connection.relayExempt).length, 0),
93
+ 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;
95
+ }
96
+ if (!['PUT', 'DELETE'].includes(request.method || '') || request.headers['content-type'] !== 'application/json' ||
97
+ request.headers['x-code-relax-admin'] !== '1') { adminReply(response, 405, { error: 'Unsupported admin request' }); return; }
98
+ try {
99
+ const body = request.method === 'DELETE' ? {} : JSON.parse(await readBody(request, 4096));
100
+ if (target.pathname === '/admin/api/settings' && request.method === 'PUT') {
101
+ const state = adminState.setMaxRelaySessions(body.maxRelaySessions);
102
+ 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' });
109
+ } else { adminReply(response, 404, { error: 'Admin route not found' }); return; }
110
+ adminReply(response, 200, { ok: true });
111
+ } catch (error) { adminReply(response, 400, { error: error.message }); }
112
+ }
113
+ function broadcastAnnouncement(announcement) {
114
+ for (const room of rooms.values()) {
115
+ for (const observer of room.observers.values()) send(observer.ws, { type: 'announcement', announcement });
116
+ for (const connection of room.connections.values()) send(connection.ws, { type: 'announcement', announcement });
117
+ }
118
+ }
119
+ function releaseRelay(connection) {
120
+ if (!connection?.relayAdmitted) return;
121
+ connection.relayAdmitted = false; activeRelaySessions = Math.max(0, activeRelaySessions - 1);
122
+ }
123
+ function admitRelay(connection, room) {
124
+ if (connection.relayAdmitted || connection.relayExempt) return true;
125
+ if (connection.capacityExempt) {
126
+ connection.relayExempt = true;
127
+ log({ event: 'relay-capacity-exempt', device: room.id }); return true;
128
+ }
129
+ const limit = adminState.read().maxRelaySessions;
130
+ if (activeRelaySessions >= limit) {
131
+ log({ event: 'relay-capacity-rejected', device: room.id, activeRelaySessions, maxRelaySessions: limit }); return false;
132
+ }
133
+ connection.relayAdmitted = true; activeRelaySessions++;
134
+ log({ event: 'relay-capacity-admitted', device: room.id, activeRelaySessions, maxRelaySessions: limit }); return true;
135
+ }
68
136
  const wss = new WebSocketServer({ noServer: true, maxPayload, perMessageDeflate: {
69
137
  serverNoContextTakeover: true, clientNoContextTakeover: true,
70
138
  threshold: 1024, concurrencyLimit: 4, zlibDeflateOptions: { level: 3 },
@@ -81,12 +149,24 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
81
149
  }
82
150
  wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws));
83
151
  });
84
- wss.on('connection', ws => {
152
+ wss.on('connection', ws => {
85
153
  let role, room, session, clientId, alive = true;
86
154
  const authTimer = setTimeout(() => ws.close(1008, 'Authentication timeout'), 5000);
87
155
  ws.on('pong', () => { alive = true; });
88
156
  const heartbeat = setInterval(() => { if (!alive) ws.terminate(); else { alive = false; ws.ping(); } }, 30000);
89
- const fail = (code = 4401, reason = 'Device authorization rejected') => ws.close(code, reason);
157
+ const fail = (code = 4401, reason = 'Device authorization rejected') => ws.close(code, reason);
158
+ const queueClientMessage = (connection, message, pause = 0) => {
159
+ const bytes = Buffer.byteLength(JSON.stringify(message));
160
+ connection.queuedBytes = (connection.queuedBytes || 0) + bytes;
161
+ if (connection.queuedBytes > 40 * 1024 * 1024) {
162
+ connection.ws.close(1013, 'Receiver too slow'); return;
163
+ }
164
+ connection.outbound = (connection.outbound || Promise.resolve()).then(async () => {
165
+ if (pause > 0) await new Promise(resolve => setTimeout(resolve, pause));
166
+ connection.queuedBytes -= bytes;
167
+ send(connection.ws, message);
168
+ }).catch(() => connection.ws.close(1011, 'Relay delivery failed'));
169
+ };
90
170
  ws.on('error', () => {}); // close owns routing cleanup.
91
171
  ws.on('close', (code, reason) => {
92
172
  log({ event: 'closed', role: role || 'unauthenticated', device: room?.id, code, reason: reason.toString() });
@@ -97,6 +177,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
97
177
  if (!room.durable) rooms.delete(room.id);
98
178
  for (const connection of room.connections.values()) connection.ws.close(4410, 'Computer offline; authorization retained');
99
179
  } else if (role === 'client' && room?.connections.get(clientId)?.ws === ws) {
180
+ releaseRelay(room.connections.get(clientId));
100
181
  room.connections.delete(clientId);
101
182
  if (room.host) send(room.host, { type: 'detach', session });
102
183
  } else if (role === 'observer' && room?.observers.get(clientId)?.ws === ws) {
@@ -143,7 +224,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
143
224
  send(observer.ws, { type: 'lan-candidates', candidates: room.lanCandidates });
144
225
  }
145
226
  const invite = room.clients.length ? {} : invitation(room);
146
- send(ws, { type: 'registered', protocolVersion: 3, room: room.id, paired: room.clients.length > 0,
227
+ send(ws, { type: 'registered', protocolVersion: 7, room: room.id, paired: room.clients.length > 0,
147
228
  clientCount: room.clients.length, clients: room.clients, revision: room.revision, ...invite }); return;
148
229
  }
149
230
  room = rooms.get(message.room);
@@ -177,6 +258,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
177
258
  room.observers.set(clientId, { ws });
178
259
  send(ws, { type: 'observing', online: Boolean(room.host) });
179
260
  send(ws, { type: 'debug-permissions', enabled: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
261
+ const announcement = adminState.read().announcement;
262
+ if (announcement) send(ws, { type: 'announcement', announcement });
180
263
  if (room.hostName) send(ws, { type: 'computer-info', hostName: room.hostName });
181
264
  if (room.lanCandidates?.length) send(ws, { type: 'lan-candidates', candidates: room.lanCandidates });
182
265
  if (room.officialComputer) send(ws, { type: 'official-computer', computer: room.officialComputer });
@@ -186,14 +269,19 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
186
269
  const oldConnection = room.connections.get(clientId);
187
270
  role = 'client'; clearTimeout(authTimer); session = randomUUID();
188
271
  if (oldConnection?.ws && oldConnection.ws !== ws) {
272
+ releaseRelay(oldConnection);
189
273
  send(room.host, { type: 'detach', session: oldConnection.session });
190
274
  oldConnection.ws.close(4409, 'Phone connection replaced by same identity');
191
275
  }
192
276
  room.connections.set(clientId, { ws, session, p2pProbe: room.p2pProbe && message.p2pProbe === 1,
193
- repeat: room.p2pProbeRepeat, signals: 0, probeStarted: 0 });
194
- send(ws, { type: 'paired', protocolVersion: 3, room: room.id, clientId, revision: room.revision, ...(credential ? { credential } : {}) });
277
+ repeat: room.p2pProbeRepeat, signals: 0, probeStarted: 0, outbound: Promise.resolve(), queuedBytes: 0,
278
+ relayAdmitted: false, relayExempt: false,
279
+ capacityExempt: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
280
+ send(ws, { type: 'paired', protocolVersion: 7, room: room.id, clientId, revision: room.revision, ...(credential ? { credential } : {}) });
195
281
  send(ws, { type: 'computer-info', hostName: room.hostName });
196
282
  if (room.lanCandidates?.length) send(ws, { type: 'lan-candidates', candidates: room.lanCandidates });
283
+ const announcement = adminState.read().announcement;
284
+ if (announcement) send(ws, { type: 'announcement', announcement });
197
285
  send(room.host, { type: 'attach', session, clientId, p2pProbe: room.connections.get(clientId).p2pProbe ? 1 : 0 }); return;
198
286
  }
199
287
  if (role === 'host') {
@@ -214,6 +302,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
214
302
  commit(room, { clients: [], revision: room.revision + 1 });
215
303
  room.pairingHash = undefined;
216
304
  for (const connection of room.connections.values()) {
305
+ releaseRelay(connection);
217
306
  send(ws, { type: 'detach', session: connection.session });
218
307
  connection.ws.close(4401, 'Device authorization revoked');
219
308
  }
@@ -225,8 +314,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
225
314
  }
226
315
  const connection = [...room.connections.values()].find(item => item.session === message.session);
227
316
  if (!connection) return; // Late frames from an ended session.
228
- if (message.type === 'data' && typeof message.payload === 'string') send(connection.ws, { type: 'data', payload: message.payload });
229
- else if (message.type === 'ready') send(connection.ws, { type: 'ready', p2pProbe: connection.p2pProbe ? 1 : 0,
317
+ if (message.type === 'data' && typeof message.payload === 'string') queueClientMessage(connection, { type: 'data', payload: message.payload });
318
+ else if (message.type === 'ready') queueClientMessage(connection, { type: 'ready', p2pProbe: connection.p2pProbe ? 1 : 0,
230
319
  p2pProbeRepeat: connection.p2pProbe && connection.repeat });
231
320
  else if (message.type === 'p2p-probe' && connection.p2pProbe && validProbeSignal(message)) {
232
321
  if (connection.probeId !== message.probeId) return;
@@ -251,7 +340,13 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
251
340
  if (connection.probeId !== message.probeId) return;
252
341
  if (++connection.signals > 150) return fail(1008, 'Probe signal limit');
253
342
  send(room.host, { ...message, session });
343
+ } else if (room.connections.get(clientId)?.ws === ws && message.type === 'p2p-route' && message.direct === 1) {
344
+ releaseRelay(room.connections.get(clientId));
254
345
  } else if (room.connections.get(clientId)?.ws === ws && message.type === 'data' && typeof message.payload === 'string') {
346
+ const connection = room.connections.get(clientId);
347
+ if (!controlPayload(message.payload) && !admitRelay(connection, room)) {
348
+ ws.close(4428, 'Server busy'); return;
349
+ }
255
350
  send(room.host, { type: 'data', session, payload: message.payload });
256
351
  } else fail();
257
352
  } catch (error) { log({ event: 'request-error', role, message: 'Relay operation failed' }); ws.close(1011, 'Relay operation failed'); }
@@ -338,3 +433,34 @@ function plainLabel(value) {
338
433
  function plainText(value, limit, empty) {
339
434
  return typeof value === 'string' && (empty || value.trim().length > 0) && value.trim().length <= limit && !/[\u0000-\u001f\u007f]/.test(value);
340
435
  }
436
+
437
+ function controlPayload(value) {
438
+ try {
439
+ const message = JSON.parse(value);
440
+ if (message.type === 'unsubscribe') return true;
441
+ return ['request', 'subscribe'].includes(message.type) && typeof message.path === 'string' &&
442
+ (message.path === '/api/health' || message.path.startsWith('/api/shared-recovery'));
443
+ } catch { return false; }
444
+ }
445
+
446
+ function readBody(request, limit) {
447
+ return new Promise((resolve, reject) => {
448
+ let value = '';
449
+ request.setEncoding('utf8');
450
+ request.on('data', chunk => {
451
+ value += chunk;
452
+ if (Buffer.byteLength(value) > limit) { reject(new Error('Admin request too large')); request.destroy(); }
453
+ });
454
+ request.on('end', () => resolve(value)); request.on('error', reject);
455
+ });
456
+ }
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
+ }