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

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 (91) hide show
  1. package/README.md +14 -15
  2. package/dist/bin/codex-remote.mjs +81 -26
  3. package/dist/bin/self-relay-admin.mjs +8 -0
  4. package/dist/bin/self-relay-demo.mjs +1 -1
  5. package/dist/bin/self-relay-server.mjs +5 -2
  6. package/dist/shared/app-server-events.cjs +50 -22
  7. package/dist/shared/p2p-data-channel.cjs +50 -0
  8. package/dist/skills/code-relax/SKILL.md +1 -1
  9. package/dist/src/desktop-launcher.mjs +11 -11
  10. package/dist/src/lan-socket-server.mjs +8 -2
  11. package/dist/src/managed-app-server.mjs +27 -7
  12. package/dist/src/message-images.mjs +0 -18
  13. package/dist/src/onboarding.mjs +3 -5
  14. package/dist/src/platform/README.md +1 -1
  15. package/dist/src/platform/windows/IsolatedProcess.cs +7 -3
  16. package/dist/src/platform/windows/background-process.mjs +7 -2
  17. package/dist/src/platform/windows/codex-runtime.mjs +117 -0
  18. package/dist/src/platform/windows/desktop-host.mjs +16 -1
  19. package/dist/src/platform/windows/desktop-info.ps1 +2 -4
  20. package/dist/src/platform/windows/desktop-shortcut-run.ps1 +1 -1
  21. package/dist/src/platform/windows/desktop-shortcuts.ps1 +1 -1
  22. package/dist/src/platform/windows/launch-worker.mjs +42 -0
  23. package/dist/src/platform/windows/resident-startup.ps1 +58 -18
  24. package/dist/src/platform/windows/service-host.mjs +4 -7
  25. package/dist/src/platform/windows/start-hidden-console.ps1 +18 -8
  26. package/dist/src/platform/windows/stop-desktop.ps1 +7 -4
  27. package/dist/src/runtime-discovery.mjs +43 -0
  28. package/dist/src/self-relay/admin-cloud.mjs +101 -0
  29. package/dist/src/self-relay/admin-page.mjs +71 -0
  30. package/dist/src/self-relay/admin-server.mjs +61 -0
  31. package/dist/src/self-relay/admin-state.mjs +122 -0
  32. package/dist/src/self-relay/client.mjs +2 -0
  33. package/dist/src/self-relay/connector.mjs +23 -8
  34. package/dist/src/self-relay/demo.mjs +4 -5
  35. package/dist/src/self-relay/lifecycle.mjs +53 -49
  36. package/dist/src/self-relay/p2p-probe.mjs +123 -83
  37. package/dist/src/self-relay/server.mjs +157 -17
  38. package/dist/src/server.mjs +484 -1274
  39. package/dist/src/service-doctor.mjs +13 -25
  40. package/dist/src/service-lifecycle.mjs +12 -12
  41. package/dist/src/shared-app-server.mjs +192 -34
  42. package/dist/src/shared-catalog.mjs +4 -14
  43. package/dist/src/shared-recovery.mjs +8 -6
  44. package/dist/src/shared-thread-stream.mjs +1 -1
  45. package/dist/web/activity-view.js +283 -0
  46. package/dist/web/capabilities.js +2 -9
  47. package/dist/web/chat-transport.js +21 -21
  48. package/dist/web/chat.css +148 -106
  49. package/dist/web/chat.js +1454 -2788
  50. package/dist/web/community.css +82 -0
  51. package/dist/web/community.html +36 -0
  52. package/dist/web/composer-controller.js +97 -0
  53. package/dist/web/conversation-controller.js +99 -0
  54. package/dist/web/disclosure-state-controller.js +95 -0
  55. package/dist/web/draft-controller.js +103 -0
  56. package/dist/web/harmony-platform.js +3 -2
  57. package/dist/web/history-cache.js +112 -18
  58. package/dist/web/history-controller.js +167 -0
  59. package/dist/web/index.html +113 -47
  60. package/dist/web/link-action-controller.js +212 -0
  61. package/dist/web/message-send-controller.js +177 -0
  62. package/dist/web/message-view.js +98 -0
  63. package/dist/web/p2p-data-channel.js +50 -0
  64. package/dist/web/p2p-probe.js +67 -27
  65. package/dist/web/page-resume.js +28 -0
  66. package/dist/web/pending-message-store.js +108 -0
  67. package/dist/web/queue-controller.js +82 -0
  68. package/dist/web/resources.json +1 -1
  69. package/dist/web/self-relay-session.js +28 -18
  70. package/dist/web/station-connection-controller.js +75 -0
  71. package/dist/web/task-list-view.js +289 -0
  72. package/dist/web/thread-attention-controller.js +124 -0
  73. package/dist/web/thread-context-controller.js +30 -0
  74. package/dist/web/thread-list-controller.js +61 -0
  75. package/dist/web/thread-list-sync.js +86 -0
  76. package/dist/web/thread-title-controller.js +57 -0
  77. package/dist/web/timeline-formatters.js +249 -0
  78. package/dist/web/timeline-reducer.js +77 -0
  79. package/dist/web/timeline-renderer.js +161 -0
  80. package/dist/web/timeline-scroll-controller.js +50 -0
  81. package/dist/web/usage-controller.js +258 -0
  82. package/dist/web/vendor/lucide.LICENSE.txt +17 -0
  83. package/package.json +16 -18
  84. package/tools/postinstall.mjs +140 -3
  85. package/dist/src/app-server-client.mjs +0 -468
  86. package/dist/src/app-server-tasks.mjs +0 -355
  87. package/dist/src/platform/windows/desktop-monitor.mjs +0 -34
  88. package/dist/src/platform/windows/desktop-tools.mjs +0 -48
  89. package/dist/src/platform/windows/launch-worker.ps1 +0 -13
  90. package/dist/src/thread-catalog.mjs +0 -42
  91. package/dist/tools/find-desktop-pipe.mjs +0 -10
