@walkhi/code-relax 0.1.0-beta.40 → 0.1.0-beta.41

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.
@@ -1 +1 @@
1
- {"layout":"npm-platform-packages","version":"0.1.0-beta.40"}
1
+ {"layout":"npm-platform-packages","version":"0.1.0-beta.41"}
@@ -23,6 +23,6 @@
23
23
  `;gathering=${known(this.gathering, ['new', 'in-progress', 'gathering', 'complete'])}` +
24
24
  `;local=${counts(this.local)};remote=${counts(this.remote)}` +
25
25
  `;open=${Number(this.open)};ping=${Number(this.ping)};pong=${Number(this.pong)}` +
26
- `;error=${known(this.error, ['none', 'stun', 'description', 'candidate', 'channel', 'frame', 'peer', 'initialization', 'signal'])}`;
26
+ `;error=${known(this.error, ['none', 'stun', 'description', 'candidate', 'channel', 'frame', 'peer', 'initialization', 'signal', 'ice-policy'])}`;
27
27
  }
28
28
  });
@@ -29,10 +29,13 @@
29
29
  const data = binary ? (value instanceof Uint8Array ? value : new Uint8Array(value)) : value;
30
30
  const size = binary ? data.byteLength : new TextEncoder().encode(data).length;
31
31
  if (!size || size > (binary ? MAX : PART) || this.bufferedAmount + size > LIMIT) throw new Error('文件直连分片或队列超限');
32
+ const negotiated = this.io.maxMessageSize?.();
33
+ const frameBytes = Number.isFinite(negotiated) && negotiated > 0 ? Math.min(PART, Math.max(1, Math.floor(negotiated))) : PART;
32
34
  const frames = binary ? [JSON.stringify({ type: 'rtc-file-bytes', length: size })] : [];
33
- if (binary) for (let i = 0; i < size; i += PART) frames.push(data.slice(i, i + PART));
35
+ if (binary) for (let i = 0; i < size; i += frameBytes) frames.push(data.slice(i, i + frameBytes));
34
36
  else frames.push(data);
35
37
  const sizes = frames.map(frame => typeof frame === 'string' ? new TextEncoder().encode(frame).length : frame.byteLength);
38
+ if (sizes.some(length => length > frameBytes)) throw new Error('文件传输消息超过协商上限');
36
39
  const framedSize = sizes.reduce((total, length) => total + length, 0);
37
40
  if (this.bufferedAmount + framedSize > LIMIT) throw new Error('文件直连队列超限');
38
41
  this.outbound.push({ frames, sizes, index: 0 }); this.queued += framedSize; this.pump();
@@ -66,7 +69,7 @@
66
69
  this.waitForPartial(); return;
67
70
  }
68
71
  const bytes = value instanceof Uint8Array ? value : new Uint8Array(value), part = this.partial;
69
- if (!part || bytes.length !== Math.min(PART, part.data.length - part.offset)) throw new Error();
72
+ if (!part || bytes.length < 1 || bytes.length > Math.min(PART, part.data.length - part.offset)) throw new Error();
70
73
  this.waitForPartial(); this.io.received?.(); part.data.set(bytes, part.offset); part.offset += bytes.length;
