@lazily-hub/lazily-js 0.4.0 → 0.5.0

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.
@@ -0,0 +1,647 @@
1
+ // Distributed plane — WebRTC DataChannel IPC transport + the CRDT anti-entropy
2
+ // runtime (port of lazily-rs `webrtc_transport.rs` + `crdt_plane.rs`).
3
+ //
4
+ // This module is koffi-FREE: it is pure ESM and imports only the portable wire
5
+ // types (./index.js) and the HLC clock (./seq-crdt.js). "Real" WebRTC is reached
6
+ // through a platform adapter (RtcPeerChannel / RtcPeerConnector) that wraps the
7
+ // browser `RTCDataChannel` / `RTCPeerConnection` globals — no npm dependency and
8
+ // no import-time reference to those globals, so importing this module in Node
9
+ // never throws. Everything is testable over an in-memory loopback DataChannel.
10
+ //
11
+ // - `DataChannel` (documented interface) — one frame is one serialized
12
+ // IpcMessage; `sendFrame`/`tryRecvFrame`/`isOpen`/`close`. Ordering and
13
+ // reliability are the backend's job (a WebRTC channel opened `ordered: true`).
14
+ // - `WebRtcSink` — outbound IpcSink: permission-filters Snapshot/Delta/CrdtSync
15
+ // via `filterReadable(permissions, peer)`, then encodes and sends the frame.
16
+ // - `WebRtcSource` — inbound IpcSource: pops a frame and decodes it verbatim.
17
+ // Inbound write-permission enforcement is the graph-apply layer's job, NOT
18
+ // done here (mirrors the Rust note).
19
+ // - `CrdtPlaneRuntime` — the live anti-entropy runtime: register cells,
20
+ // localUpdate→CrdtOp, ingest→count (op-log dedup by (node, stamp)), and the
21
+ // sync-frame pull protocol.
22
+
23
+ import { CrdtOp, CrdtSync, IpcMessage, WireStamp } from "./index.js";
24
+ import { Hlc } from "./seq-crdt.js";
25
+
26
+ // Lexicographic (wall_time, logical, peer) order over two WireStamps.
27
+ function compareWireStamp(a, b) {
28
+ if (a.wallTime !== b.wallTime) return a.wallTime < b.wallTime ? -1 : 1;
29
+ if (a.logical !== b.logical) return a.logical < b.logical ? -1 : 1;
30
+ if (a.peer !== b.peer) return a.peer < b.peer ? -1 : 1;
31
+ return 0;
32
+ }
33
+
34
+ function ipcValueEqual(a, b) {
35
+ if (a === b) return true;
36
+ if (a == null || b == null) return false;
37
+ return JSON.stringify(a.toWire()) === JSON.stringify(b.toWire());
38
+ }
39
+
40
+ function assertPeerId(value, name = "peer") {
41
+ if (!Number.isSafeInteger(value) || value < 0) {
42
+ throw new TypeError(`${name} must be a non-negative safe integer`);
43
+ }
44
+ return value;
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // WebRTC DataChannel transport
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /**
52
+ * Error raised by a WebRtcSink or WebRtcSource. `kind` is one of the frozen
53
+ * taxonomy values: "Closed" (channel closed), "Encode" (frame serialization),
54
+ * "Decode" (frame deserialization), "Channel" (underlying backend error).
55
+ */
56
+ export const WebRtcTransportErrorKind = Object.freeze({
57
+ Closed: "Closed",
58
+ Encode: "Encode",
59
+ Decode: "Decode",
60
+ Channel: "Channel",
61
+ });
62
+
63
+ export class WebRtcTransportError extends Error {
64
+ constructor(kind, cause = null) {
65
+ const detail = cause && cause.message ? `: ${cause.message}` : "";
66
+ super(`WebRTC transport error [${kind}]${detail}`);
67
+ this.name = "WebRtcTransportError";
68
+ this.kind = kind;
69
+ if (cause !== null) this.cause = cause;
70
+ }
71
+
72
+ static closed() {
73
+ return new WebRtcTransportError(WebRtcTransportErrorKind.Closed);
74
+ }
75
+
76
+ static encode(cause) {
77
+ return new WebRtcTransportError(WebRtcTransportErrorKind.Encode, cause);
78
+ }
79
+
80
+ static decode(cause) {
81
+ return new WebRtcTransportError(WebRtcTransportErrorKind.Decode, cause);
82
+ }
83
+
84
+ static channel(cause) {
85
+ return new WebRtcTransportError(WebRtcTransportErrorKind.Channel, cause);
86
+ }
87
+ }
88
+
89
+ /**
90
+ * In-process loopback DataChannel for deterministic tests (no network, no WebRTC
91
+ * stack). `InMemoryDataChannel.pair()` returns two cross-wired endpoints: a frame
92
+ * sent on one is received (in order) on the other. Frames are Uint8Array.
93
+ */
94
+ export class InMemoryDataChannel {
95
+ #tx;
96
+ #rx;
97
+ #state;
98
+
99
+ constructor(tx, rx, state) {
100
+ this.#tx = tx;
101
+ this.#rx = rx;
102
+ this.#state = state;
103
+ }
104
+
105
+ static pair() {
106
+ const aToB = [];
107
+ const bToA = [];
108
+ const state = { open: true };
109
+ return [
110
+ new InMemoryDataChannel(aToB, bToA, state),
111
+ new InMemoryDataChannel(bToA, aToB, state),
112
+ ];
113
+ }
114
+
115
+ sendFrame(frame) {
116
+ this.#tx.push(frame);
117
+ }
118
+
119
+ tryRecvFrame() {
120
+ return this.#rx.length > 0 ? this.#rx.shift() : null;
121
+ }
122
+
123
+ isOpen() {
124
+ return this.#state.open;
125
+ }
126
+
127
+ close() {
128
+ this.#state.open = false;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Permission-filtering IpcSink over a DataChannel. Every outbound
134
+ * Snapshot/Delta/CrdtSync is filtered to what `peer` may read via
135
+ * `filterReadable(permissions, peer)` before it is encoded and sent, so a peer
136
+ * never receives graph state it is not entitled to.
137
+ */
138
+ export class WebRtcSink {
139
+ #channel;
140
+ #permissions;
141
+ #peer;
142
+
143
+ constructor(channel, permissions, peer) {
144
+ this.#channel = channel;
145
+ this.#permissions = permissions;
146
+ this.#peer = assertPeerId(peer);
147
+ }
148
+
149
+ get channel() {
150
+ return this.#channel;
151
+ }
152
+
153
+ send(message) {
154
+ if (!this.#channel.isOpen()) {
155
+ throw WebRtcTransportError.closed();
156
+ }
157
+ let filtered;
158
+ if (message.isSnapshot) {
159
+ filtered = IpcMessage.snapshot(
160
+ message.snapshot.filterReadable(this.#permissions, this.#peer),
161
+ );
162
+ } else if (message.isDelta) {
163
+ filtered = IpcMessage.delta(
164
+ message.delta.filterReadable(this.#permissions, this.#peer),
165
+ );
166
+ } else {
167
+ filtered = IpcMessage.crdtSync(
168
+ message.crdtSync.filterReadable(this.#permissions, this.#peer),
169
+ );
170
+ }
171
+ let frame;
172
+ try {
173
+ frame = filtered.encodeJson();
174
+ } catch (error) {
175
+ throw WebRtcTransportError.encode(error);
176
+ }
177
+ try {
178
+ this.#channel.sendFrame(frame);
179
+ } catch (error) {
180
+ throw WebRtcTransportError.channel(error);
181
+ }
182
+ }
183
+ }
184
+
185
+ /**
186
+ * IpcSource over a DataChannel. Delivers each decoded IpcMessage verbatim;
187
+ * `recv()` returns null when no frame is pending and the channel is still open,
188
+ * and throws a Closed WebRtcTransportError once the channel is closed and drained.
189
+ */
190
+ export class WebRtcSource {
191
+ #channel;
192
+
193
+ constructor(channel) {
194
+ this.#channel = channel;
195
+ }
196
+
197
+ get channel() {
198
+ return this.#channel;
199
+ }
200
+
201
+ recv() {
202
+ let frame;
203
+ try {
204
+ frame = this.#channel.tryRecvFrame();
205
+ } catch (error) {
206
+ throw WebRtcTransportError.channel(error);
207
+ }
208
+ if (frame !== null && frame !== undefined) {
209
+ try {
210
+ return IpcMessage.decodeJson(frame);
211
+ } catch (error) {
212
+ throw WebRtcTransportError.decode(error);
213
+ }
214
+ }
215
+ if (this.#channel.isOpen()) {
216
+ return null;
217
+ }
218
+ throw WebRtcTransportError.closed();
219
+ }
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // CRDT anti-entropy runtime
224
+ // ---------------------------------------------------------------------------
225
+
226
+ // Default state-based LWW register: the whole cell state is replaced by the
227
+ // op carrying the greatest WireStamp (lexicographic order). This is the model
228
+ // the distributed/anti_entropy_converge fixture exercises.
229
+ class LwwStateCell {
230
+ constructor() {
231
+ this.op = null;
232
+ }
233
+
234
+ // Merge a CrdtOp. Returns true iff the converged VALUE changed (a newer stamp
235
+ // carrying identical bytes, or an older/equal stamp, is not a value change).
236
+ merge(op) {
237
+ if (this.op === null) {
238
+ this.op = op;
239
+ return true;
240
+ }
241
+ if (compareWireStamp(op.stamp, this.op.stamp) > 0) {
242
+ const changed = !ipcValueEqual(op.state, this.op.state);
243
+ this.op = op;
244
+ return changed;
245
+ }
246
+ return false;
247
+ }
248
+
249
+ get value() {
250
+ return this.op ? this.op.state : undefined;
251
+ }
252
+ }
253
+
254
+ function dedupKey(node, stamp) {
255
+ return `${node}|${stamp.wallTime}|${stamp.logical}|${stamp.peer}`;
256
+ }
257
+
258
+ /**
259
+ * The live runtime that folds distributed CRDT anti-entropy frames into a set of
260
+ * replicated root cells (port of lazily-rs `CrdtPlaneRuntime`). One runtime per
261
+ * shared session per replica.
262
+ *
263
+ * - `register(node, key?, cell?)` — register a root cell under `node`, optionally
264
+ * projecting a wire-stable `key`. `cell` defaults to a state-based LWW register;
265
+ * a custom cell must implement `merge(op) -> boolean` and a `value` getter.
266
+ * - `localUpdate(node, nowMicros, state)` — apply a local edit; ticks the plane
267
+ * clock, stamps a fresh WireStamp, records the op, and returns the CrdtOp to
268
+ * broadcast (or null when the value did not change / node is unknown).
269
+ * - `ingest(sync, nowMicros)` — fold every not-yet-seen CrdtOp exactly once
270
+ * (op-log dedup by (node, stamp)) and advance the frontier/membership. Returns
271
+ * the count of newly-applied ops; re-ingesting a frame applies 0 (idempotent).
272
+ * - `syncFrame()` / `syncFrameSince(frontier)` / `syncReply(request)` — the
273
+ * frontier-advertising pull protocol.
274
+ */
275
+ export class CrdtPlaneRuntime {
276
+ #peer;
277
+ #hlc;
278
+ #cells = new Map();
279
+ #log = new Set();
280
+ #ops = [];
281
+ #keyToNode = new Map();
282
+ #nodeToKey = new Map();
283
+ #frontier = new Map();
284
+ #membership = new Set();
285
+
286
+ constructor(peer) {
287
+ this.#peer = assertPeerId(peer);
288
+ this.#hlc = new Hlc(this.#peer);
289
+ }
290
+
291
+ get peer() {
292
+ return this.#peer;
293
+ }
294
+
295
+ // Number of registered / auto-created cells.
296
+ get size() {
297
+ return this.#cells.size;
298
+ }
299
+
300
+ isEmpty() {
301
+ return this.#cells.size === 0;
302
+ }
303
+
304
+ register(node, key = null, cell = null) {
305
+ assertPeerId(node, "node");
306
+ if (key !== null && key !== undefined) {
307
+ this.#keyToNode.set(key, node);
308
+ this.#nodeToKey.set(node, key);
309
+ }
310
+ this.#cells.set(node, cell ?? new LwwStateCell());
311
+ return this;
312
+ }
313
+
314
+ #cellFor(node) {
315
+ let cell = this.#cells.get(node);
316
+ if (cell === undefined) {
317
+ cell = new LwwStateCell();
318
+ this.#cells.set(node, cell);
319
+ }
320
+ return cell;
321
+ }
322
+
323
+ #resolveNode(op) {
324
+ if (op.key !== null && this.#keyToNode.has(op.key)) {
325
+ return this.#keyToNode.get(op.key);
326
+ }
327
+ if (op.key !== null) {
328
+ this.#keyToNode.set(op.key, op.node);
329
+ this.#nodeToKey.set(op.node, op.key);
330
+ }
331
+ return op.node;
332
+ }
333
+
334
+ #observeStamp(stamp) {
335
+ if (stamp.peer !== this.#peer) {
336
+ this.#membership.add(stamp.peer);
337
+ } else {
338
+ this.#membership.add(this.#peer);
339
+ }
340
+ const current = this.#frontier.get(stamp.peer);
341
+ if (current === undefined || compareWireStamp(stamp, current) > 0) {
342
+ this.#frontier.set(stamp.peer, stamp);
343
+ }
344
+ }
345
+
346
+ // The converged IpcValue at `node`, or undefined.
347
+ value(node) {
348
+ return this.#cells.get(node)?.value;
349
+ }
350
+
351
+ // The winning CrdtOp at `node`, or undefined.
352
+ winningOp(node) {
353
+ const cell = this.#cells.get(node);
354
+ return cell instanceof LwwStateCell ? (cell.op ?? undefined) : undefined;
355
+ }
356
+
357
+ // Registered node ids, ascending.
358
+ nodes() {
359
+ return [...this.#cells.keys()].sort((a, b) => a - b);
360
+ }
361
+
362
+ // Converged {node, key, state} projection (state as wire JSON), ascending by
363
+ // node — the shape the anti_entropy_converge fixture asserts.
364
+ converged() {
365
+ const out = [];
366
+ for (const node of this.nodes()) {
367
+ const op = this.winningOp(node);
368
+ if (op === undefined) continue;
369
+ const entry = { node, state: op.state.toWire() };
370
+ if (op.key !== null) entry.key = op.key;
371
+ out.push(entry);
372
+ }
373
+ return out;
374
+ }
375
+
376
+ localUpdate(node, nowMicros, state) {
377
+ if (!this.#cells.has(node)) {
378
+ return null;
379
+ }
380
+ const hlc = this.#hlc.send(nowMicros);
381
+ const wire = new WireStamp({
382
+ wallTime: hlc.wallTime,
383
+ logical: hlc.logical,
384
+ peer: hlc.peer,
385
+ });
386
+ const key = this.#nodeToKey.get(node) ?? null;
387
+ const op =
388
+ key !== null
389
+ ? CrdtOp.keyed(node, key, wire, state)
390
+ : new CrdtOp(node, wire, state);
391
+ const cell = this.#cellFor(node);
392
+ const changed = cell.merge(op);
393
+ if (!changed) {
394
+ return null;
395
+ }
396
+ this.#log.add(dedupKey(node, wire));
397
+ this.#ops.push(op);
398
+ this.#observeStamp(wire);
399
+ return op;
400
+ }
401
+
402
+ ingest(sync, nowMicros) {
403
+ for (const entry of sync.frontier) {
404
+ const stamp = entry.stamp;
405
+ if (stamp.peer !== this.#peer) {
406
+ this.#hlc.recv(stamp, nowMicros);
407
+ }
408
+ this.#observeStamp(stamp);
409
+ }
410
+ let applied = 0;
411
+ for (const op of sync.ops) {
412
+ const key = dedupKey(op.node, op.stamp);
413
+ if (this.#log.has(key)) {
414
+ continue;
415
+ }
416
+ this.#log.add(key);
417
+ this.#ops.push(op);
418
+ applied += 1;
419
+ if (op.stamp.peer !== this.#peer) {
420
+ this.#hlc.recv(op.stamp, nowMicros);
421
+ }
422
+ this.#observeStamp(op.stamp);
423
+ const node = this.#resolveNode(op);
424
+ this.#cellFor(node).merge(op);
425
+ }
426
+ return applied;
427
+ }
428
+
429
+ // Per-peer highest observed WireStamp, ascending by peer, as {peer, stamp}.
430
+ frontierEntries() {
431
+ return [...this.#frontier.entries()]
432
+ .sort((a, b) => a[0] - b[0])
433
+ .map(([peer, stamp]) => ({ peer, stamp }));
434
+ }
435
+
436
+ // Wire form of the frontier: [[peer, WireStamp.toWire()], ...].
437
+ wireFrontier() {
438
+ return this.frontierEntries().map((entry) => [
439
+ entry.peer,
440
+ entry.stamp.toWire(),
441
+ ]);
442
+ }
443
+
444
+ // Peers observed in this session (self included once local edits exist).
445
+ membership() {
446
+ return [...this.#membership].sort((a, b) => a - b);
447
+ }
448
+
449
+ membershipCount() {
450
+ return this.#membership.size;
451
+ }
452
+
453
+ // A frame shipping the entire op log plus this replica's frontier. Safe to
454
+ // resend (the receiver dedups).
455
+ syncFrame() {
456
+ return new CrdtSync({ frontier: this.frontierEntries(), ops: [...this.#ops] });
457
+ }
458
+
459
+ // A frame shipping only the ops a peer described by `since` has not observed.
460
+ // `since` is an iterable of {peer, stamp} or [peer, WireStamp] frontier entries.
461
+ syncFrameSince(since) {
462
+ const watermark = this.#watermarkOf(since);
463
+ const ops = this.#ops.filter((op) => {
464
+ const seen = watermark.get(op.stamp.peer);
465
+ return seen === undefined || compareWireStamp(op.stamp, seen) > 0;
466
+ });
467
+ return new CrdtSync({ frontier: this.frontierEntries(), ops });
468
+ }
469
+
470
+ // Reply to a peer's anti-entropy `request` (a CrdtSync): ship exactly the ops
471
+ // the requester (described by `request.frontier`) is missing.
472
+ syncReply(request) {
473
+ return this.syncFrameSince(request.frontier);
474
+ }
475
+
476
+ #watermarkOf(since) {
477
+ const watermark = new Map();
478
+ for (const entry of since ?? []) {
479
+ let peer;
480
+ let stamp;
481
+ if (Array.isArray(entry)) {
482
+ peer = entry[0];
483
+ stamp =
484
+ entry[1] instanceof WireStamp ? entry[1] : WireStamp.fromWire(entry[1]);
485
+ } else {
486
+ peer = entry.peer;
487
+ stamp =
488
+ entry.stamp instanceof WireStamp
489
+ ? entry.stamp
490
+ : WireStamp.fromWire(entry.stamp);
491
+ }
492
+ watermark.set(peer, stamp);
493
+ }
494
+ return watermark;
495
+ }
496
+ }
497
+
498
+ // ---------------------------------------------------------------------------
499
+ // Browser WebRTC platform adapter (constructed lazily; importing never throws)
500
+ // ---------------------------------------------------------------------------
501
+
502
+ /** Whether the browser WebRTC globals are present in this environment. */
503
+ export function isWebRtcAvailable() {
504
+ return (
505
+ typeof RTCPeerConnection !== "undefined" &&
506
+ typeof RTCDataChannel !== "undefined"
507
+ );
508
+ }
509
+
510
+ const textEncoder = new TextEncoder();
511
+
512
+ /**
513
+ * Adapter that wraps a browser `RTCDataChannel` as a `DataChannel`. Inbound
514
+ * messages are buffered so `tryRecvFrame` stays non-blocking. Frames are
515
+ * Uint8Array; the channel `binaryType` is forced to "arraybuffer".
516
+ */
517
+ export class RtcPeerChannel {
518
+ #dc;
519
+ #inbox = [];
520
+ #open;
521
+
522
+ constructor(dataChannel) {
523
+ this.#dc = dataChannel;
524
+ this.#open = dataChannel.readyState === "open";
525
+ dataChannel.binaryType = "arraybuffer";
526
+ dataChannel.addEventListener("open", () => {
527
+ this.#open = true;
528
+ });
529
+ dataChannel.addEventListener("close", () => {
530
+ this.#open = false;
531
+ });
532
+ dataChannel.addEventListener("message", (event) => {
533
+ const data = event.data;
534
+ if (data instanceof ArrayBuffer) {
535
+ this.#inbox.push(new Uint8Array(data));
536
+ } else if (typeof data === "string") {
537
+ this.#inbox.push(textEncoder.encode(data));
538
+ } else if (ArrayBuffer.isView(data)) {
539
+ this.#inbox.push(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
540
+ } else {
541
+ this.#inbox.push(new Uint8Array(data));
542
+ }
543
+ });
544
+ }
545
+
546
+ sendFrame(frame) {
547
+ this.#dc.send(frame);
548
+ }
549
+
550
+ tryRecvFrame() {
551
+ return this.#inbox.length > 0 ? this.#inbox.shift() : null;
552
+ }
553
+
554
+ isOpen() {
555
+ return this.#open;
556
+ }
557
+
558
+ close() {
559
+ this.#dc.close();
560
+ }
561
+ }
562
+
563
+ /**
564
+ * Thin helper that drives an `RTCPeerConnection` offer/answer/ICE handshake via
565
+ * a `SignalingClient` (from ./signaling.js). Constructed lazily: the constructor
566
+ * throws if the WebRTC globals are absent, so a Node import of this module never
567
+ * evaluates them.
568
+ *
569
+ * Typical use: the initiator calls `createDataChannel(label)` then
570
+ * `createOffer(to)`; the responder feeds forwarded `offer`/`answer`/`ice`
571
+ * server frames into `acceptOffer` / `acceptAnswer` / `addIceCandidate`.
572
+ */
573
+ export class RtcPeerConnector {
574
+ #client;
575
+ #remote;
576
+ #pc;
577
+
578
+ constructor(signalingClient, { rtcConfig, remote = null } = {}) {
579
+ if (typeof RTCPeerConnection === "undefined") {
580
+ throw new Error("RTCPeerConnection is not available in this environment");
581
+ }
582
+ this.#client = signalingClient;
583
+ this.#remote = remote;
584
+ this.#pc = new RTCPeerConnection(rtcConfig);
585
+ this.#pc.addEventListener("icecandidate", (event) => {
586
+ if (event.candidate && this.#remote !== null) {
587
+ this.#client.ice(this.#remote, JSON.stringify(event.candidate));
588
+ }
589
+ });
590
+ }
591
+
592
+ get connection() {
593
+ return this.#pc;
594
+ }
595
+
596
+ set remote(peer) {
597
+ this.#remote = peer;
598
+ }
599
+
600
+ // Create a `DataChannel`-shaped adapter over a fresh ordered/reliable channel.
601
+ createDataChannel(label = "lazily", options = {}) {
602
+ const dc = this.#pc.createDataChannel(label, { ordered: true, ...options });
603
+ return new RtcPeerChannel(dc);
604
+ }
605
+
606
+ // Await inbound remote channels (returns a Promise<RtcPeerChannel>).
607
+ onDataChannel() {
608
+ return new Promise((resolve) => {
609
+ this.#pc.addEventListener(
610
+ "datachannel",
611
+ (event) => resolve(new RtcPeerChannel(event.channel)),
612
+ { once: true },
613
+ );
614
+ });
615
+ }
616
+
617
+ async createOffer(to) {
618
+ this.#remote = to;
619
+ const offer = await this.#pc.createOffer();
620
+ await this.#pc.setLocalDescription(offer);
621
+ this.#client.offer(to, offer.sdp);
622
+ return offer;
623
+ }
624
+
625
+ async acceptOffer(from, sdp) {
626
+ this.#remote = from;
627
+ await this.#pc.setRemoteDescription({ type: "offer", sdp });
628
+ const answer = await this.#pc.createAnswer();
629
+ await this.#pc.setLocalDescription(answer);
630
+ this.#client.answer(from, answer.sdp);
631
+ return answer;
632
+ }
633
+
634
+ async acceptAnswer(sdp) {
635
+ await this.#pc.setRemoteDescription({ type: "answer", sdp });
636
+ }
637
+
638
+ async addIceCandidate(candidate) {
639
+ const init =
640
+ typeof candidate === "string" ? JSON.parse(candidate) : candidate;
641
+ await this.#pc.addIceCandidate(init);
642
+ }
643
+
644
+ close() {
645
+ this.#pc.close();
646
+ }
647
+ }
package/src/index.js CHANGED
@@ -976,8 +976,9 @@ export const BINDING_CAPABILITIES = Object.freeze({
976
976
  ffi: FfiCapability.None,
977
977
  // Reactive core (Cell/Slot/Effect/Signal): shipped.
978
978
  reactive_core: true,
979
- // Async reactive context: optional (async.md: "A binding MAY omit it entirely").
980
- async_context: false,
979
+ // Async reactive context: shipped (./reactive-async Promise-driven derivations
980
+ // with revision-guarded stale-completion discard, in-flight dedup, cancellation).
981
+ async_context: true,
981
982
  // Shipped MUST surfaces:
982
983
  ipc: true,
983
984
  crdt: true,
@@ -990,9 +991,12 @@ export const BINDING_CAPABILITIES = Object.freeze({
990
991
  state_charts: true,
991
992
  permissions: true,
992
993
  capability_negotiation: true,
993
- // Optional (MAY) transports — not bridged by this binding:
994
- signaling: false,
995
- webrtc: false,
994
+ // Optional (MAY) transports — bridged by this binding (./signaling,
995
+ // ./distributed): the kebab-tagged signaling wire protocol + room routing and
996
+ // the WebRTC DataChannel IPC transport + CRDT anti-entropy runtime. Real
997
+ // WebRTC is reached through a browser platform adapter (no npm dependency).
998
+ signaling: true,
999
+ webrtc: true,
996
1000
  });
997
1001
 
998
1002
  export const OpKind = Object.freeze({