@@ -1,9 +1,9 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { execFile } from 'node:child_process';
4
- import { setTimeout as delay } from 'node:timers/promises';
5
- import { startBackground, processAlive } from '../background-process.mjs';
6
- import { promisify } from 'node:util';
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFile } from 'node:child_process';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ import { startBackground, processAlive } from '../background-process.mjs';
6
+ import { promisify } from 'node:util';
7
7
  import { randomBytes, randomUUID } from 'node:crypto';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { hostname } from 'node:os';
@@ -16,45 +16,49 @@ import { connectBridgeRelay } from './connector.mjs';
16
16
  import { validProbeIceServers } from './p2p-probe.mjs';
17
17
  import { relayAddress, validSecret } from './wire.mjs';
18
18
  import { saveLanAuthorizations } from './lan-authorizations.mjs';
19
- import { lanNetworks } from '../platform/windows/network-info.mjs';
20
-
21
- const exec = promisify(execFile);
22
- const script = fileURLToPath(import.meta.url);
23
- const recordPath = root => path.join(root, 'self-relay-session.json');
24
-
25
- async function loadConfig(stateRoot) {
26
- const config = readRecord(path.join(stateRoot, 'self-relay-config.json')) || {};
19
+ import { lanNetworks } from '../platform/windows/network-info.mjs';
20
+
21
+ const exec = promisify(execFile);
22
+ const script = fileURLToPath(import.meta.url);
23
+ const recordPath = root => path.join(root, 'self-relay-session.json');
24
+ const officialConfig = { url: 'wss://api.uulife.site/relay' };
25
+
26
+ async function loadConfig(stateRoot) {
27
+ const configPath = path.join(stateRoot, 'self-relay-config.json');
28
+ const stored = readRecord(configPath) || {};
29
+ const config = { ...officialConfig, ...stored };
30
+ if (!stored.url) writeRecord(configPath, config);
27
31
  if (config.p2pIceServers !== undefined && !validProbeIceServers(config.p2pIceServers)) throw new Error('p2pIceServers 只接受最多 4 个 stun:主机:端口地址');
28
32
  if (config.p2pWindowsRouteHelper !== undefined && typeof config.p2pWindowsRouteHelper !== 'boolean') throw new Error('p2pWindowsRouteHelper 必须是布尔值');
29
33
  if (config.p2pWindowsRouteHelper && !/^[A-Za-z0-9_-]{32,128}$/.test(config.p2pWindowsRouteHelperToken || '')) throw new Error('p2pWindowsRouteHelperToken 无效');
30
34
  const url = process.env.RELAY_URL || config.url;
31
- if (!url) throw new Error('请先配置 self-relay-config.json 的 url 和 hostKeyFile(或 sshHost、sshKeyPath),详见自建中继文档。');
35
+ if (!url) throw new Error('请先配置 self-relay-config.json 的 url 和 hostKeyFile(或 sshHost、sshKeyPath),详见自建中继文档。');
32
36
  relayAddress(url);
33
37
  const resolved = relayAddress(url);
34
38
  const pairingUrl = config.pairingUrl || `${resolved.startsWith('wss:') ? 'https:' : 'http:'}//${new URL(resolved).host}/pair`;
35
39
  const pairing = new URL(pairingUrl);
36
40
  if (!['https:', 'http:'].includes(pairing.protocol) || pairing.pathname !== '/pair' || pairing.search || pairing.hash || pairing.username || pairing.password ||
37
41
  (pairing.protocol === 'http:' && pairing.hostname !== '127.0.0.1')) throw new Error('self-relay-config.json 的 pairingUrl 必须是 HTTPS /pair 地址。');
38
- return { ...config, url: resolved, pairingUrl: pairing.href };
39
- }
40
- async function registrationKey(config) {
41
- const hostKeyFile = process.env.RELAY_HOST_KEY_FILE || config.hostKeyFile;
42
- let hostKey;
43
- if (hostKeyFile) hostKey = fs.readFileSync(hostKeyFile, 'utf8').trim();
44
- else {
45
- if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(config.sshHost || '') ||
46
- !/^\/[a-zA-Z0-9/_.-]+$/.test(config.sshKeyPath || '')) throw new Error('中继注册凭据配置不完整或无效。');
47
- try {
48
- const result = await exec('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', config.sshHost,
49
- `sudo -n cat ${config.sshKeyPath}`], { encoding: 'utf8', timeout: 15000, windowsHide: true });
50
- hostKey = result.stdout.trim();
51
- } catch { throw new Error('无法通过已配置 SSH 读取中继注册凭据。'); }
52
- }
53
- if (!validSecret(hostKey)) throw new Error('中继注册凭据格式无效。');
54
- return hostKey;
55
- }
56
-
57
- export async function selfRelay(command, stateRoot, requiredMode = '') {
42
+ return { ...config, url: resolved, pairingUrl: pairing.href };
43
+ }
44
+ async function registrationKey(config) {
45
+ if (config.url === officialConfig.url) return undefined;
46
+ const hostKeyFile = process.env.RELAY_HOST_KEY_FILE || config.hostKeyFile;
47
+ let hostKey;
48
+ if (hostKeyFile) hostKey = fs.readFileSync(hostKeyFile, 'utf8').trim();
49
+ else {
50
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(config.sshHost || '') ||
51
+ !/^\/[a-zA-Z0-9/_.-]+$/.test(config.sshKeyPath || '')) throw new Error('中继注册凭据配置不完整或无效。');
52
+ try {
53
+ const result = await exec('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', config.sshHost,
54
+ `sudo -n cat ${config.sshKeyPath}`], { encoding: 'utf8', timeout: 15000, windowsHide: true });
55
+ hostKey = result.stdout.trim();
56
+ } catch { throw new Error('无法通过已配置 SSH 读取中继注册凭据。'); }
57
+ }
58
+ if (!validSecret(hostKey)) throw new Error('中继注册凭据格式无效。');
59
+ return hostKey;
60
+ }
61
+ export async function selfRelay(command, stateRoot, requiredMode = '') {
58
62
  const previous = readRecord(recordPath(stateRoot));
59
63
  let current;
60
64
  if (previous) {
@@ -75,7 +79,7 @@ export async function selfRelay(command, stateRoot, requiredMode = '') {
75
79
  if (current) {
76
80
  if (requiredMode) {
77
81
  const bridge = await serviceHealth(readRecord(path.join(stateRoot, 'bridge-session.json')));
78
- if (bridge?.transportMode !== requiredMode) throw new Error('当前 Bridge 未启用完整控制;请先执行 shared-start。');
82
+ if (bridge?.transportMode !== requiredMode) throw new Error('Codex 内核未就绪;请先执行 shared-start。');
79
83
  }
80
84
  if (command === 'relay-revoke') return controlRequest(previous, 'POST', 'revoke');
81
85
  if (command === 'relay-pair' || (current.relayConnected && !current.paired && !current.qrPath)) return controlRequest(previous, 'POST', 'pair');
@@ -111,7 +115,7 @@ async function worker(stateRoot, instanceId, refresh = false, requiredMode = '')
111
115
  const session = () => readRecord(path.join(stateRoot, 'bridge-session.json'));
112
116
  let health = await serviceHealth(session());
113
117
  if (health?.status !== 'ok') throw new Error('Bridge 尚未运行,请先执行 relax start --json。');
114
- if (requiredMode && health.transportMode !== requiredMode) throw new Error('完整控制的 Code Relax 中继需要完整控制 Bridge,请先执行 relax shared-start --json。');
118
+ if (requiredMode && health.transportMode !== requiredMode) throw new Error('Codex 内核未就绪,请先执行 relax shared-start --json。');
115
119
  const legacyPath = path.join(stateRoot, 'self-relay-device.json');
116
120
  const legacy = readRecord(legacyPath);
117
121
  const stored = await credentialStore('load');
@@ -134,15 +138,15 @@ async function worker(stateRoot, instanceId, refresh = false, requiredMode = '')
134
138
  fs.rmSync(legacyPath);
135
139
  }
136
140
  let identity = identities.servers[config.url];
137
- if (!identity) {
138
- identity = { id: randomUUID(), secret: randomBytes(32).toString('base64url'), registered: false };
139
- identities.servers[config.url] = identity; await credentialStore('save', identities);
140
- }
141
- const hostKey = identity.registered ? undefined : await registrationKey(config);
142
- let bridgeError = '';
141
+ if (!identity) {
142
+ identity = { id: randomUUID(), secret: randomBytes(32).toString('base64url'), registered: false };
143
+ identities.servers[config.url] = identity; await credentialStore('save', identities);
144
+ }
145
+ const hostKey = identity.registered ? undefined : await registrationKey(config);
146
+ let bridgeError = '';
143
147
  async function refreshHealth() {
144
- try { health = await serviceHealth(session()) || { transportMode: health.transportMode, status: 'offline', desktopConnected: false }; bridgeError = ''; }
145
- catch (error) { bridgeError = error.message; health = { transportMode: health.transportMode, status: 'offline', desktopConnected: false }; }
148
+ try { health = await serviceHealth(session()) || { transportMode: health.transportMode, status: 'offline' }; bridgeError = ''; }
149
+ catch (error) { bridgeError = error.message; health = { transportMode: health.transportMode, status: 'offline' }; }
146
150
  }
147
151
  const secret = randomBytes(32).toString('base64url');
148
152
  let connector, control, qrPath, expiresAt = 0, paired = false, clientCount = 0, attachedCount = 0;
@@ -164,10 +168,10 @@ async function worker(stateRoot, instanceId, refresh = false, requiredMode = '')
164
168
  : '尚未完成设备授权;调用 relax pair --json 生成配对二维码。';
165
169
  return { running: !closing, relayConnected: online, mode: 'self-relay', transportMode: health.transportMode,
166
170
  relayUrl: config.url, deviceId: identity.id, paired, clientCount, authorization: online ? (paired ? 'authorized' : 'unpaired') : 'unverified', phoneConnected: attachedCount > 0,
167
- bridgeReady: health.status === 'ok' && Boolean(health.sharedAppServerConnected || health.desktopConnected), bridgeError,
171
+ bridgeReady: health.status === 'ok' && health.execution?.ready === true && health.sharedAppServerConnected === true, bridgeError,
168
172
  expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
169
173
  ...(available ? { qrPath } : {}), mobileAccess: 'unverified', lastError,
170
- displayText: `Code Relax 中继 · ${health.transportMode === 'lan-shared' ? '完整控制' : '基础控制'}\n${next}\n停止连接器保留授权:relax relay-stop --json。撤销全部移动设备:relax relay-revoke --json。` };
174
+ displayText: `Code Relax 中继 · ${health.transportMode === 'lan-shared' ? 'Codex 内核已就绪' : 'Codex 内核未就绪'}\n${next}\n停止连接器保留授权:relax relay-stop --json。撤销全部移动设备:relax relay-revoke --json。` };
171
175
  };
172
176
  async function shutdown() {
173
177
  if (closing) return;
@@ -199,7 +203,7 @@ async function worker(stateRoot, instanceId, refresh = false, requiredMode = '')
199
203
  await refreshHealth();
200
204
  const lanCandidates = await currentLanCandidates();
201
205
  connector?.events.removeAllListeners(); connector?.close();
202
- const current = connector = connectBridgeRelay({ relayUrl: config.url, hostKey: identity.registered ? undefined : hostKey, identity,
206
+ const current = connector = connectBridgeRelay({ relayUrl: config.url, hostKey: identity.registered ? undefined : hostKey, identity,
203
207
  bridgeUrl: `ws://127.0.0.1:${bridge?.port || 45831}/api/socket`, bridgeToken: bridge?.accessUrl ? new URL(bridge.accessUrl).searchParams.get('token') : 'unavailable',
204
208
  bridgeConnection: () => { const latest = session(); return { url: `ws://127.0.0.1:${latest.port}/api/socket`, token: new URL(latest.accessUrl).searchParams.get('token') }; },
205
209
  hostName: hostname(), lanCandidates, p2pProbe: config.p2pProbe === true, iceServers: config.p2pIceServers || [],
@@ -219,7 +223,7 @@ async function worker(stateRoot, instanceId, refresh = false, requiredMode = '')
219
223
  if (![1008, 4401, 4406, 4409, 4429].includes(event.code)) scheduleRetry();
220
224
  });
221
225
  const registration = await current.ready;
222
- if (registration.protocolVersion !== 3 || registration.room !== identity.id) {
226
+ if (registration.protocolVersion !== 7 || registration.room !== identity.id) {
223
227
  current.events.removeAllListeners(); current.close(); throw Object.assign(new Error('中继服务器尚未升级设备授权协议,未使用临时配对降级。'), { code: 4401 });
224
228
  }
225
229
  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
+ }