71
74
  if (part.offset === part.data.length) {
72
75
  this.partial = null; clearTimeout(this.timer); this.timer = null;
@@ -0,0 +1,22 @@
1
+ (function (root, factory) {
2
+ if (typeof module === 'object' && module.exports) module.exports = factory();
3
+ else root.CodeRelaxIcePolicy = factory();
4
+ })(globalThis, () => {
5
+ const directType = type => ['host', 'srflx', 'prflx'].includes(type);
6
+ const candidate = value => {
7
+ if (typeof value !== 'string' || value.length > 2048 || /[\r\n]/.test(value)) return false;
8
+ const fields = value.trim().split(/\s+/);
9
+ return /^(?:a=)?candidate:/.test(fields[0]) && fields[6] === 'typ' && directType(fields[7]);
10
+ };
11
+ return {
12
+ directType, candidate,
13
+ servers: values => Array.isArray(values) && values.length <= 4 && values.every(value => {
14
+ if (typeof value !== 'string' || !/^stun:[a-zA-Z0-9.-]+:[0-9]{1,5}$/.test(value)) return false;
15
+ const port = Number(value.split(':').at(-1));
16
+ return port > 0 && port <= 65535;
17
+ }),
18
+ sdp: value => typeof value === 'string' && value.length <= 65536 &&
19
+ value.split(/\r?\n/).filter(line => line.startsWith('a=candidate:')).every(candidate),
20
+ pair: (localType, remoteType) => directType(localType) && directType(remoteType),
21
+ };
22
+ });
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import P2pDiagnostics from '../../shared/p2p-diagnostics.cjs';
3
3
  import P2pDataFramer from '../../shared/p2p-data-channel.cjs';
4
4
  import FileChannel from '../../shared/p2p-file-channel.cjs';
5
+ import IcePolicy from '../../shared/p2p-ice-policy.cjs';
5
6
 
6
7
  const PROBE_UDP_PORT_RANGE = Object.freeze({ begin: 42000, end: 42031 });
7
8
  const HIGH_WATER = 1024 * 1024;
@@ -26,7 +27,7 @@ export async function createP2pProbe({ signal, report = () => {}, onData = () =>
26
27
  const files = new Map();
27
28
  const nonce = randomUUID();
28
29
  const idle = new P2pDataFramer.IdleWatch(message => {
29
- if (!channel.sendMessage(JSON.stringify(message))) throw new Error('Idle probe send failed');
30
+ channel.sendMessage(JSON.stringify(message));
30
31
  }, () => finish('idle-timeout'));
31
32
  const timer = setTimeout(() => finish(verified ? 'closed' : 'timeout'), 15000);
32
33
  function finish(state) {
@@ -40,6 +41,8 @@ export async function createP2pProbe({ signal, report = () => {}, onData = () =>
40
41
  }
41
42
  function verify() {
42
43
  if (verified || !pingReceived || !pongReceived) return;
44
+ const pair = peer.getSelectedCandidatePair();
45
+ if (!pair || !IcePolicy.pair(pair.local.type, pair.remote.type)) { diagnostic.error = 'ice-policy'; finish('failed'); return; }
43
46
  verified = true; clearTimeout(timer);
44
47
  if (remoteDirect) idle.received();
45
48
  report(remoteDirect ? 'direct' : 'verified', diagnostic.snapshot('verified'));
@@ -54,7 +57,7 @@ export async function createP2pProbe({ signal, report = () => {}, onData = () =>
54
57
  }
55
58
  peer.onDataChannel(raw => {
56
59
  if (closed || purpose !== 'file' || !onFileChannel || raw.getLabel() !== 'code-relax-file-v1' || files.size >= 4) { raw.close(); return; }
57
- const socket = new FileChannel({ buffered: () => raw.bufferedAmount(),
60
+ const socket = new FileChannel({ buffered: () => raw.bufferedAmount(), maxMessageSize: () => peer.maxMessageSize(),
58
61
  send: value => typeof value === 'string' ? raw.sendMessage(value) : raw.sendMessageBinary(value), close: () => raw.close(), received: () => idle.received() });
59
62
  files.set(raw, socket);
60
63
  socket.addEventListener('close', () => files.delete(raw));
@@ -75,7 +78,8 @@ export async function createP2pProbe({ signal, report = () => {}, onData = () =>
75
78
  try {
76
79
  while (outbound.length && channel.bufferedAmount() < HIGH_WATER) {
77
80
  const item = outbound[0], frame = framer.frame(item);
78
- if (!channel.sendMessage(frame)) return;
81
+ // false means accepted into libdatachannel's buffer, not rejected. Never replay it.
82
+ channel.sendMessage(frame);
79
83
  if (framer.commit(item)) { outbound.shift(); queuedBytes -= item.bytes; }
80
84
  }
81
85
  } catch { diagnostic.error = 'channel'; finish('failed'); }
@@ -92,7 +96,11 @@ export async function createP2pProbe({ signal, report = () => {}, onData = () =>
92
96
  peer.onGatheringStateChange(state => { if (!closed) diagnostic.gathering = state; });
93
97
  peer.onStateChange(state => { if (!closed && (state === 'failed' || state === 'closed')) { diagnostic.error = 'peer'; finish('failed'); } });
94
98
  peer.onLocalDescription((sdp, type) => { if (!closed) { diagnostic.offer = type === 'offer'; signal({ kind: type, sdp, iceServers, direct: 1 }); } });
95
- peer.onLocalCandidate((candidate, mid) => { if (!closed) { diagnostic.candidate('local', candidate); signal({ kind: 'candidate', candidate, mid }); } });
99
+ peer.onLocalCandidate((candidate, mid) => {
100
+ if (closed) return;
101
+ if (!IcePolicy.candidate(candidate)) { diagnostic.error = 'ice-policy'; finish('failed'); return; }
102
+ diagnostic.candidate('local', candidate); signal({ kind: 'candidate', candidate, mid });
103
+ });
96
104
  channel = peer.createDataChannel('code-relax-probe-v1');
97
105
  channel.setBufferedAmountLowThreshold(LOW_WATER);
98
106
  channel.onBufferedAmountLow(pump);
@@ -124,12 +132,14 @@ export async function createP2pProbe({ signal, report = () => {}, onData = () =>
124
132
  receive(message) {
125
133
  if (closed) return;
126
134
  if (message.kind === 'answer' && !answered) {
135
+ if (!IcePolicy.sdp(message.sdp)) { diagnostic.error = 'ice-policy'; finish('failed'); return; }
127
136
  try { peer.setRemoteDescription(message.sdp, 'answer'); }
128
137
  catch { diagnostic.error = 'description'; finish('failed'); return; }
129
138
  answered = true; remoteDirect = message.direct === 1; diagnostic.answer = true;
130
139
  for (const item of candidates) peer.addRemoteCandidate(item.candidate, item.mid);
131
140
  candidates = [];
132
141
  } else if (message.kind === 'candidate') {
142
+ if (!IcePolicy.candidate(message.candidate)) { diagnostic.error = 'ice-policy'; finish('failed'); return; }
133
143
  diagnostic.candidate('remote', message.candidate);
134
144
  if (answered) peer.addRemoteCandidate(message.candidate, message.mid);
135
145
  else if (candidates.length < 64) candidates.push(message);
@@ -145,15 +155,14 @@ export async function createP2pProbe({ signal, report = () => {}, onData = () =>
145
155
  export function validProbeSignal(value) {
146
156
  if (!value || value.version !== 1 || !/^[\w-]{16,128}$/.test(value.probeId || '')) return false;
147
157
  if (['start', 'close'].includes(value.kind)) return true;
148
- if (['offer', 'answer'].includes(value.kind)) return typeof value.sdp === 'string' && value.sdp.length <= 65536 &&
158
+ if (['offer', 'answer'].includes(value.kind)) return IcePolicy.sdp(value.sdp) &&
149
159
  (value.direct === undefined || value.direct === 0 || value.direct === 1) &&
150
160
  (value.iceServers === undefined || validProbeIceServers(value.iceServers));
151
- if (value.kind === 'candidate') return typeof value.candidate === 'string' && value.candidate.length <= 2048 &&
161
+ if (value.kind === 'candidate') return IcePolicy.candidate(value.candidate) &&
152
162
  typeof value.mid === 'string' && value.mid.length <= 64;
153
163
  return false;
154
164
  }
155
165
 
156
166
  export function validProbeIceServers(value) {
157
- return Array.isArray(value) && value.length <= 4 && value.every(item =>
158
- typeof item === 'string' && /^stun:[a-zA-Z0-9.-]+:[0-9]{1,5}$/.test(item) && Number(item.split(':').at(-1)) <= 65535);
167
+ return IcePolicy.servers(value);
159
168
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@walkhi/code-relax",
3
- "version": "0.1.0-beta.40",
3
+ "version": "0.1.0-beta.41",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -41,8 +41,8 @@
41
41
  "tag": "beta"
42
42
  },
43
43
  "optionalDependencies": {
44
- "@walkhi/code-relax-native-win32-x64": "0.1.0-beta.40",
45
- "@walkhi/code-relax-native-darwin-arm64": "0.1.0-beta.40",
46
- "@walkhi/code-relax-native-darwin-x64": "0.1.0-beta.40"
44
+ "@walkhi/code-relax-native-win32-x64": "0.1.0-beta.41",
45
+ "@walkhi/code-relax-native-darwin-arm64": "0.1.0-beta.41",
46
+ "@walkhi/code-relax-native-darwin-x64": "0.1.0-beta.41"
47
47
  }
48
48
  }