smoldot 2.0.8 → 2.0.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.
@@ -140,7 +140,7 @@ export interface ConnectionConfig {
140
140
  *
141
141
  * This function must only be called for connections of type "multi-stream".
142
142
  */
143
- onStreamReset: (streamId: number) => void;
143
+ onStreamReset: (streamId: number, message: string) => void;
144
144
  /**
145
145
  * Callback called when some data sent using {@link Connection.send} has effectively been
146
146
  * written on the stream, meaning that some buffer space is now free.
@@ -153,10 +153,10 @@ function start(options, wasmModule, platformBindings) {
153
153
  throw new Error();
154
154
  state.instance.instance.streamWritableBytes(connectionId, numExtra, streamId);
155
155
  },
156
- onStreamReset(streamId) {
156
+ onStreamReset(streamId, message) {
157
157
  if (state.instance.status !== "ready")
158
158
  throw new Error();
159
- state.instance.instance.streamReset(connectionId, streamId);
159
+ state.instance.instance.streamReset(connectionId, streamId, message);
160
160
  },
161
161
  }));
162
162
  break;
@@ -111,7 +111,7 @@ export interface Instance {
111
111
  streamWritableBytes: (connectionId: number, numExtra: number, streamId?: number) => void;
112
112
  streamMessage: (connectionId: number, message: Uint8Array, streamId?: number) => void;
113
113
  streamOpened: (connectionId: number, streamId: number, direction: 'inbound' | 'outbound') => void;
114
- streamReset: (connectionId: number, streamId: number) => void;
114
+ streamReset: (connectionId: number, streamId: number, message: string) => void;
115
115
  }
116
116
  /**
117
117
  * Starts a new instance using the given configuration.
@@ -438,10 +438,12 @@ function startLocalInstance(config, wasmModule, eventCallback) {
438
438
  return;
439
439
  state.instance.exports.connection_stream_opened(connectionId, streamId, direction === 'outbound' ? 1 : 0);
440
440
  },
441
- streamReset: (connectionId, streamId) => {
441
+ streamReset: (connectionId, streamId, message) => {
442
442
  if (!state.instance)
443
443
  return;
444
- state.instance.exports.stream_reset(connectionId, streamId);
444
+ state.bufferIndices[0] = new TextEncoder().encode(message);
445
+ state.instance.exports.stream_reset(connectionId, streamId, 0);
446
+ delete state.bufferIndices[0];
445
447
  },
446
448
  };
447
449
  });
@@ -196,9 +196,9 @@ function connectToInstanceServer(config) {
196
196
  const msg = { ty: "stream-writable-bytes", connectionId, numExtra, streamId };
197
197
  portToServer.postMessage(msg);
198
198
  },
199
- streamReset(connectionId, streamId) {
199
+ streamReset(connectionId, streamId, message) {
200
200
  state.connections.get(connectionId).delete(streamId);
201
- const msg = { ty: "stream-reset", connectionId, streamId };
201
+ const msg = { ty: "stream-reset", connectionId, streamId, message };
202
202
  portToServer.postMessage(msg);
203
203
  },
204
204
  };
@@ -333,7 +333,7 @@ function startInstanceServer(config, initPortToClient) {
333
333
  if (!state.connections.has(message.connectionId))
334
334
  return;
335
335
  // The stream might have been reset locally in the past.
336
- if (message.streamId && !state.connections.get(message.connectionId).has(message.streamId))
336
+ if (message.streamId !== undefined && !state.connections.get(message.connectionId).has(message.streamId))
337
337
  return;
338
338
  state.instance.streamMessage(message.connectionId, message.message, message.streamId);
339
339
  break;
@@ -351,7 +351,7 @@ function startInstanceServer(config, initPortToClient) {
351
351
  if (!state.connections.has(message.connectionId))
352
352
  return;
353
353
  // The stream might have been reset locally in the past.
354
- if (message.streamId && !state.connections.get(message.connectionId).has(message.streamId))
354
+ if (message.streamId !== undefined && !state.connections.get(message.connectionId).has(message.streamId))
355
355
  return;
356
356
  state.instance.streamWritableBytes(message.connectionId, message.numExtra, message.streamId);
357
357
  break;
@@ -361,10 +361,10 @@ function startInstanceServer(config, initPortToClient) {
361
361
  if (!state.connections.has(message.connectionId))
362
362
  return;
363
363
  // The stream might have been reset locally in the past.
364
- if (message.streamId && !state.connections.get(message.connectionId).has(message.streamId))
364
+ if (!state.connections.get(message.connectionId).has(message.streamId))
365
365
  return;
366
366
  state.connections.get(message.connectionId).delete(message.streamId);
367
- state.instance.streamReset(message.connectionId, message.streamId);
367
+ state.instance.streamReset(message.connectionId, message.streamId, message.message);
368
368
  break;
369
369
  }
370
370
  }
@@ -155,56 +155,52 @@ function connect(config) {
155
155
  }
156
156
  else if (config.address.ty === "webrtc") {
157
157
  const { targetPort, ipVersion, targetIp, remoteTlsCertificateSha256 } = config.address;
158
- // TODO: detect localhost for Firefox? https://bugzilla.mozilla.org/show_bug.cgi?id=1659672
159
- // Note that `pc` can be the connection, but also null or undefined.
160
- // `undefined` means "certificate generation in progress", while `null` means "opening must
161
- // be cancelled".
162
- // While it would be better to use for example a string instead of `null`, using `null` lets
163
- // us use the `!` operator more easily and leads to more readable code.
164
- let pc = undefined;
165
- // Contains the data channels that are open and have been reported to smoldot.
166
- const dataChannels = new Map();
167
- // SHA256 hash of the DTLS certificate of the local node. Unknown as long as it hasn't been
168
- // generated.
169
- // TODO: could be merged with `pc` in one variable, and maybe even the other fields as well
170
- let localTlsCertificateSha256;
158
+ const state = {
159
+ pc: undefined,
160
+ dataChannels: new Map(),
161
+ nextStreamId: 0,
162
+ isFirstOutSubstream: true,
163
+ };
171
164
  // Kills all the JavaScript objects (the connection and all its substreams), ensuring that no
172
165
  // callback will be called again. Doesn't report anything to smoldot, as this should be done
173
166
  // by the caller.
174
167
  const killAllJs = () => {
175
168
  // The `RTCPeerConnection` is created pretty quickly. It is however still possible for
176
169
  // smoldot to cancel the opening, in which case `pc` will still be undefined.
177
- if (!pc) {
178
- console.assert(dataChannels.size === 0, "substreams exist while pc is undef");
179
- pc = null;
170
+ if (!state.pc) {
171
+ console.assert(state.dataChannels.size === 0, "substreams exist while pc is undef");
172
+ state.pc = null;
180
173
  return;
181
174
  }
182
- pc.onconnectionstatechange = null;
183
- pc.onnegotiationneeded = null;
184
- pc.ondatachannel = null;
185
- for (const channel of Array.from(dataChannels.values())) {
175
+ state.pc.onconnectionstatechange = null;
176
+ state.pc.onnegotiationneeded = null;
177
+ state.pc.ondatachannel = null;
178
+ for (const channel of Array.from(state.dataChannels.values())) {
186
179
  channel.channel.onopen = null;
187
180
  channel.channel.onerror = null;
188
181
  channel.channel.onclose = null;
189
182
  channel.channel.onbufferedamountlow = null;
190
183
  channel.channel.onmessage = null;
191
184
  }
192
- dataChannels.clear();
193
- pc.close(); // Not necessarily necessary, but it doesn't hurt to do so.
185
+ state.dataChannels.clear();
186
+ state.pc.close(); // Not necessarily necessary, but it doesn't hurt to do so.
194
187
  };
195
188
  // Function that configures a newly-opened channel and adds it to the map. Used for both
196
189
  // inbound and outbound substreams.
197
190
  const addChannel = (dataChannel, direction) => {
198
- const dataChannelId = dataChannel.id;
191
+ const streamId = state.nextStreamId;
192
+ state.nextStreamId += 1;
199
193
  dataChannel.binaryType = 'arraybuffer';
200
194
  let isOpen = { value: false };
201
195
  dataChannel.onopen = () => {
202
196
  console.assert(!isOpen.value, "substream opened twice");
203
197
  isOpen.value = true;
204
- config.onStreamOpened(dataChannelId, direction);
205
- config.onWritableBytes(65536, dataChannelId);
198
+ config.onStreamOpened(streamId, direction);
199
+ config.onWritableBytes(65536, streamId);
206
200
  };
207
- dataChannel.onerror = dataChannel.onclose = (_error) => {
201
+ dataChannel.onerror = dataChannel.onclose = (event) => {
202
+ // Note that Firefox doesn't support <https://developer.mozilla.org/en-US/docs/Web/API/RTCErrorEvent>.
203
+ const message = (event instanceof RTCErrorEvent) ? event.error.toString() : "RTCDataChannel closed";
208
204
  if (!isOpen.value) {
209
205
  // Substream wasn't opened yet and thus has failed to open. The API has no
210
206
  // mechanism to report substream openings failures. We could try opening it
@@ -212,24 +208,30 @@ function connect(config) {
212
208
  // entire connection.
213
209
  killAllJs();
214
210
  // Note that the event doesn't give any additional reason for the failure.
215
- config.onConnectionReset("data channel failed to open");
211
+ config.onConnectionReset("data channel failed to open: " + message);
216
212
  }
217
213
  else {
218
214
  // Substream was open and is now closed. Normal situation.
219
- config.onStreamReset(dataChannelId);
215
+ dataChannel.onopen = null;
216
+ dataChannel.onerror = null;
217
+ dataChannel.onclose = null;
218
+ dataChannel.onbufferedamountlow = null;
219
+ dataChannel.onmessage = null;
220
+ state.dataChannels.delete(streamId);
221
+ config.onStreamReset(streamId, message);
220
222
  }
221
223
  };
222
224
  dataChannel.onbufferedamountlow = () => {
223
- const channel = dataChannels.get(dataChannelId);
225
+ const channel = state.dataChannels.get(streamId);
224
226
  const val = channel.bufferedBytes;
225
227
  channel.bufferedBytes = 0;
226
- config.onWritableBytes(val, dataChannelId);
228
+ config.onWritableBytes(val, streamId);
227
229
  };
228
230
  dataChannel.onmessage = (m) => {
229
231
  // The `data` field is an `ArrayBuffer`.
230
- config.onMessage(new Uint8Array(m.data), dataChannelId);
232
+ config.onMessage(new Uint8Array(m.data), streamId);
231
233
  };
232
- dataChannels.set(dataChannelId, { channel: dataChannel, bufferedBytes: 0 });
234
+ state.dataChannels.set(streamId, { channel: dataChannel, bufferedBytes: 0 });
233
235
  };
234
236
  // It is possible for the browser to use multiple different certificates.
235
237
  // In order for our local certificate to be deterministic, we need to generate it manually and
@@ -237,10 +239,23 @@ function connect(config) {
237
239
  // According to <https://w3c.github.io/webrtc-pc/#dom-rtcpeerconnection-generatecertificate>,
238
240
  // browsers are guaranteed to support `{ name: "ECDSA", namedCurve: "P-256" }`.
239
241
  RTCPeerConnection.generateCertificate({ name: "ECDSA", namedCurve: "P-256", hash: "SHA-256" }).then((localCertificate) => __awaiter(this, void 0, void 0, function* () {
240
- if (pc === null)
242
+ if (state.pc === null)
243
+ return;
244
+ // Due to <https://bugzilla.mozilla.org/show_bug.cgi?id=1659672>, connections from
245
+ // Firefox to a localhost WebRTC server always fails. Since this bug has been opened
246
+ // for three years at the time of writing, it is unlikely to be fixed in the short
247
+ // term. In order to provider better user feedback, we straight up refuse connecting
248
+ // and stop the connection.
249
+ // Note that this is just a hint. Failing to detect this will lead to the WebRTC
250
+ // handshake timing out.
251
+ // TODO: eventually remove this if the Firefox bug is fixed
252
+ if ((targetIp == 'localhost' || targetIp == '127.0.0.1' || targetIp == '::1') && navigator.userAgent.indexOf('Firefox') !== -1) {
253
+ killAllJs();
254
+ config.onConnectionReset("Firefox can't connect to a localhost WebRTC server");
241
255
  return;
256
+ }
242
257
  // Create a new WebRTC connection.
243
- pc = new RTCPeerConnection({ certificates: [localCertificate] });
258
+ state.pc = new RTCPeerConnection({ certificates: [localCertificate] });
244
259
  // We need to build the multihash corresponding to the local certificate.
245
260
  // While there exists a `RTCPeerConnection.getFingerprints` function, Firefox notably
246
261
  // doesn't support it.
@@ -263,7 +278,7 @@ function connect(config) {
263
278
  }
264
279
  }
265
280
  else {
266
- const localSdpOffer = yield pc.createOffer();
281
+ const localSdpOffer = yield state.pc.createOffer();
267
282
  // Note that this regex is not strict. The browser isn't a malicious actor, and the
268
283
  // objective of this regex is not to detect invalid input.
269
284
  const localSdpOfferFingerprintMatch = localSdpOffer.sdp.match(/a(\s*)=(\s*)fingerprint:(\s*)(sha|SHA)-256(\s*)(([a-fA-F0-9]{2}(:)*){32})/);
@@ -277,23 +292,23 @@ function connect(config) {
277
292
  config.onConnectionReset('Failed to obtain the browser certificate fingerprint');
278
293
  return;
279
294
  }
280
- localTlsCertificateSha256 = new Uint8Array(32);
295
+ let localTlsCertificateSha256 = new Uint8Array(32);
281
296
  localTlsCertificateSha256.set(localTlsCertificateHex.split(':').map((s) => parseInt(s, 16)), 0);
282
297
  // `onconnectionstatechange` is used to detect when the connection has closed or has failed
283
298
  // to open.
284
299
  // Note that smoldot will think that the connection is open even when it is still opening.
285
300
  // Therefore we don't care about events concerning the fact that the connection is now fully
286
301
  // open.
287
- pc.onconnectionstatechange = (_event) => {
288
- if (pc.connectionState == "closed" || pc.connectionState == "disconnected" || pc.connectionState == "failed") {
302
+ state.pc.onconnectionstatechange = (_event) => {
303
+ if (state.pc.connectionState == "closed" || state.pc.connectionState == "disconnected" || state.pc.connectionState == "failed") {
289
304
  killAllJs();
290
- config.onConnectionReset("WebRTC state transitioned to " + pc.connectionState);
305
+ config.onConnectionReset("WebRTC state transitioned to " + state.pc.connectionState);
291
306
  }
292
307
  };
293
- pc.onnegotiationneeded = (_event) => __awaiter(this, void 0, void 0, function* () {
308
+ state.pc.onnegotiationneeded = (_event) => __awaiter(this, void 0, void 0, function* () {
294
309
  var _a;
295
310
  // Create a new offer and set it as local description.
296
- let sdpOffer = (yield pc.createOffer()).sdp;
311
+ let sdpOffer = (yield state.pc.createOffer()).sdp;
297
312
  // We check that the locally-generated SDP offer has a data channel with the UDP
298
313
  // protocol. If that isn't the case, the connection will likely fail.
299
314
  if (sdpOffer.match(/^m=application(\s+)(\d+)(\s+)UDP\/DTLS\/SCTP(\s+)webrtc-datachannel$/m) === null) {
@@ -312,7 +327,7 @@ function connect(config) {
312
327
  const ufragPwd = "libp2p+webrtc+v1/" + browserGeneratedPwd;
313
328
  sdpOffer = sdpOffer.replace(/^a=ice-ufrag.*$/m, 'a=ice-ufrag:' + ufragPwd);
314
329
  sdpOffer = sdpOffer.replace(/^a=ice-pwd.*$/m, 'a=ice-pwd:' + ufragPwd);
315
- yield pc.setLocalDescription({ type: 'offer', sdp: sdpOffer });
330
+ yield state.pc.setLocalDescription({ type: 'offer', sdp: sdpOffer });
316
331
  // Transform certificate hash into fingerprint (upper-hex; each byte separated by ":").
317
332
  const fingerprint = Array.from(remoteTlsCertificateSha256).map((n) => ("0" + n.toString(16)).slice(-2).toUpperCase()).join(':');
318
333
  // Note that the trailing line feed is important, as otherwise Chrome
@@ -373,9 +388,9 @@ function connect(config) {
373
388
  // A transport address for a candidate that can be used for connectivity
374
389
  // checks (RFC8839).
375
390
  "a=candidate:1 1 UDP 1 " + targetIp + " " + String(targetPort) + " typ host" + "\n";
376
- yield pc.setRemoteDescription({ type: "answer", sdp: remoteSdp });
391
+ yield state.pc.setRemoteDescription({ type: "answer", sdp: remoteSdp });
377
392
  });
378
- pc.ondatachannel = ({ channel }) => {
393
+ state.pc.ondatachannel = ({ channel }) => {
379
394
  // TODO: is the substream maybe already open? according to the Internet it seems that no but it's unclear
380
395
  addChannel(channel, 'inbound');
381
396
  };
@@ -391,18 +406,18 @@ function connect(config) {
391
406
  killAllJs();
392
407
  }
393
408
  else {
394
- const channel = dataChannels.get(streamId);
409
+ const channel = state.dataChannels.get(streamId);
395
410
  channel.channel.onopen = null;
396
411
  channel.channel.onerror = null;
397
412
  channel.channel.onclose = null;
398
413
  channel.channel.onbufferedamountlow = null;
399
414
  channel.channel.onmessage = null;
400
415
  channel.channel.close();
401
- dataChannels.delete(streamId);
416
+ state.dataChannels.delete(streamId);
402
417
  }
403
418
  },
404
419
  send: (data, streamId) => {
405
- const channel = dataChannels.get(streamId);
420
+ const channel = state.dataChannels.get(streamId);
406
421
  channel.channel.send(data);
407
422
  channel.bufferedBytes += data.length;
408
423
  },
@@ -412,7 +427,10 @@ function connect(config) {
412
427
  // therefore `pc` is guaranteed to be non-null.
413
428
  // Note that the label passed to `createDataChannel` is required to be empty as
414
429
  // per the libp2p WebRTC specification.
415
- addChannel(pc.createDataChannel(""), 'outbound');
430
+ // TODO: adjusting the options based on the first substream is a bit hacky
431
+ const opts = state.isFirstOutSubstream ? { negotiated: true, id: 0 } : {};
432
+ state.isFirstOutSubstream = false;
433
+ addChannel(state.pc.createDataChannel("", opts), 'outbound');
416
434
  }
417
435
  };
418
436
  }