@noy-db/by-peer 0.7.0 → 0.8.0-pre.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.
package/LICENSE CHANGED
@@ -1,21 +1,202 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 vLannaAi
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright 2026 vLannaAi
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/NOTICE ADDED
@@ -0,0 +1 @@
1
+ noy-db — Copyright 2026 vLannaAi
package/README.md CHANGED
@@ -1,8 +1,21 @@
1
1
  # @noy-db/by-peer
2
2
 
3
- WebRTC peer-to-peer transport for [noy-db](https://github.com/vLannaAi/noy-db) — no server in the middle.
4
-
5
- > First member of the `by-*` family of session-share transports. See [`docs/packages/by-transports.md`](https://github.com/vLannaAi/noy-db-docs/blob/main/content/docs/packages/by-transports.md) for the family contract and roster.
3
+ <!-- prose-preamble
4
+ // Bindings the illustrative blocks below elide. Typed on purpose (#1310).
5
+ import { servePeerStore, peerStore, type PeerChannel } from '@noy-db/by-peer'
6
+ import type { NoydbStore } from '@noy-db/hub'
7
+ declare const channel: PeerChannel
8
+ declare const local: NoydbStore
9
+ declare const inviteToken: string
10
+ declare const aliceChannel: PeerChannel
11
+ declare const bobChannel: PeerChannel
12
+ declare const aliceInvite: string
13
+ declare const bobInvite: string
14
+ -->
15
+
16
+ WebRTC peer-to-peer transport for [noy-db](https://github.com/noy-db/core) — no server in the middle.
17
+
18
+ > First member of the `by-*` family of session-share transports. See [`docs/packages/by-transports.md`](https://github.com/noy-db/docs/blob/main/content/docs/packages/by-transports.md) for the family contract and roster.
6
19
  >
7
20
  > Previously published as `@noy-db/p2p` — same code, renamed to fit the prefix family.
8
21
 
@@ -93,9 +106,53 @@ servePeerStore({
93
106
 
94
107
  Denied methods surface as a remote `Error` at the client.
95
108
 
109
+ ## Multiple invites — a star of peers
110
+
111
+ `serveMultiPeerStore` is one peer accepting **N invites instead of 1**: star
112
+ topology, every invited peer talking to this one, never to each other. Each
113
+ accepted invite is one `servePeerStore` underneath — its own channel, its own
114
+ token, its own `allow` — so everything above holds per peer. **The one thing
115
+ that is not assembly is a token per peer:**
116
+
117
+ ```ts
118
+ import { serveMultiPeerStore } from '@noy-db/by-peer'
119
+
120
+ const star = serveMultiPeerStore({ store: local })
121
+
122
+ // One invite → one token → one channel. Returns a revoke for exactly that peer.
123
+ const revokeAlice = star.accept({ channel: aliceChannel, token: aliceInvite })
124
+ star.accept({ channel: bobChannel, token: bobInvite, allow: new Set(['get', 'list', 'loadAll', 'ping']) })
125
+
126
+ revokeAlice() // Alice is gone; Bob is untouched
127
+ star.size // 1
128
+ star.dispose() // everyone
129
+ ```
130
+
131
+ - **Fail-closed survives the widening.** `accept()` refuses an empty or missing
132
+ token at runtime as well as by type, so "no token" can never mean "any peer".
133
+ - **A token is bound to the channel it was accepted on.** Alice's token on
134
+ Bob's channel is refused; a token that is already live is refused at
135
+ `accept()` — one token, one peer, or revocation stops meaning anything.
136
+ - **A closed channel is a departed peer**: it is dropped and its token may be
137
+ issued again.
138
+ - **Attribution is a decision, not a default.** With a token per peer "who
139
+ wrote this" becomes answerable, so this package records nothing — it holds
140
+ `(channel, token)` pairs and no history. Pass a per-peer decorated `store` in
141
+ `accept()` if you want attribution, and own what you log.
142
+ - **What the hub peer sees.** Every invited peer's records pass through it as
143
+ ciphertext; it can open them only with a keyring for that vault (as the
144
+ vault's owner it normally has one — this is a session share, not an escrow).
145
+ It can always observe metadata: which channel called which method on which
146
+ vault/collection/id, and when. It is also the single point of failure: if
147
+ its tab closes, the invites close with it.
148
+
149
+ No catalog, no locator, no discovery — those belong to the daemon. And no
150
+ `leaderElection`: an invite is one channel to one remote peer, the opposite
151
+ shape from the many-tabs-one-bus case Web Locks solves below.
152
+
96
153
  ## Leader election (cross-tab coordination)
97
154
 
98
- When 3+ tabs share a `BroadcastChannel`-backed `PeerChannel` (typically via `@noy-db/by-tabs`) and each runs `servePeerStore`, every non-sending tab responds to every RPC — producing duplicate responses for the same request id and `O(N²)` channel traffic at scale (issue [#3](https://github.com/vLannaAi/noy-db/issues/3)).
155
+ When 3+ tabs share a `BroadcastChannel`-backed `PeerChannel` (typically via `@noy-db/by-tabs`) and each runs `servePeerStore`, every non-sending tab responds to every RPC — producing duplicate responses for the same request id and `O(N²)` channel traffic at scale (issue [#3](https://github.com/noy-db/core/issues/3)).
99
156
 
100
157
  Opt in to leader-election semantics via the Web Locks API. Only the lock-holding tab registers an RPC handler; others queue silently and take over when the holder's tab closes (the lock auto-releases).
101
158
 
package/dist/index.d.ts CHANGED
@@ -255,6 +255,102 @@ interface ServePeerStoreOptions {
255
255
  */
256
256
  declare function servePeerStore(opts: ServePeerStoreOptions): () => void;
257
257
 
258
+ /**
259
+ * `serveMultiPeerStore()` — the hub of a star: one browser peer accepting
260
+ * N invites instead of 1 (#1239).
261
+ *
262
+ * Mostly assembly. Each accepted invite is exactly one `servePeerStore`
263
+ * instance — its own channel, its own token, its own `allow` set — so
264
+ * everything `servePeerStore` guarantees for the 1:1 case holds per peer here.
265
+ * What this module adds is the part that is NOT assembly: **a token per peer.**
266
+ *
267
+ * - **Revocation is per peer.** Dropping one invite stops serving that peer
268
+ * and nobody else; a single shared token could not express that.
269
+ * - **Fail-closed survives the widening.** `accept()` requires a non-empty
270
+ * token, checked at runtime as well as by type, so a multi-peer API can
271
+ * never turn "no token" into "any peer" — the pre-#1236 behaviour arriving
272
+ * by another route.
273
+ * - **A token is bound to the channel it was accepted on.** Presenting peer
274
+ * A's token on peer B's channel is refused, because B's instance only knows
275
+ * B's token. A token that is already live is refused at `accept()`: one
276
+ * token, one peer, or revocation and attribution stop meaning anything.
277
+ *
278
+ * ## What the hub peer can and cannot see
279
+ *
280
+ * Star topology makes this peer a single point of failure and of trust. Every
281
+ * invited peer's records pass through it — **as ciphertext.** Hub encrypts
282
+ * before any store is called, so the serving peer holds envelopes it cannot
283
+ * open unless it also holds a keyring for that vault (which, as the vault's
284
+ * own owner, it normally does — this is a session share, not an escrow). What
285
+ * it CAN observe regardless is metadata: which channel called which method
286
+ * on which vault/collection/id, and when.
287
+ *
288
+ * ## Attribution is a decision, not a default
289
+ *
290
+ * With one token "who wrote this" was unanswerable; with a token per peer it
291
+ * becomes answerable, and therefore a choice. This module records **nothing**
292
+ * — it holds `(channel, token)` pairs and no history. A host that wants
293
+ * attribution passes a per-peer decorated `store` in `accept()` and owns the
294
+ * privacy consequences of what it logs.
295
+ *
296
+ * ## No leader election here, deliberately
297
+ *
298
+ * `servePeerStore`'s `leaderElection` exists for a `BroadcastChannel`-backed
299
+ * channel shared by many tabs of ONE origin. An invite is the opposite shape:
300
+ * one channel, one remote peer. A star of invites lives in one tab; if that
301
+ * tab closes, the invites close with it and the peers reconnect through a new
302
+ * one. Layering Web Locks over N distinct channels would need one lock per
303
+ * channel and a story for what a "logical server" is across them — not
304
+ * something a session share needs, and out of scope per the milestone
305
+ * (catalog, locator and discovery belong to the daemon).
306
+ *
307
+ * @module
308
+ */
309
+
310
+ /** One invited peer. */
311
+ interface PeerInvite {
312
+ /** The duplex channel to this one peer. */
313
+ readonly channel: PeerChannel;
314
+ /**
315
+ * Bearer token from THIS peer's invite. Required and non-empty; the same
316
+ * token cannot be live on two channels at once.
317
+ */
318
+ readonly token: string;
319
+ /**
320
+ * Per-peer method whitelist (e.g. a read-only peer). Overrides the server
321
+ * default for this peer only. NOT authentication — it composes with `token`.
322
+ */
323
+ readonly allow?: ReadonlySet<string>;
324
+ /**
325
+ * Serve this peer from a different store — the seam for per-peer
326
+ * attribution or metering decorators. Defaults to the server's store.
327
+ */
328
+ readonly store?: NoydbStore;
329
+ }
330
+ interface MultiPeerServerOptions {
331
+ /** The local store every accepted peer is served from, unless an invite overrides it. */
332
+ readonly store: NoydbStore;
333
+ /** Default method whitelist for every accepted peer. */
334
+ readonly allow?: ReadonlySet<string>;
335
+ }
336
+ interface MultiPeerServer {
337
+ /**
338
+ * Admit one invited peer. Returns a revoke function for exactly that peer;
339
+ * calling it stops serving that channel and leaves every other peer alone.
340
+ * Idempotent. The channel itself is NOT closed — ownership stays with the
341
+ * caller, as with `servePeerStore`'s dispose.
342
+ *
343
+ * Throws if the token is missing or empty (fail-closed), if that token is
344
+ * already live on another channel, or after `dispose()`.
345
+ */
346
+ accept(invite: PeerInvite): () => void;
347
+ /** Peers currently being served. */
348
+ readonly size: number;
349
+ /** Revoke every peer. Idempotent; `accept()` refuses afterwards. */
350
+ dispose(): void;
351
+ }
352
+ declare function serveMultiPeerStore(opts: MultiPeerServerOptions): MultiPeerServer;
353
+
258
354
  /**
259
355
  * WebRTC handshake helper — opinionated wrapper around
260
356
  * `RTCPeerConnection` that produces a ready-to-use `PeerChannel`.
@@ -382,4 +478,4 @@ declare function channelMesh(channel: PeerChannel): NoydbMesh;
382
478
  */
383
479
  declare const byPeer: (channel: PeerChannel) => NoydbMesh;
384
480
 
385
- export { type Initiator, type MinimalLockManager, type PeerChannel, type PeerStoreOptions, type Responder, type RpcClientOptions, type RpcHandler, type RpcMessage, type RpcRequest, type RpcResponse, type ServePeerStoreOptions, type WebRTCOptions, acceptOffer, byPeer, channelMesh, createOffer, createRpcClient, fromDataChannel, pairInMemory, peerStore, servePeerStore, serveRpc };
481
+ export { type Initiator, type MinimalLockManager, type MultiPeerServer, type MultiPeerServerOptions, type PeerChannel, type PeerInvite, type PeerStoreOptions, type Responder, type RpcClientOptions, type RpcHandler, type RpcMessage, type RpcRequest, type RpcResponse, type ServePeerStoreOptions, type WebRTCOptions, acceptOffer, byPeer, channelMesh, createOffer, createRpcClient, fromDataChannel, pairInMemory, peerStore, serveMultiPeerStore, servePeerStore, serveRpc };
package/dist/index.js CHANGED
@@ -397,6 +397,54 @@ function getDefaultLocks() {
397
397
  );
398
398
  }
399
399
 
400
+ // src/serve-multi.ts
401
+ function serveMultiPeerStore(opts) {
402
+ const peers = /* @__PURE__ */ new Map();
403
+ let disposed = false;
404
+ return {
405
+ accept(invite) {
406
+ if (disposed) throw new Error("serveMultiPeerStore: this server is disposed and accepts no more invites");
407
+ const token = invite?.token;
408
+ if (typeof token !== "string" || token.length === 0) {
409
+ throw new Error(
410
+ 'serveMultiPeerStore.accept: an invite needs a non-empty bearer token. A multi-peer server never serves an unauthenticated channel \u2014 "no token" must not become "any peer".'
411
+ );
412
+ }
413
+ if (peers.has(token)) {
414
+ throw new Error(
415
+ "serveMultiPeerStore.accept: that token is already live on another channel. One token, one peer \u2014 issue a fresh invite (or revoke the existing peer first)."
416
+ );
417
+ }
418
+ const allow = invite.allow ?? opts.allow;
419
+ const stop = servePeerStore({
420
+ channel: invite.channel,
421
+ store: invite.store ?? opts.store,
422
+ token,
423
+ ...allow !== void 0 && { allow }
424
+ });
425
+ let live = true;
426
+ const revoke = () => {
427
+ if (!live) return;
428
+ live = false;
429
+ stop();
430
+ offClose();
431
+ peers.delete(token);
432
+ };
433
+ const offClose = invite.channel.on("close", revoke);
434
+ peers.set(token, revoke);
435
+ return revoke;
436
+ },
437
+ get size() {
438
+ return peers.size;
439
+ },
440
+ dispose() {
441
+ if (disposed) return;
442
+ disposed = true;
443
+ for (const revoke of [...peers.values()]) revoke();
444
+ }
445
+ };
446
+ }
447
+
400
448
  // src/webrtc.ts
401
449
  function requireRTC() {
402
450
  const g = globalThis;
@@ -575,6 +623,7 @@ export {
575
623
  fromDataChannel,
576
624
  pairInMemory,
577
625
  peerStore,
626
+ serveMultiPeerStore,
578
627
  servePeerStore,
579
628
  serveRpc
580
629
  };
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/channel.ts","../src/rpc.ts","../src/peer-store.ts","../src/serve.ts","../src/webrtc.ts","../src/coordination.ts"],"sourcesContent":["/**\n * `PeerChannel` — the minimal duplex message primitive used by the p2p\n * NoydbStore wrapper. Any transport that can deliver UTF-8 strings\n * reliably and in-order qualifies: WebRTC DataChannel, BroadcastChannel,\n * MessagePort, WebSocket, even postMessage pairs.\n *\n * Keeping the transport abstract has three payoffs:\n *\n * 1. **Tests run without a WebRTC polyfill.** `pairInMemory()` returns\n * two wired channels for conformance tests against `to-memory`.\n * 2. **Consumers pick their signaling story.** Matrix rooms, QR codes,\n * pastebin, Firebase Realtime DB — the handshake is out of scope.\n * 3. **Future transports slot in cheaply.** WebTransport (HTTP/3),\n * libp2p, Iroh, or a plain relay WebSocket become additional\n * bindings without touching the RPC layer.\n *\n * @module\n */\n\n/**\n * Minimal duplex message primitive.\n *\n * Implementations MUST deliver every `send` payload in order exactly\n * once to every live `on('message')` subscriber. `close()` is best-effort\n * — once called, further `send()` calls MAY throw and `on('close')` MUST\n * fire once.\n */\nexport interface PeerChannel {\n /** Enqueue a payload for delivery to the remote end. */\n send(payload: string): void\n /** Subscribe to incoming payloads or lifecycle events. Returns unsubscribe. */\n on(event: 'message', listener: (payload: string) => void): () => void\n on(event: 'close', listener: () => void): () => void\n /** Close the channel. Idempotent. */\n close(): void\n /** True once the channel is ready for `send`. */\n readonly isOpen: boolean\n}\n\n/**\n * Create a pair of in-memory `PeerChannel`s wired to each other.\n * Intended for tests and multi-tab simulations inside a single process.\n */\nexport function pairInMemory(): [PeerChannel, PeerChannel] {\n type Listeners = {\n message: Set<(p: string) => void>\n close: Set<() => void>\n }\n\n function make(): { ch: PeerChannel; listeners: Listeners; closed: { v: boolean } } {\n const listeners: Listeners = { message: new Set(), close: new Set() }\n const closed = { v: false }\n const ch: PeerChannel = {\n get isOpen() {\n return !closed.v\n },\n send() {\n // Placeholder — wired below.\n },\n on(event: 'message' | 'close', listener: ((payload: string) => void) | (() => void)): () => void {\n if (event === 'message') {\n listeners.message.add(listener as (p: string) => void)\n return () => listeners.message.delete(listener as (p: string) => void)\n }\n listeners.close.add(listener as () => void)\n return () => listeners.close.delete(listener as () => void)\n },\n close() {\n if (closed.v) return\n closed.v = true\n for (const fn of listeners.close) fn()\n },\n } as PeerChannel\n return { ch, listeners, closed }\n }\n\n const a = make()\n const b = make()\n\n function closeBoth(): void {\n if (!a.closed.v) {\n a.closed.v = true\n for (const fn of a.listeners.close) fn()\n }\n if (!b.closed.v) {\n b.closed.v = true\n for (const fn of b.listeners.close) fn()\n }\n }\n\n a.ch.send = (payload) => {\n if (b.closed.v) throw new Error('PeerChannel closed')\n queueMicrotask(() => {\n for (const fn of b.listeners.message) fn(payload)\n })\n }\n b.ch.send = (payload) => {\n if (a.closed.v) throw new Error('PeerChannel closed')\n queueMicrotask(() => {\n for (const fn of a.listeners.message) fn(payload)\n })\n }\n a.ch.close = closeBoth\n b.ch.close = closeBoth\n\n return [a.ch, b.ch]\n}\n\n/**\n * Wrap a WebRTC `RTCDataChannel` as a `PeerChannel`.\n *\n * Browser-only — the caller is responsible for establishing the\n * `RTCPeerConnection`, exchanging SDP offers/answers out of band, and\n * passing the opened DataChannel here. When the remote peer is only\n * reachable via TURN, the relay sees DTLS-wrapped ciphertext (noy-db\n * already encrypts at rest, so even a TURN compromise leaks nothing).\n */\nexport function fromDataChannel(dc: RTCDataChannel): PeerChannel {\n type Listeners = {\n message: Set<(p: string) => void>\n close: Set<() => void>\n }\n const listeners: Listeners = { message: new Set(), close: new Set() }\n let closed = false\n\n dc.addEventListener('message', (ev: MessageEvent) => {\n if (typeof ev.data !== 'string') return\n for (const fn of listeners.message) fn(ev.data)\n })\n dc.addEventListener('close', () => {\n if (closed) return\n closed = true\n for (const fn of listeners.close) fn()\n })\n\n return {\n get isOpen() {\n return !closed && dc.readyState === 'open'\n },\n send(payload) {\n if (closed || dc.readyState !== 'open') {\n throw new Error(`PeerChannel not open (readyState: ${dc.readyState})`)\n }\n dc.send(payload)\n },\n on(event: 'message' | 'close', listener: ((payload: string) => void) | (() => void)): () => void {\n if (event === 'message') {\n listeners.message.add(listener as (p: string) => void)\n return () => listeners.message.delete(listener as (p: string) => void)\n }\n listeners.close.add(listener as () => void)\n return () => listeners.close.delete(listener as () => void)\n },\n close() {\n if (closed) return\n closed = true\n try {\n dc.close()\n } catch {\n // ignore — channel already torn down\n }\n for (const fn of listeners.close) fn()\n },\n } as PeerChannel\n}\n","/**\n * JSON-RPC protocol over a `PeerChannel`.\n *\n * Request shape:\n * `{ t: 'req', id, method, args, auth? }`\n * Response shape (success):\n * `{ t: 'res', id, ok: true, result }`\n * Response shape (error):\n * `{ t: 'res', id, ok: false, error: { name, message, version? } }`\n *\n * Why not reuse msgpack/protobuf? The payloads are already base64-encoded\n * ciphertext — further binary packing saves ~8-12% at a large dependency\n * cost. JSON over UTF-8 is inspectable, fits the zero-dependency ethos,\n * and WebRTC DataChannel string mode already frames for us.\n *\n * @module\n */\n\nimport type { PeerChannel } from './channel.js'\n\n/** Wire format discriminator for RPC messages. */\nexport type RpcMessage = RpcRequest | RpcResponse\n\nexport interface RpcRequest {\n readonly t: 'req'\n readonly id: string\n readonly method: string\n readonly args: readonly unknown[]\n /**\n * Bearer token from the invite (milestone 52). Additive and optional ON THE\n * WIRE — an older client simply omits it — but a fail-closed server refuses\n * a request without one, which is the point: holding the channel is no\n * longer the credential.\n */\n readonly auth?: string\n}\n\nexport interface RpcResponse {\n readonly t: 'res'\n readonly id: string\n readonly ok: boolean\n readonly result?: unknown\n readonly error?: { name: string; message: string; version?: number }\n}\n\n/**\n * Handler invoked when an RPC request arrives. Return value is serialized as\n * `result`. `auth` is the bearer token the caller presented, or `undefined`\n * when it presented none — the handler decides, not this layer.\n */\nexport type RpcHandler = (\n method: string,\n args: readonly unknown[],\n auth?: string,\n) => Promise<unknown>\n\n/** Options for a client-side RPC caller. */\nexport interface RpcClientOptions {\n /** Max milliseconds to wait for a response before rejecting. */\n timeoutMs?: number\n /** Bearer token from the invite, presented on every request (milestone 52). */\n token?: string\n}\n\n/**\n * Drop trailing `undefined` arguments before serialisation (#1026).\n *\n * JSON has no `undefined`: `JSON.stringify([a, undefined])` produces\n * `[a, null]`. For an argument whose absence is meaningful that is not a\n * lossless round-trip, it is a REWRITE. `NoydbStore.put`'s\n * `expectedVersion?: number` is exactly such an argument — `undefined` means\n * \"do not compare-and-set\", while a store's guard (`expectedVersion !== undefined`)\n * treats the `null` that arrives as a real assertion, one no existing record can\n * satisfy. Every overwrite through a peer store therefore failed with\n * `expected null, found <n>`, making the whole topology look read-only.\n *\n * Trimming from the end is safe because a hole in the middle cannot occur: the\n * store contract only ever has optional parameters last, and an explicitly\n * passed `undefined` in a trailing slot is indistinguishable from omission by\n * definition.\n */\nfunction trimTrailingUndefined(args: readonly unknown[]): readonly unknown[] {\n let end = args.length\n while (end > 0 && args[end - 1] === undefined) end--\n return end === args.length ? args : args.slice(0, end)\n}\n\n/** Client: wrap a `PeerChannel` in a `call(method, args)` helper. */\nexport function createRpcClient(channel: PeerChannel, opts: RpcClientOptions = {}) {\n const timeoutMs = opts.timeoutMs ?? 30_000\n type Pending = {\n resolve: (v: unknown) => void\n reject: (err: Error) => void\n timer: ReturnType<typeof setTimeout>\n }\n const pending = new Map<string, Pending>()\n let counter = 0\n\n const offMessage = channel.on('message', (payload) => {\n let msg: RpcMessage\n try {\n msg = JSON.parse(payload) as RpcMessage\n } catch {\n return\n }\n if (msg.t !== 'res') return\n const entry = pending.get(msg.id)\n if (!entry) return\n clearTimeout(entry.timer)\n pending.delete(msg.id)\n if (msg.ok) {\n entry.resolve(msg.result)\n } else {\n const e = msg.error ?? { name: 'Error', message: 'unknown remote error' }\n const err = new Error(e.message)\n err.name = e.name\n if (typeof e.version === 'number') {\n ;(err as Error & { version?: number }).version = e.version\n }\n entry.reject(err)\n }\n })\n\n const offClose = channel.on('close', () => {\n for (const [, entry] of pending) {\n clearTimeout(entry.timer)\n entry.reject(new Error('PeerChannel closed before response'))\n }\n pending.clear()\n })\n\n return {\n async call<T = unknown>(method: string, args: readonly unknown[]): Promise<T> {\n const id = `${Date.now().toString(36)}-${(++counter).toString(36)}`\n const req: RpcRequest = {\n t: 'req', id, method, args: trimTrailingUndefined(args),\n ...(opts.token !== undefined && { auth: opts.token }),\n }\n return new Promise<T>((resolve, reject) => {\n const timer = setTimeout(() => {\n pending.delete(id)\n reject(new Error(`RPC ${method} timed out after ${timeoutMs}ms`))\n }, timeoutMs)\n pending.set(id, {\n resolve: resolve as (v: unknown) => void,\n reject,\n timer,\n })\n try {\n channel.send(JSON.stringify(req))\n } catch (err) {\n clearTimeout(timer)\n pending.delete(id)\n reject(err instanceof Error ? err : new Error(String(err)))\n }\n })\n },\n dispose() {\n offMessage()\n offClose()\n for (const [, entry] of pending) {\n clearTimeout(entry.timer)\n entry.reject(new Error('RPC client disposed'))\n }\n pending.clear()\n },\n }\n}\n\n/** Server: dispatch incoming RPC requests through a handler. Returns a dispose fn. */\nexport function serveRpc(channel: PeerChannel, handler: RpcHandler): () => void {\n async function handle(payload: string): Promise<void> {\n let msg: RpcMessage\n try {\n msg = JSON.parse(payload) as RpcMessage\n } catch {\n return\n }\n if (msg.t !== 'req') return\n\n let response: RpcResponse\n try {\n const result = await handler(msg.method, msg.args, msg.auth)\n response = { t: 'res', id: msg.id, ok: true, result }\n } catch (err) {\n const e = err as Error & { version?: number }\n response = {\n t: 'res',\n id: msg.id,\n ok: false,\n error: {\n name: e.name ?? 'Error',\n message: e.message ?? String(err),\n ...(typeof e.version === 'number' && { version: e.version }),\n },\n }\n }\n\n if (!channel.isOpen) return\n try {\n channel.send(JSON.stringify(response))\n } catch {\n // Channel closed mid-response — nothing to do.\n }\n }\n\n const offMessage = channel.on('message', (payload) => {\n void handle(payload)\n })\n\n return () => offMessage()\n}\n","/**\n * `peerStore()` — a `NoydbStore` backed by RPC calls over a `PeerChannel`.\n *\n * The local peer calls `get`/`put`/`delete`/… against this store as if\n * it were any other backend; every call is serialized as an RPC request\n * to the remote peer, which runs `servePeerStore()` to funnel the RPCs\n * into its own local `NoydbStore`.\n *\n * Error re-hydration: the remote handler re-throws `ConflictError` with\n * a `.version` field when a CAS check fails. The RPC layer carries\n * `version` in the error envelope so the local caller can catch\n * `ConflictError` with the same semantics as a direct store call.\n *\n * @module\n */\n\nimport type { NoydbStore, EncryptedEnvelope, VaultSnapshot } from '@noy-db/hub/to'\nimport { ConflictError } from '@noy-db/hub/to'\nimport type { PeerChannel } from './channel.js'\nimport { createRpcClient } from './rpc.js'\n\nexport interface PeerStoreOptions {\n /** The duplex channel to the remote peer. */\n readonly channel: PeerChannel\n /** Max ms to wait for any single RPC response. Default 30s. */\n readonly timeoutMs?: number\n /** Optional display name used in diagnostics. Default `'by-peer'`. */\n readonly name?: string\n /**\n * Bearer token from the invite, presented on every request (milestone 52).\n * The serving peer is fail-closed, so a store created without one is refused.\n */\n readonly token?: string\n}\n\n/**\n * Create a `NoydbStore` that forwards every operation to a remote peer\n * over the supplied `PeerChannel`. The remote peer must be running\n * `servePeerStore()` against its own local store.\n */\nexport function peerStore(opts: PeerStoreOptions): NoydbStore & { dispose: () => void } {\n const rpc = createRpcClient(opts.channel, {\n timeoutMs: opts.timeoutMs ?? 30_000,\n ...(opts.token !== undefined && { token: opts.token }),\n })\n\n async function call<T>(method: string, args: readonly unknown[]): Promise<T> {\n try {\n return await rpc.call<T>(method, args)\n } catch (err) {\n // Re-hydrate ConflictError so CAS semantics survive the wire hop.\n const e = err as Error & { version?: number }\n if (e.name === 'ConflictError' && typeof e.version === 'number') {\n throw new ConflictError(e.version, e.message)\n }\n throw err\n }\n }\n\n return {\n name: opts.name ?? 'by-peer',\n\n async get(vault, collection, id) {\n return call<EncryptedEnvelope | null>('get', [vault, collection, id])\n },\n\n async put(vault, collection, id, envelope, expectedVersion) {\n await call<void>('put', [vault, collection, id, envelope, expectedVersion])\n },\n\n async delete(vault, collection, id) {\n await call<void>('delete', [vault, collection, id])\n },\n\n async list(vault, collection) {\n return call<string[]>('list', [vault, collection])\n },\n\n async loadAll(vault) {\n return call<VaultSnapshot>('loadAll', [vault])\n },\n\n async saveAll(vault, data) {\n await call<void>('saveAll', [vault, data])\n },\n\n async ping() {\n try {\n return await call<boolean>('ping', [])\n } catch {\n return false\n }\n },\n\n dispose() {\n rpc.dispose()\n },\n }\n}\n","/**\n * `servePeerStore()` — runs on the peer that owns the data. Listens on\n * a `PeerChannel` for RPC requests from a remote `peerStore()` client\n * and executes each one against the local `NoydbStore`.\n *\n * The 6 core methods plus the optional `ping` / `listSince` / `listPage`\n * extensions are exposed. Unknown methods surface as a remote Error.\n *\n * **Leader-election (issue #3).** When a `BroadcastChannel`-backed\n * `PeerChannel` is shared by 3+ tabs each running `servePeerStore`,\n * every non-sending tab responds to every RPC — producing duplicate\n * responses for the same request id and O(N²) channel traffic at scale.\n * Pass `leaderElection: { lockName, locks? }` to gate serving on the\n * Web Locks API: only the lock-holding tab registers an RPC handler;\n * others queue and take over when the holder's tab closes (lock auto-\n * releases). Default behaviour (no `leaderElection`) is unchanged for\n * the 2-tab case.\n *\n * @module\n */\n\nimport type { NoydbStore, EncryptedEnvelope, VaultSnapshot } from '@noy-db/hub/to'\nimport type { PeerChannel } from './channel.js'\nimport { serveRpc } from './rpc.js'\n\n/**\n * Minimal subset of the Web Locks API used by leader-election. The\n * browser's `navigator.locks` satisfies this; tests inject a stub.\n *\n * @public\n */\nexport interface MinimalLockManager {\n request<T>(\n name: string,\n options: { mode?: 'exclusive' | 'shared'; signal?: AbortSignal },\n callback: (lock: unknown) => Promise<T>,\n ): Promise<T>\n}\n\n/**\n * Compare in time independent of how many leading characters match.\n *\n * A plain `===` leaks the length of the shared prefix through timing, which\n * over a channel an attacker can call repeatedly is enough to recover a token\n * character by character. Length is not secret here (it is fixed by the invite\n * format), so comparing lengths first is fine; the CONTENT comparison must not\n * short-circuit.\n */\nfunction constantTimeEquals(a: string | undefined, b: string): boolean {\n if (a === undefined || a.length !== b.length) return false\n let diff = 0\n for (let i = 0; i < b.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)\n return diff === 0\n}\n\nexport interface ServePeerStoreOptions {\n /** The duplex channel from the remote peer. */\n readonly channel: PeerChannel\n /** The local store to serve. */\n readonly store: NoydbStore\n /**\n * Bearer token from the invite. Every request must present it (milestone 52).\n *\n * ⚠️ FAIL-CLOSED: when this is omitted, EVERY request is refused. That\n * mirrors `in-rest`, where no `authorize` means 401 on everything, and it is\n * a deliberate reversal — this function used to serve the whole store to\n * anyone who reached the channel.\n *\n * Holding the channel used to BE the credential, which is defensible for a\n * 1:1 invite-based session share. It stops being defensible the moment a\n * channel stops meaning \"I invited this person\" — which is exactly what a\n * multi-peer topology does.\n */\n readonly token?: string\n /**\n * Optional method whitelist. When provided, any method not in the set\n * is rejected with \"method not allowed\". Useful for read-only peers.\n *\n * NOT authentication, and never was: it says which of the six methods may be\n * called, not by whom. It composes with `token` rather than replacing it.\n */\n readonly allow?: ReadonlySet<string>\n /**\n * Opt in to leader-election semantics for cross-tab coordination via\n * the Web Locks API. Required when 3+ tabs share a `BroadcastChannel`-\n * backed `PeerChannel` — without leader election, every non-sending tab\n * responds to every RPC, producing duplicate responses (issue #3).\n *\n * Browser support: Chrome 69+, Firefox 96+, Safari 15.4+.\n *\n * - `lockName` — globally unique lock name. Convention:\n * `noy-db:peer-server:<channel-name>`. The same `lockName` MUST be\n * used by every tab that wants to share the leader role.\n * - `locks` — defaults to `navigator.locks` in browsers. Pass a stub\n * for tests or for non-browser hosts that polyfill the Web Locks API.\n *\n * Behaviour: at most one tab holds the lock at a time. Only that tab\n * registers an RPC handler on the channel; other tabs queue (their\n * `servePeerStore` call returns a working dispose function but does\n * not respond to incoming RPCs). When the holder's tab closes (or its\n * `servePeerStore` is disposed), the next queued tab takes over.\n */\n readonly leaderElection?: {\n readonly lockName: string\n readonly locks?: MinimalLockManager\n }\n}\n\nconst CORE_METHODS = new Set<string>([\n 'get',\n 'put',\n 'delete',\n 'list',\n 'loadAll',\n 'saveAll',\n 'ping',\n 'listSince',\n 'listPage',\n 'listVaults',\n])\n\n/**\n * Start serving the local store on the channel. Returns a dispose\n * function that stops the RPC listener. The underlying channel is NOT\n * closed by dispose — ownership stays with the caller.\n *\n * When `leaderElection` is set, the RPC handler is only registered while\n * this tab holds the named Web Lock. The returned dispose function\n * always works: if called before the lock is acquired, it cancels the\n * wait; if called while holding the lock, it releases the lock and\n * stops serving.\n */\nexport function servePeerStore(opts: ServePeerStoreOptions): () => void {\n if (!opts.leaderElection) {\n return startServing(opts)\n }\n return startServingWithLeaderElection(opts)\n}\n\n/**\n * Register the RPC handler immediately. Used when leader-election is off,\n * and inside the lock callback when on.\n */\nfunction startServing(opts: ServePeerStoreOptions): () => void {\n const { store, channel, allow, token } = opts\n\n return serveRpc(channel, async (method, args, auth) => {\n // Fail-closed, and BEFORE anything else — a refusal that ran after the\n // store call would be worse than no refusal: the caller sees an error and\n // the write landed anyway.\n if (token === undefined || !constantTimeEquals(auth, token)) {\n throw new Error(\n 'Unauthorized: this peer store requires the bearer token from the invite. ' +\n 'Pass it as `servePeerStore({ token })` on the server and `peerStore({ token })` ' +\n 'on the client. A server with no token configured serves nobody, by design.',\n )\n }\n if (!CORE_METHODS.has(method)) {\n throw new Error(`Unknown RPC method: ${method}`)\n }\n if (allow && !allow.has(method)) {\n throw new Error(`Method not allowed: ${method}`)\n }\n\n switch (method) {\n case 'get': {\n const [vault, collection, id] = args as [string, string, string]\n return store.get(vault, collection, id)\n }\n case 'put': {\n const [vault, collection, id, envelope, expectedVersion] = args as [\n string,\n string,\n string,\n EncryptedEnvelope,\n number | null | undefined,\n ]\n // #1026 — `null` is not a legal `expectedVersion` (the contract types it\n // `number | undefined`), so anything that arrives as `null` came from a\n // JSON round-trip of `undefined` and means \"do not compare-and-set\".\n // Normalising here as well as on the client keeps a peer running an\n // older by-peer from silently failing every overwrite: a store's guard\n // is `!== undefined`, which `null` would pass, turning \"no check\" into\n // an assertion no existing record can satisfy.\n await store.put(vault, collection, id, envelope, expectedVersion ?? undefined)\n return null\n }\n case 'delete': {\n const [vault, collection, id] = args as [string, string, string]\n await store.delete(vault, collection, id)\n return null\n }\n case 'list': {\n const [vault, collection] = args as [string, string]\n return store.list(vault, collection)\n }\n case 'loadAll': {\n const [vault] = args as [string]\n return store.loadAll(vault)\n }\n case 'saveAll': {\n const [vault, data] = args as [string, VaultSnapshot]\n await store.saveAll(vault, data)\n return null\n }\n case 'ping': {\n if (!store.ping) return true\n return store.ping()\n }\n case 'listSince': {\n if (!store.listSince) throw new Error('listSince not supported by remote store')\n const [vault, collection, since] = args as [string, string, string]\n return store.listSince(vault, collection, since)\n }\n case 'listPage': {\n if (!store.listPage) throw new Error('listPage not supported by remote store')\n const [vault, collection, cursor, limit] = args as [\n string,\n string,\n string | undefined,\n number | undefined,\n ]\n return store.listPage(vault, collection, cursor, limit)\n }\n case 'listVaults': {\n if (!store.listVaults) throw new Error('listVaults not supported by remote store')\n return store.listVaults()\n }\n }\n /* istanbul ignore next — CORE_METHODS gate makes this unreachable */\n throw new Error(`Unhandled method: ${method}`)\n })\n}\n\n/**\n * Acquire a Web Lock first; only register the RPC listener while\n * holding it. Returns a dispose that cancels the wait (if not yet\n * acquired) or releases the lock (if held).\n */\nfunction startServingWithLeaderElection(opts: ServePeerStoreOptions): () => void {\n // Asserted by the caller branch in `servePeerStore`.\n const leader = opts.leaderElection as { lockName: string; locks?: MinimalLockManager }\n const locks = leader.locks ?? getDefaultLocks()\n const ac = new AbortController()\n let stopServing: (() => void) | null = null\n let releaseLock: (() => void) | null = null\n let disposed = false\n\n void locks\n .request(leader.lockName, { mode: 'exclusive', signal: ac.signal }, () => {\n if (disposed) return Promise.resolve()\n stopServing = startServing(opts)\n // Hold the lock for the lifetime of this tab's leadership. The\n // returned promise stays pending until `releaseLock` is called by\n // the dispose function.\n return new Promise<void>((resolve) => {\n releaseLock = resolve\n })\n })\n .catch((err: unknown) => {\n // AbortError is the expected cancellation when dispose runs before\n // the lock was acquired. Anything else is unexpected — surface it\n // at the next tick so the host can observe it.\n if ((err as { name?: string } | null)?.name === 'AbortError') return\n queueMicrotask(() => {\n throw err\n })\n })\n\n return () => {\n if (disposed) return\n disposed = true\n if (stopServing) {\n stopServing()\n stopServing = null\n }\n if (releaseLock) {\n releaseLock()\n releaseLock = null\n } else {\n ac.abort()\n }\n }\n}\n\nfunction getDefaultLocks(): MinimalLockManager {\n const nav = (globalThis as { navigator?: { locks?: MinimalLockManager } }).navigator\n if (nav && nav.locks) return nav.locks\n throw new Error(\n 'leaderElection requires the Web Locks API (navigator.locks). ' +\n 'Use Chrome 69+, Firefox 96+, Safari 15.4+, or pass `leaderElection.locks: <stub>` ' +\n 'for tests / non-browser hosts.',\n )\n}\n","/**\n * WebRTC handshake helper — opinionated wrapper around\n * `RTCPeerConnection` that produces a ready-to-use `PeerChannel`.\n *\n * The handshake is split into two halves so the caller can ferry the\n * SDP blobs over whatever signaling channel they prefer (QR code,\n * Matrix room, pastebin, Firebase, signed URL…). Signaling is\n * intentionally out of scope — noy-db has no opinion on how peers\n * discover each other, only on what flows once they do.\n *\n * ```ts\n * // Peer A (initiator)\n * const a = await createOffer({ iceServers })\n * send(a.offer) // → signaling channel\n * const answer = await receive() // ← signaling channel\n * await a.accept(answer)\n * const channel = await a.channel // ready PeerChannel\n *\n * // Peer B (responder)\n * const offer = await receive() // ← signaling channel\n * const b = await acceptOffer(offer, { iceServers })\n * send(b.answer) // → signaling channel\n * const channel = await b.channel // ready PeerChannel\n * ```\n *\n * Browser-only. Node consumers who want to interconnect with browsers\n * can plug `@roamhq/wrtc` into the global `RTCPeerConnection` slot; this\n * module does not pull it in so the package has zero runtime deps.\n *\n * @module\n */\n\nimport type { PeerChannel } from './channel.js'\nimport { fromDataChannel } from './channel.js'\n\ntype PeerConnection = RTCPeerConnection\ntype SessionDescription = RTCSessionDescriptionInit\n\nexport interface WebRTCOptions {\n /** Optional ICE servers (STUN / TURN). */\n readonly iceServers?: RTCIceServer[]\n /** Label for the `RTCDataChannel`. Default `'noydb'`. */\n readonly label?: string\n}\n\nexport interface Initiator {\n readonly offer: SessionDescription\n /** Feed in the remote peer's SDP answer to complete the handshake. */\n accept(answer: SessionDescription): Promise<void>\n /** Resolves with the opened `PeerChannel` once the DataChannel is live. */\n readonly channel: Promise<PeerChannel>\n readonly connection: PeerConnection\n}\n\nexport interface Responder {\n readonly answer: SessionDescription\n /** Resolves with the opened `PeerChannel` once the DataChannel is live. */\n readonly channel: Promise<PeerChannel>\n readonly connection: PeerConnection\n}\n\nfunction requireRTC(): typeof RTCPeerConnection {\n const g = globalThis as { RTCPeerConnection?: typeof RTCPeerConnection }\n if (!g.RTCPeerConnection) {\n throw new Error(\n '[@noy-db/by-peer] globalThis.RTCPeerConnection is undefined — use this module in a browser, or polyfill with @roamhq/wrtc in Node',\n )\n }\n return g.RTCPeerConnection\n}\n\n/**\n * Build an offer as the initiating peer. Returns the SDP offer to send\n * to the remote peer and a promise for the opened `PeerChannel`.\n */\nexport async function createOffer(opts: WebRTCOptions = {}): Promise<Initiator> {\n const RTC = requireRTC()\n const pc = new RTC({\n ...(opts.iceServers && { iceServers: opts.iceServers }),\n })\n\n const dc = pc.createDataChannel(opts.label ?? 'noydb', {\n ordered: true,\n })\n\n const channelPromise = new Promise<PeerChannel>((resolve, reject) => {\n dc.addEventListener('open', () => resolve(fromDataChannel(dc)))\n dc.addEventListener('error', () => reject(new Error('DataChannel error')))\n })\n\n const offer = await pc.createOffer()\n await pc.setLocalDescription(offer)\n await waitForIceComplete(pc)\n\n return {\n offer: pc.localDescription ?? offer,\n connection: pc,\n channel: channelPromise,\n async accept(answer) {\n await pc.setRemoteDescription(answer)\n },\n }\n}\n\n/**\n * Accept an incoming offer as the responding peer. Returns the SDP\n * answer to send back to the initiator and a promise for the opened\n * `PeerChannel`.\n */\nexport async function acceptOffer(\n offer: SessionDescription,\n opts: WebRTCOptions = {},\n): Promise<Responder> {\n const RTC = requireRTC()\n const pc = new RTC({\n ...(opts.iceServers && { iceServers: opts.iceServers }),\n })\n\n const channelPromise = new Promise<PeerChannel>((resolve, reject) => {\n pc.addEventListener('datachannel', (ev) => {\n const dc = ev.channel\n if (dc.readyState === 'open') {\n resolve(fromDataChannel(dc))\n } else {\n dc.addEventListener('open', () => resolve(fromDataChannel(dc)))\n dc.addEventListener('error', () => reject(new Error('DataChannel error')))\n }\n })\n })\n\n await pc.setRemoteDescription(offer)\n const answer = await pc.createAnswer()\n await pc.setLocalDescription(answer)\n await waitForIceComplete(pc)\n\n return {\n answer: pc.localDescription ?? answer,\n connection: pc,\n channel: channelPromise,\n }\n}\n\n/**\n * Resolve once ICE gathering reaches `'complete'`. Using non-trickle ICE\n * keeps the signaling exchange to a single round-trip — at the cost of\n * a slightly longer initial handshake. Consumers that want trickle ICE\n * can bypass this helper and drive `RTCPeerConnection` directly.\n */\nfunction waitForIceComplete(pc: PeerConnection): Promise<void> {\n if (pc.iceGatheringState === 'complete') return Promise.resolve()\n return new Promise((resolve) => {\n const check = () => {\n if (pc.iceGatheringState === 'complete') {\n pc.removeEventListener('icegatheringstatechange', check)\n resolve()\n }\n }\n pc.addEventListener('icegatheringstatechange', check)\n })\n}\n","/**\n * **@noy-db/by-peer** real-time {@link NoydbMesh} — the shared\n * drain-barrier core for every `by-*` transport (#469).\n *\n * The kernel defines the {@link NoydbMesh} port; the store default\n * polls `_meta/schema-fence`, but a real-time app can inject this push-based\n * provider so a schema cutover fences **instantly** instead of via store\n * polling. The migration cutover and `@klum-db/lobby` both drive the same port\n * through `mesh`, so neither names a `by-*` package.\n *\n * This module owns the transport-agnostic logic for ANY {@link PeerChannel}:\n * `@noy-db/by-tabs` (BroadcastChannel) delegates to {@link channelMesh}\n * here rather than duplicating the protocol, and the WebRTC peer path uses\n * {@link byPeer} (the same factory, by its by-peer public name).\n *\n * ## Wire protocol\n *\n * Payloads are JSON strings (the `PeerChannel` is string-only). Every envelope\n * carries a `k` discriminator, namespaced `co-*` so it never collides with a\n * write-relay sharing the same channel:\n *\n * ```jsonc\n * { \"k\": \"co-fence\", \"vault\": \"acme\", \"fence\": { ... } }\n * { \"k\": \"co-presence\", \"vault\": \"acme\", \"presence\": { ... } }\n * ```\n *\n * ## Local-echo\n *\n * A `PeerChannel` does **not** echo a post back to the sender (neither\n * BroadcastChannel nor a WebRTC DataChannel does), so every local op\n * (`setFence`, `reportPresence`) updates this provider's own in-memory state\n * and notifies its own subscribers *before* broadcasting. Remote ends converge\n * via the `message` listener.\n *\n * @module\n */\n\nimport type { PeerChannel } from './channel.js'\nimport type { NoydbMesh, FenceDoc, WriterPresence } from '@noy-db/hub/by'\n\n/** Default fence when a vault has never been fenced. */\nconst DEFAULT_FENCE: FenceDoc = { currentSchemaVersion: 0, fenceState: 'normal' }\n\n/**\n * Default presence horizon used when pruning before an `observePresence` emit\n * (`reachableWriters` is always given an explicit `staleMs` by the caller). An\n * end that stops heartbeating for longer than this drops out of pushed batches.\n */\nconst DEFAULT_STALE_MS = 30_000\n\ntype FenceEnvelope = { readonly k: 'co-fence'; readonly vault: string; readonly fence: FenceDoc }\ntype PresenceEnvelope = {\n readonly k: 'co-presence'\n readonly vault: string\n readonly presence: WriterPresence\n}\ntype Envelope = FenceEnvelope | PresenceEnvelope\n\ninterface VaultState {\n fence: FenceDoc\n readonly presence: Map<string, WriterPresence>\n readonly fenceObservers: Set<(f: FenceDoc) => void>\n readonly presenceObservers: Set<(w: readonly WriterPresence[]) => void>\n}\n\n/**\n * Build a real-time {@link NoydbMesh} backed by any\n * {@link PeerChannel} — the shared core for every `by-*` transport.\n *\n * The provider owns its in-memory fence + presence maps per vault and keeps\n * them in sync with every other end on the channel. Pass it as\n * `createNoydb({ mesh: channelMesh(ch) })`, or drive it\n * directly via `runDrainBarrier`.\n *\n * `@noy-db/by-tabs` re-exports this under the name `byTabs`, and\n * by-peer exposes the same factory as {@link byPeer}.\n */\nexport function channelMesh(channel: PeerChannel): NoydbMesh {\n const vaults = new Map<string, VaultState>()\n\n function stateFor(vault: string): VaultState {\n let s = vaults.get(vault)\n if (!s) {\n s = {\n fence: DEFAULT_FENCE,\n presence: new Map(),\n fenceObservers: new Set(),\n presenceObservers: new Set(),\n }\n vaults.set(vault, s)\n }\n return s\n }\n\n function notifyFence(s: VaultState): void {\n for (const cb of [...s.fenceObservers]) cb(s.fence)\n }\n\n /**\n * Prune a vault's presence map to writers seen within `staleMs` of `now`,\n * deleting stale entries from the canonical map. Callers pass their own\n * authoritative clock — never a wall clock — so a synthetic test clock and a\n * real `Date.now()` heartbeat stay consistent.\n */\n function prune(s: VaultState, now: number, staleMs: number): WriterPresence[] {\n const live: WriterPresence[] = []\n for (const [id, p] of [...s.presence]) {\n if (now - p.lastSeen <= staleMs) live.push(p)\n else s.presence.delete(id)\n }\n return live\n }\n\n function notifyPresence(s: VaultState): void {\n if (s.presenceObservers.size === 0) return\n // Best-effort prune for the *pushed batch* so an end that stopped\n // heartbeating drops out, but DON'T mutate the canonical map here: emits\n // happen on the provider's local clock, while `reportPresence` callers may\n // stamp `lastSeen` with a synthetic clock. Authoritative deletion belongs to\n // `reachableWriters`, which is handed the caller's clock + staleMs.\n const newest = Math.max(...[...s.presence.values()].map((p) => p.lastSeen))\n const horizon = newest - DEFAULT_STALE_MS\n const live = [...s.presence.values()].filter((p) => p.lastSeen >= horizon)\n for (const cb of [...s.presenceObservers]) cb(live)\n }\n\n function send(env: Envelope): void {\n if (!channel.isOpen) return\n try {\n channel.send(JSON.stringify(env))\n } catch {\n // Channel torn down mid-flight — local state already updated; drop.\n }\n }\n\n channel.on('message', (raw: string) => {\n let env: Envelope\n try {\n const parsed = JSON.parse(raw) as { k?: unknown }\n if (parsed.k !== 'co-fence' && parsed.k !== 'co-presence') return\n env = parsed as Envelope\n } catch {\n return // not our message / malformed JSON\n }\n\n if (env.k === 'co-fence') {\n const s = stateFor(env.vault)\n s.fence = env.fence\n notifyFence(s)\n } else {\n const s = stateFor(env.vault)\n s.presence.set(env.presence.writerId, env.presence)\n notifyPresence(s)\n }\n })\n\n return {\n async setFence(vault: string, fence: FenceDoc): Promise<void> {\n const s = stateFor(vault)\n s.fence = fence\n notifyFence(s)\n send({ k: 'co-fence', vault, fence })\n },\n\n async readFence(vault: string): Promise<FenceDoc> {\n return vaults.get(vault)?.fence ?? DEFAULT_FENCE\n },\n\n observeFence(vault: string, onChange: (f: FenceDoc) => void): () => void {\n const s = stateFor(vault)\n s.fenceObservers.add(onChange)\n return () => s.fenceObservers.delete(onChange)\n },\n\n async reportPresence(vault: string, p: WriterPresence): Promise<void> {\n const s = stateFor(vault)\n s.presence.set(p.writerId, p)\n notifyPresence(s)\n send({ k: 'co-presence', vault, presence: p })\n },\n\n observePresence(vault: string, onChange: (w: readonly WriterPresence[]) => void): () => void {\n const s = stateFor(vault)\n s.presenceObservers.add(onChange)\n return () => s.presenceObservers.delete(onChange)\n },\n\n async reachableWriters(\n vault: string,\n o: { staleMs: number; now: number },\n ): Promise<readonly WriterPresence[]> {\n const s = vaults.get(vault)\n if (!s) return []\n return prune(s, o.now, o.staleMs)\n },\n }\n}\n\n/**\n * by-peer's public name for the shared {@link channelMesh} core — a\n * real-time {@link NoydbMesh} over a WebRTC `PeerChannel` (or any\n * other `PeerChannel`). Inject via\n * `createNoydb({ mesh: byPeer(ch) })`.\n *\n * It is the *same* factory as {@link channelMesh}; both names are\n * exported so callers can pick the one that reads best at the call site.\n */\nexport const byPeer: (channel: PeerChannel) => NoydbMesh = channelMesh\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/channel.ts","../src/rpc.ts","../src/peer-store.ts","../src/serve.ts","../src/serve-multi.ts","../src/webrtc.ts","../src/coordination.ts"],"sourcesContent":["/**\n * `PeerChannel` — the minimal duplex message primitive used by the p2p\n * NoydbStore wrapper. Any transport that can deliver UTF-8 strings\n * reliably and in-order qualifies: WebRTC DataChannel, BroadcastChannel,\n * MessagePort, WebSocket, even postMessage pairs.\n *\n * Keeping the transport abstract has three payoffs:\n *\n * 1. **Tests run without a WebRTC polyfill.** `pairInMemory()` returns\n * two wired channels for conformance tests against `to-memory`.\n * 2. **Consumers pick their signaling story.** Matrix rooms, QR codes,\n * pastebin, Firebase Realtime DB — the handshake is out of scope.\n * 3. **Future transports slot in cheaply.** WebTransport (HTTP/3),\n * libp2p, Iroh, or a plain relay WebSocket become additional\n * bindings without touching the RPC layer.\n *\n * @module\n */\n\n/**\n * Minimal duplex message primitive.\n *\n * Implementations MUST deliver every `send` payload in order exactly\n * once to every live `on('message')` subscriber. `close()` is best-effort\n * — once called, further `send()` calls MAY throw and `on('close')` MUST\n * fire once.\n */\nexport interface PeerChannel {\n /** Enqueue a payload for delivery to the remote end. */\n send(payload: string): void\n /** Subscribe to incoming payloads or lifecycle events. Returns unsubscribe. */\n on(event: 'message', listener: (payload: string) => void): () => void\n on(event: 'close', listener: () => void): () => void\n /** Close the channel. Idempotent. */\n close(): void\n /** True once the channel is ready for `send`. */\n readonly isOpen: boolean\n}\n\n/**\n * Create a pair of in-memory `PeerChannel`s wired to each other.\n * Intended for tests and multi-tab simulations inside a single process.\n */\nexport function pairInMemory(): [PeerChannel, PeerChannel] {\n type Listeners = {\n message: Set<(p: string) => void>\n close: Set<() => void>\n }\n\n function make(): { ch: PeerChannel; listeners: Listeners; closed: { v: boolean } } {\n const listeners: Listeners = { message: new Set(), close: new Set() }\n const closed = { v: false }\n const ch: PeerChannel = {\n get isOpen() {\n return !closed.v\n },\n send() {\n // Placeholder — wired below.\n },\n on(event: 'message' | 'close', listener: ((payload: string) => void) | (() => void)): () => void {\n if (event === 'message') {\n listeners.message.add(listener as (p: string) => void)\n return () => listeners.message.delete(listener as (p: string) => void)\n }\n listeners.close.add(listener as () => void)\n return () => listeners.close.delete(listener as () => void)\n },\n close() {\n if (closed.v) return\n closed.v = true\n for (const fn of listeners.close) fn()\n },\n } as PeerChannel\n return { ch, listeners, closed }\n }\n\n const a = make()\n const b = make()\n\n function closeBoth(): void {\n if (!a.closed.v) {\n a.closed.v = true\n for (const fn of a.listeners.close) fn()\n }\n if (!b.closed.v) {\n b.closed.v = true\n for (const fn of b.listeners.close) fn()\n }\n }\n\n a.ch.send = (payload) => {\n if (b.closed.v) throw new Error('PeerChannel closed')\n queueMicrotask(() => {\n for (const fn of b.listeners.message) fn(payload)\n })\n }\n b.ch.send = (payload) => {\n if (a.closed.v) throw new Error('PeerChannel closed')\n queueMicrotask(() => {\n for (const fn of a.listeners.message) fn(payload)\n })\n }\n a.ch.close = closeBoth\n b.ch.close = closeBoth\n\n return [a.ch, b.ch]\n}\n\n/**\n * Wrap a WebRTC `RTCDataChannel` as a `PeerChannel`.\n *\n * Browser-only — the caller is responsible for establishing the\n * `RTCPeerConnection`, exchanging SDP offers/answers out of band, and\n * passing the opened DataChannel here. When the remote peer is only\n * reachable via TURN, the relay sees DTLS-wrapped ciphertext (noy-db\n * already encrypts at rest, so even a TURN compromise leaks nothing).\n */\nexport function fromDataChannel(dc: RTCDataChannel): PeerChannel {\n type Listeners = {\n message: Set<(p: string) => void>\n close: Set<() => void>\n }\n const listeners: Listeners = { message: new Set(), close: new Set() }\n let closed = false\n\n dc.addEventListener('message', (ev: MessageEvent) => {\n if (typeof ev.data !== 'string') return\n for (const fn of listeners.message) fn(ev.data)\n })\n dc.addEventListener('close', () => {\n if (closed) return\n closed = true\n for (const fn of listeners.close) fn()\n })\n\n return {\n get isOpen() {\n return !closed && dc.readyState === 'open'\n },\n send(payload) {\n if (closed || dc.readyState !== 'open') {\n throw new Error(`PeerChannel not open (readyState: ${dc.readyState})`)\n }\n dc.send(payload)\n },\n on(event: 'message' | 'close', listener: ((payload: string) => void) | (() => void)): () => void {\n if (event === 'message') {\n listeners.message.add(listener as (p: string) => void)\n return () => listeners.message.delete(listener as (p: string) => void)\n }\n listeners.close.add(listener as () => void)\n return () => listeners.close.delete(listener as () => void)\n },\n close() {\n if (closed) return\n closed = true\n try {\n dc.close()\n } catch {\n // ignore — channel already torn down\n }\n for (const fn of listeners.close) fn()\n },\n } as PeerChannel\n}\n","/**\n * JSON-RPC protocol over a `PeerChannel`.\n *\n * Request shape:\n * `{ t: 'req', id, method, args, auth? }`\n * Response shape (success):\n * `{ t: 'res', id, ok: true, result }`\n * Response shape (error):\n * `{ t: 'res', id, ok: false, error: { name, message, version? } }`\n *\n * Why not reuse msgpack/protobuf? The payloads are already base64-encoded\n * ciphertext — further binary packing saves ~8-12% at a large dependency\n * cost. JSON over UTF-8 is inspectable, fits the zero-dependency ethos,\n * and WebRTC DataChannel string mode already frames for us.\n *\n * @module\n */\n\nimport type { PeerChannel } from './channel.js'\n\n/** Wire format discriminator for RPC messages. */\nexport type RpcMessage = RpcRequest | RpcResponse\n\nexport interface RpcRequest {\n readonly t: 'req'\n readonly id: string\n readonly method: string\n readonly args: readonly unknown[]\n /**\n * Bearer token from the invite (milestone 52). Additive and optional ON THE\n * WIRE — an older client simply omits it — but a fail-closed server refuses\n * a request without one, which is the point: holding the channel is no\n * longer the credential.\n */\n readonly auth?: string\n}\n\nexport interface RpcResponse {\n readonly t: 'res'\n readonly id: string\n readonly ok: boolean\n readonly result?: unknown\n readonly error?: { name: string; message: string; version?: number }\n}\n\n/**\n * Handler invoked when an RPC request arrives. Return value is serialized as\n * `result`. `auth` is the bearer token the caller presented, or `undefined`\n * when it presented none — the handler decides, not this layer.\n */\nexport type RpcHandler = (\n method: string,\n args: readonly unknown[],\n auth?: string,\n) => Promise<unknown>\n\n/** Options for a client-side RPC caller. */\nexport interface RpcClientOptions {\n /** Max milliseconds to wait for a response before rejecting. */\n timeoutMs?: number\n /** Bearer token from the invite, presented on every request (milestone 52). */\n token?: string\n}\n\n/**\n * Drop trailing `undefined` arguments before serialisation (#1026).\n *\n * JSON has no `undefined`: `JSON.stringify([a, undefined])` produces\n * `[a, null]`. For an argument whose absence is meaningful that is not a\n * lossless round-trip, it is a REWRITE. `NoydbStore.put`'s\n * `expectedVersion?: number` is exactly such an argument — `undefined` means\n * \"do not compare-and-set\", while a store's guard (`expectedVersion !== undefined`)\n * treats the `null` that arrives as a real assertion, one no existing record can\n * satisfy. Every overwrite through a peer store therefore failed with\n * `expected null, found <n>`, making the whole topology look read-only.\n *\n * Trimming from the end is safe because a hole in the middle cannot occur: the\n * store contract only ever has optional parameters last, and an explicitly\n * passed `undefined` in a trailing slot is indistinguishable from omission by\n * definition.\n */\nfunction trimTrailingUndefined(args: readonly unknown[]): readonly unknown[] {\n let end = args.length\n while (end > 0 && args[end - 1] === undefined) end--\n return end === args.length ? args : args.slice(0, end)\n}\n\n/** Client: wrap a `PeerChannel` in a `call(method, args)` helper. */\nexport function createRpcClient(channel: PeerChannel, opts: RpcClientOptions = {}) {\n const timeoutMs = opts.timeoutMs ?? 30_000\n type Pending = {\n resolve: (v: unknown) => void\n reject: (err: Error) => void\n timer: ReturnType<typeof setTimeout>\n }\n const pending = new Map<string, Pending>()\n let counter = 0\n\n const offMessage = channel.on('message', (payload) => {\n let msg: RpcMessage\n try {\n msg = JSON.parse(payload) as RpcMessage\n } catch {\n return\n }\n if (msg.t !== 'res') return\n const entry = pending.get(msg.id)\n if (!entry) return\n clearTimeout(entry.timer)\n pending.delete(msg.id)\n if (msg.ok) {\n entry.resolve(msg.result)\n } else {\n const e = msg.error ?? { name: 'Error', message: 'unknown remote error' }\n const err = new Error(e.message)\n err.name = e.name\n if (typeof e.version === 'number') {\n ;(err as Error & { version?: number }).version = e.version\n }\n entry.reject(err)\n }\n })\n\n const offClose = channel.on('close', () => {\n for (const [, entry] of pending) {\n clearTimeout(entry.timer)\n entry.reject(new Error('PeerChannel closed before response'))\n }\n pending.clear()\n })\n\n return {\n async call<T = unknown>(method: string, args: readonly unknown[]): Promise<T> {\n const id = `${Date.now().toString(36)}-${(++counter).toString(36)}`\n const req: RpcRequest = {\n t: 'req', id, method, args: trimTrailingUndefined(args),\n ...(opts.token !== undefined && { auth: opts.token }),\n }\n return new Promise<T>((resolve, reject) => {\n const timer = setTimeout(() => {\n pending.delete(id)\n reject(new Error(`RPC ${method} timed out after ${timeoutMs}ms`))\n }, timeoutMs)\n pending.set(id, {\n resolve: resolve as (v: unknown) => void,\n reject,\n timer,\n })\n try {\n channel.send(JSON.stringify(req))\n } catch (err) {\n clearTimeout(timer)\n pending.delete(id)\n reject(err instanceof Error ? err : new Error(String(err)))\n }\n })\n },\n dispose() {\n offMessage()\n offClose()\n for (const [, entry] of pending) {\n clearTimeout(entry.timer)\n entry.reject(new Error('RPC client disposed'))\n }\n pending.clear()\n },\n }\n}\n\n/** Server: dispatch incoming RPC requests through a handler. Returns a dispose fn. */\nexport function serveRpc(channel: PeerChannel, handler: RpcHandler): () => void {\n async function handle(payload: string): Promise<void> {\n let msg: RpcMessage\n try {\n msg = JSON.parse(payload) as RpcMessage\n } catch {\n return\n }\n if (msg.t !== 'req') return\n\n let response: RpcResponse\n try {\n const result = await handler(msg.method, msg.args, msg.auth)\n response = { t: 'res', id: msg.id, ok: true, result }\n } catch (err) {\n const e = err as Error & { version?: number }\n response = {\n t: 'res',\n id: msg.id,\n ok: false,\n error: {\n name: e.name ?? 'Error',\n message: e.message ?? String(err),\n ...(typeof e.version === 'number' && { version: e.version }),\n },\n }\n }\n\n if (!channel.isOpen) return\n try {\n channel.send(JSON.stringify(response))\n } catch {\n // Channel closed mid-response — nothing to do.\n }\n }\n\n const offMessage = channel.on('message', (payload) => {\n void handle(payload)\n })\n\n return () => offMessage()\n}\n","/**\n * `peerStore()` — a `NoydbStore` backed by RPC calls over a `PeerChannel`.\n *\n * The local peer calls `get`/`put`/`delete`/… against this store as if\n * it were any other backend; every call is serialized as an RPC request\n * to the remote peer, which runs `servePeerStore()` to funnel the RPCs\n * into its own local `NoydbStore`.\n *\n * Error re-hydration: the remote handler re-throws `ConflictError` with\n * a `.version` field when a CAS check fails. The RPC layer carries\n * `version` in the error envelope so the local caller can catch\n * `ConflictError` with the same semantics as a direct store call.\n *\n * @module\n */\n\nimport type { NoydbStore, EncryptedEnvelope, VaultSnapshot } from '@noy-db/hub/to'\nimport { ConflictError } from '@noy-db/hub/to'\nimport type { PeerChannel } from './channel.js'\nimport { createRpcClient } from './rpc.js'\n\nexport interface PeerStoreOptions {\n /** The duplex channel to the remote peer. */\n readonly channel: PeerChannel\n /** Max ms to wait for any single RPC response. Default 30s. */\n readonly timeoutMs?: number\n /** Optional display name used in diagnostics. Default `'by-peer'`. */\n readonly name?: string\n /**\n * Bearer token from the invite, presented on every request (milestone 52).\n * The serving peer is fail-closed, so a store created without one is refused.\n */\n readonly token?: string\n}\n\n/**\n * Create a `NoydbStore` that forwards every operation to a remote peer\n * over the supplied `PeerChannel`. The remote peer must be running\n * `servePeerStore()` against its own local store.\n */\nexport function peerStore(opts: PeerStoreOptions): NoydbStore & { dispose: () => void } {\n const rpc = createRpcClient(opts.channel, {\n timeoutMs: opts.timeoutMs ?? 30_000,\n ...(opts.token !== undefined && { token: opts.token }),\n })\n\n async function call<T>(method: string, args: readonly unknown[]): Promise<T> {\n try {\n return await rpc.call<T>(method, args)\n } catch (err) {\n // Re-hydrate ConflictError so CAS semantics survive the wire hop.\n const e = err as Error & { version?: number }\n if (e.name === 'ConflictError' && typeof e.version === 'number') {\n throw new ConflictError(e.version, e.message)\n }\n throw err\n }\n }\n\n return {\n name: opts.name ?? 'by-peer',\n\n async get(vault, collection, id) {\n return call<EncryptedEnvelope | null>('get', [vault, collection, id])\n },\n\n async put(vault, collection, id, envelope, expectedVersion) {\n await call<void>('put', [vault, collection, id, envelope, expectedVersion])\n },\n\n async delete(vault, collection, id) {\n await call<void>('delete', [vault, collection, id])\n },\n\n async list(vault, collection) {\n return call<string[]>('list', [vault, collection])\n },\n\n async loadAll(vault) {\n return call<VaultSnapshot>('loadAll', [vault])\n },\n\n async saveAll(vault, data) {\n await call<void>('saveAll', [vault, data])\n },\n\n async ping() {\n try {\n return await call<boolean>('ping', [])\n } catch {\n return false\n }\n },\n\n dispose() {\n rpc.dispose()\n },\n }\n}\n","/**\n * `servePeerStore()` — runs on the peer that owns the data. Listens on\n * a `PeerChannel` for RPC requests from a remote `peerStore()` client\n * and executes each one against the local `NoydbStore`.\n *\n * The 6 core methods plus the optional `ping` / `listSince` / `listPage`\n * extensions are exposed. Unknown methods surface as a remote Error.\n *\n * **Leader-election (issue #3).** When a `BroadcastChannel`-backed\n * `PeerChannel` is shared by 3+ tabs each running `servePeerStore`,\n * every non-sending tab responds to every RPC — producing duplicate\n * responses for the same request id and O(N²) channel traffic at scale.\n * Pass `leaderElection: { lockName, locks? }` to gate serving on the\n * Web Locks API: only the lock-holding tab registers an RPC handler;\n * others queue and take over when the holder's tab closes (lock auto-\n * releases). Default behaviour (no `leaderElection`) is unchanged for\n * the 2-tab case.\n *\n * @module\n */\n\nimport type { NoydbStore, EncryptedEnvelope, VaultSnapshot } from '@noy-db/hub/to'\nimport type { PeerChannel } from './channel.js'\nimport { serveRpc } from './rpc.js'\n\n/**\n * Minimal subset of the Web Locks API used by leader-election. The\n * browser's `navigator.locks` satisfies this; tests inject a stub.\n *\n * @public\n */\nexport interface MinimalLockManager {\n request<T>(\n name: string,\n options: { mode?: 'exclusive' | 'shared'; signal?: AbortSignal },\n callback: (lock: unknown) => Promise<T>,\n ): Promise<T>\n}\n\n/**\n * Compare in time independent of how many leading characters match.\n *\n * A plain `===` leaks the length of the shared prefix through timing, which\n * over a channel an attacker can call repeatedly is enough to recover a token\n * character by character. Length is not secret here (it is fixed by the invite\n * format), so comparing lengths first is fine; the CONTENT comparison must not\n * short-circuit.\n */\nfunction constantTimeEquals(a: string | undefined, b: string): boolean {\n if (a === undefined || a.length !== b.length) return false\n let diff = 0\n for (let i = 0; i < b.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)\n return diff === 0\n}\n\nexport interface ServePeerStoreOptions {\n /** The duplex channel from the remote peer. */\n readonly channel: PeerChannel\n /** The local store to serve. */\n readonly store: NoydbStore\n /**\n * Bearer token from the invite. Every request must present it (milestone 52).\n *\n * ⚠️ FAIL-CLOSED: when this is omitted, EVERY request is refused. That\n * mirrors `in-rest`, where no `authorize` means 401 on everything, and it is\n * a deliberate reversal — this function used to serve the whole store to\n * anyone who reached the channel.\n *\n * Holding the channel used to BE the credential, which is defensible for a\n * 1:1 invite-based session share. It stops being defensible the moment a\n * channel stops meaning \"I invited this person\" — which is exactly what a\n * multi-peer topology does.\n */\n readonly token?: string\n /**\n * Optional method whitelist. When provided, any method not in the set\n * is rejected with \"method not allowed\". Useful for read-only peers.\n *\n * NOT authentication, and never was: it says which of the six methods may be\n * called, not by whom. It composes with `token` rather than replacing it.\n */\n readonly allow?: ReadonlySet<string>\n /**\n * Opt in to leader-election semantics for cross-tab coordination via\n * the Web Locks API. Required when 3+ tabs share a `BroadcastChannel`-\n * backed `PeerChannel` — without leader election, every non-sending tab\n * responds to every RPC, producing duplicate responses (issue #3).\n *\n * Browser support: Chrome 69+, Firefox 96+, Safari 15.4+.\n *\n * - `lockName` — globally unique lock name. Convention:\n * `noy-db:peer-server:<channel-name>`. The same `lockName` MUST be\n * used by every tab that wants to share the leader role.\n * - `locks` — defaults to `navigator.locks` in browsers. Pass a stub\n * for tests or for non-browser hosts that polyfill the Web Locks API.\n *\n * Behaviour: at most one tab holds the lock at a time. Only that tab\n * registers an RPC handler on the channel; other tabs queue (their\n * `servePeerStore` call returns a working dispose function but does\n * not respond to incoming RPCs). When the holder's tab closes (or its\n * `servePeerStore` is disposed), the next queued tab takes over.\n */\n readonly leaderElection?: {\n readonly lockName: string\n readonly locks?: MinimalLockManager\n }\n}\n\nconst CORE_METHODS = new Set<string>([\n 'get',\n 'put',\n 'delete',\n 'list',\n 'loadAll',\n 'saveAll',\n 'ping',\n 'listSince',\n 'listPage',\n 'listVaults',\n])\n\n/**\n * Start serving the local store on the channel. Returns a dispose\n * function that stops the RPC listener. The underlying channel is NOT\n * closed by dispose — ownership stays with the caller.\n *\n * When `leaderElection` is set, the RPC handler is only registered while\n * this tab holds the named Web Lock. The returned dispose function\n * always works: if called before the lock is acquired, it cancels the\n * wait; if called while holding the lock, it releases the lock and\n * stops serving.\n */\nexport function servePeerStore(opts: ServePeerStoreOptions): () => void {\n if (!opts.leaderElection) {\n return startServing(opts)\n }\n return startServingWithLeaderElection(opts)\n}\n\n/**\n * Register the RPC handler immediately. Used when leader-election is off,\n * and inside the lock callback when on.\n */\nfunction startServing(opts: ServePeerStoreOptions): () => void {\n const { store, channel, allow, token } = opts\n\n return serveRpc(channel, async (method, args, auth) => {\n // Fail-closed, and BEFORE anything else — a refusal that ran after the\n // store call would be worse than no refusal: the caller sees an error and\n // the write landed anyway.\n if (token === undefined || !constantTimeEquals(auth, token)) {\n throw new Error(\n 'Unauthorized: this peer store requires the bearer token from the invite. ' +\n 'Pass it as `servePeerStore({ token })` on the server and `peerStore({ token })` ' +\n 'on the client. A server with no token configured serves nobody, by design.',\n )\n }\n if (!CORE_METHODS.has(method)) {\n throw new Error(`Unknown RPC method: ${method}`)\n }\n if (allow && !allow.has(method)) {\n throw new Error(`Method not allowed: ${method}`)\n }\n\n switch (method) {\n case 'get': {\n const [vault, collection, id] = args as [string, string, string]\n return store.get(vault, collection, id)\n }\n case 'put': {\n const [vault, collection, id, envelope, expectedVersion] = args as [\n string,\n string,\n string,\n EncryptedEnvelope,\n number | null | undefined,\n ]\n // #1026 — `null` is not a legal `expectedVersion` (the contract types it\n // `number | undefined`), so anything that arrives as `null` came from a\n // JSON round-trip of `undefined` and means \"do not compare-and-set\".\n // Normalising here as well as on the client keeps a peer running an\n // older by-peer from silently failing every overwrite: a store's guard\n // is `!== undefined`, which `null` would pass, turning \"no check\" into\n // an assertion no existing record can satisfy.\n await store.put(vault, collection, id, envelope, expectedVersion ?? undefined)\n return null\n }\n case 'delete': {\n const [vault, collection, id] = args as [string, string, string]\n await store.delete(vault, collection, id)\n return null\n }\n case 'list': {\n const [vault, collection] = args as [string, string]\n return store.list(vault, collection)\n }\n case 'loadAll': {\n const [vault] = args as [string]\n return store.loadAll(vault)\n }\n case 'saveAll': {\n const [vault, data] = args as [string, VaultSnapshot]\n await store.saveAll(vault, data)\n return null\n }\n case 'ping': {\n if (!store.ping) return true\n return store.ping()\n }\n case 'listSince': {\n if (!store.listSince) throw new Error('listSince not supported by remote store')\n const [vault, collection, since] = args as [string, string, string]\n return store.listSince(vault, collection, since)\n }\n case 'listPage': {\n if (!store.listPage) throw new Error('listPage not supported by remote store')\n const [vault, collection, cursor, limit] = args as [\n string,\n string,\n string | undefined,\n number | undefined,\n ]\n return store.listPage(vault, collection, cursor, limit)\n }\n case 'listVaults': {\n if (!store.listVaults) throw new Error('listVaults not supported by remote store')\n return store.listVaults()\n }\n }\n /* istanbul ignore next — CORE_METHODS gate makes this unreachable */\n throw new Error(`Unhandled method: ${method}`)\n })\n}\n\n/**\n * Acquire a Web Lock first; only register the RPC listener while\n * holding it. Returns a dispose that cancels the wait (if not yet\n * acquired) or releases the lock (if held).\n */\nfunction startServingWithLeaderElection(opts: ServePeerStoreOptions): () => void {\n // Asserted by the caller branch in `servePeerStore`.\n const leader = opts.leaderElection as { lockName: string; locks?: MinimalLockManager }\n const locks = leader.locks ?? getDefaultLocks()\n const ac = new AbortController()\n let stopServing: (() => void) | null = null\n let releaseLock: (() => void) | null = null\n let disposed = false\n\n void locks\n .request(leader.lockName, { mode: 'exclusive', signal: ac.signal }, () => {\n if (disposed) return Promise.resolve()\n stopServing = startServing(opts)\n // Hold the lock for the lifetime of this tab's leadership. The\n // returned promise stays pending until `releaseLock` is called by\n // the dispose function.\n return new Promise<void>((resolve) => {\n releaseLock = resolve\n })\n })\n .catch((err: unknown) => {\n // AbortError is the expected cancellation when dispose runs before\n // the lock was acquired. Anything else is unexpected — surface it\n // at the next tick so the host can observe it.\n if ((err as { name?: string } | null)?.name === 'AbortError') return\n queueMicrotask(() => {\n throw err\n })\n })\n\n return () => {\n if (disposed) return\n disposed = true\n if (stopServing) {\n stopServing()\n stopServing = null\n }\n if (releaseLock) {\n releaseLock()\n releaseLock = null\n } else {\n ac.abort()\n }\n }\n}\n\nfunction getDefaultLocks(): MinimalLockManager {\n const nav = (globalThis as { navigator?: { locks?: MinimalLockManager } }).navigator\n if (nav && nav.locks) return nav.locks\n throw new Error(\n 'leaderElection requires the Web Locks API (navigator.locks). ' +\n 'Use Chrome 69+, Firefox 96+, Safari 15.4+, or pass `leaderElection.locks: <stub>` ' +\n 'for tests / non-browser hosts.',\n )\n}\n","/**\n * `serveMultiPeerStore()` — the hub of a star: one browser peer accepting\n * N invites instead of 1 (#1239).\n *\n * Mostly assembly. Each accepted invite is exactly one `servePeerStore`\n * instance — its own channel, its own token, its own `allow` set — so\n * everything `servePeerStore` guarantees for the 1:1 case holds per peer here.\n * What this module adds is the part that is NOT assembly: **a token per peer.**\n *\n * - **Revocation is per peer.** Dropping one invite stops serving that peer\n * and nobody else; a single shared token could not express that.\n * - **Fail-closed survives the widening.** `accept()` requires a non-empty\n * token, checked at runtime as well as by type, so a multi-peer API can\n * never turn \"no token\" into \"any peer\" — the pre-#1236 behaviour arriving\n * by another route.\n * - **A token is bound to the channel it was accepted on.** Presenting peer\n * A's token on peer B's channel is refused, because B's instance only knows\n * B's token. A token that is already live is refused at `accept()`: one\n * token, one peer, or revocation and attribution stop meaning anything.\n *\n * ## What the hub peer can and cannot see\n *\n * Star topology makes this peer a single point of failure and of trust. Every\n * invited peer's records pass through it — **as ciphertext.** Hub encrypts\n * before any store is called, so the serving peer holds envelopes it cannot\n * open unless it also holds a keyring for that vault (which, as the vault's\n * own owner, it normally does — this is a session share, not an escrow). What\n * it CAN observe regardless is metadata: which channel called which method\n * on which vault/collection/id, and when.\n *\n * ## Attribution is a decision, not a default\n *\n * With one token \"who wrote this\" was unanswerable; with a token per peer it\n * becomes answerable, and therefore a choice. This module records **nothing**\n * — it holds `(channel, token)` pairs and no history. A host that wants\n * attribution passes a per-peer decorated `store` in `accept()` and owns the\n * privacy consequences of what it logs.\n *\n * ## No leader election here, deliberately\n *\n * `servePeerStore`'s `leaderElection` exists for a `BroadcastChannel`-backed\n * channel shared by many tabs of ONE origin. An invite is the opposite shape:\n * one channel, one remote peer. A star of invites lives in one tab; if that\n * tab closes, the invites close with it and the peers reconnect through a new\n * one. Layering Web Locks over N distinct channels would need one lock per\n * channel and a story for what a \"logical server\" is across them — not\n * something a session share needs, and out of scope per the milestone\n * (catalog, locator and discovery belong to the daemon).\n *\n * @module\n */\n\nimport type { NoydbStore } from '@noy-db/hub/to'\nimport type { PeerChannel } from './channel.js'\nimport { servePeerStore } from './serve.js'\n\n/** One invited peer. */\nexport interface PeerInvite {\n /** The duplex channel to this one peer. */\n readonly channel: PeerChannel\n /**\n * Bearer token from THIS peer's invite. Required and non-empty; the same\n * token cannot be live on two channels at once.\n */\n readonly token: string\n /**\n * Per-peer method whitelist (e.g. a read-only peer). Overrides the server\n * default for this peer only. NOT authentication — it composes with `token`.\n */\n readonly allow?: ReadonlySet<string>\n /**\n * Serve this peer from a different store — the seam for per-peer\n * attribution or metering decorators. Defaults to the server's store.\n */\n readonly store?: NoydbStore\n}\n\nexport interface MultiPeerServerOptions {\n /** The local store every accepted peer is served from, unless an invite overrides it. */\n readonly store: NoydbStore\n /** Default method whitelist for every accepted peer. */\n readonly allow?: ReadonlySet<string>\n}\n\nexport interface MultiPeerServer {\n /**\n * Admit one invited peer. Returns a revoke function for exactly that peer;\n * calling it stops serving that channel and leaves every other peer alone.\n * Idempotent. The channel itself is NOT closed — ownership stays with the\n * caller, as with `servePeerStore`'s dispose.\n *\n * Throws if the token is missing or empty (fail-closed), if that token is\n * already live on another channel, or after `dispose()`.\n */\n accept(invite: PeerInvite): () => void\n /** Peers currently being served. */\n readonly size: number\n /** Revoke every peer. Idempotent; `accept()` refuses afterwards. */\n dispose(): void\n}\n\nexport function serveMultiPeerStore(opts: MultiPeerServerOptions): MultiPeerServer {\n // token -> revoke. The token is the key because it is what an invite IS;\n // the channel is where it is honoured.\n const peers = new Map<string, () => void>()\n let disposed = false\n\n return {\n accept(invite) {\n if (disposed) throw new Error('serveMultiPeerStore: this server is disposed and accepts no more invites')\n const token = invite?.token\n if (typeof token !== 'string' || token.length === 0) {\n throw new Error(\n 'serveMultiPeerStore.accept: an invite needs a non-empty bearer token. ' +\n 'A multi-peer server never serves an unauthenticated channel — \"no token\" ' +\n 'must not become \"any peer\".',\n )\n }\n if (peers.has(token)) {\n throw new Error(\n 'serveMultiPeerStore.accept: that token is already live on another channel. ' +\n 'One token, one peer — issue a fresh invite (or revoke the existing peer first).',\n )\n }\n\n const allow = invite.allow ?? opts.allow\n const stop = servePeerStore({\n channel: invite.channel,\n store: invite.store ?? opts.store,\n token,\n ...(allow !== undefined && { allow }),\n })\n\n let live = true\n const revoke = (): void => {\n if (!live) return\n live = false\n stop()\n offClose()\n peers.delete(token)\n }\n // A closed channel is a departed peer: drop it so its token can be\n // issued again and `size` tells the truth.\n const offClose = invite.channel.on('close', revoke)\n peers.set(token, revoke)\n return revoke\n },\n\n get size() {\n return peers.size\n },\n\n dispose() {\n if (disposed) return\n disposed = true\n for (const revoke of [...peers.values()]) revoke()\n },\n }\n}\n","/**\n * WebRTC handshake helper — opinionated wrapper around\n * `RTCPeerConnection` that produces a ready-to-use `PeerChannel`.\n *\n * The handshake is split into two halves so the caller can ferry the\n * SDP blobs over whatever signaling channel they prefer (QR code,\n * Matrix room, pastebin, Firebase, signed URL…). Signaling is\n * intentionally out of scope — noy-db has no opinion on how peers\n * discover each other, only on what flows once they do.\n *\n * ```ts\n * // Peer A (initiator)\n * const a = await createOffer({ iceServers })\n * send(a.offer) // → signaling channel\n * const answer = await receive() // ← signaling channel\n * await a.accept(answer)\n * const channel = await a.channel // ready PeerChannel\n *\n * // Peer B (responder)\n * const offer = await receive() // ← signaling channel\n * const b = await acceptOffer(offer, { iceServers })\n * send(b.answer) // → signaling channel\n * const channel = await b.channel // ready PeerChannel\n * ```\n *\n * Browser-only. Node consumers who want to interconnect with browsers\n * can plug `@roamhq/wrtc` into the global `RTCPeerConnection` slot; this\n * module does not pull it in so the package has zero runtime deps.\n *\n * @module\n */\n\nimport type { PeerChannel } from './channel.js'\nimport { fromDataChannel } from './channel.js'\n\ntype PeerConnection = RTCPeerConnection\ntype SessionDescription = RTCSessionDescriptionInit\n\nexport interface WebRTCOptions {\n /** Optional ICE servers (STUN / TURN). */\n readonly iceServers?: RTCIceServer[]\n /** Label for the `RTCDataChannel`. Default `'noydb'`. */\n readonly label?: string\n}\n\nexport interface Initiator {\n readonly offer: SessionDescription\n /** Feed in the remote peer's SDP answer to complete the handshake. */\n accept(answer: SessionDescription): Promise<void>\n /** Resolves with the opened `PeerChannel` once the DataChannel is live. */\n readonly channel: Promise<PeerChannel>\n readonly connection: PeerConnection\n}\n\nexport interface Responder {\n readonly answer: SessionDescription\n /** Resolves with the opened `PeerChannel` once the DataChannel is live. */\n readonly channel: Promise<PeerChannel>\n readonly connection: PeerConnection\n}\n\nfunction requireRTC(): typeof RTCPeerConnection {\n const g = globalThis as { RTCPeerConnection?: typeof RTCPeerConnection }\n if (!g.RTCPeerConnection) {\n throw new Error(\n '[@noy-db/by-peer] globalThis.RTCPeerConnection is undefined — use this module in a browser, or polyfill with @roamhq/wrtc in Node',\n )\n }\n return g.RTCPeerConnection\n}\n\n/**\n * Build an offer as the initiating peer. Returns the SDP offer to send\n * to the remote peer and a promise for the opened `PeerChannel`.\n */\nexport async function createOffer(opts: WebRTCOptions = {}): Promise<Initiator> {\n const RTC = requireRTC()\n const pc = new RTC({\n ...(opts.iceServers && { iceServers: opts.iceServers }),\n })\n\n const dc = pc.createDataChannel(opts.label ?? 'noydb', {\n ordered: true,\n })\n\n const channelPromise = new Promise<PeerChannel>((resolve, reject) => {\n dc.addEventListener('open', () => resolve(fromDataChannel(dc)))\n dc.addEventListener('error', () => reject(new Error('DataChannel error')))\n })\n\n const offer = await pc.createOffer()\n await pc.setLocalDescription(offer)\n await waitForIceComplete(pc)\n\n return {\n offer: pc.localDescription ?? offer,\n connection: pc,\n channel: channelPromise,\n async accept(answer) {\n await pc.setRemoteDescription(answer)\n },\n }\n}\n\n/**\n * Accept an incoming offer as the responding peer. Returns the SDP\n * answer to send back to the initiator and a promise for the opened\n * `PeerChannel`.\n */\nexport async function acceptOffer(\n offer: SessionDescription,\n opts: WebRTCOptions = {},\n): Promise<Responder> {\n const RTC = requireRTC()\n const pc = new RTC({\n ...(opts.iceServers && { iceServers: opts.iceServers }),\n })\n\n const channelPromise = new Promise<PeerChannel>((resolve, reject) => {\n pc.addEventListener('datachannel', (ev) => {\n const dc = ev.channel\n if (dc.readyState === 'open') {\n resolve(fromDataChannel(dc))\n } else {\n dc.addEventListener('open', () => resolve(fromDataChannel(dc)))\n dc.addEventListener('error', () => reject(new Error('DataChannel error')))\n }\n })\n })\n\n await pc.setRemoteDescription(offer)\n const answer = await pc.createAnswer()\n await pc.setLocalDescription(answer)\n await waitForIceComplete(pc)\n\n return {\n answer: pc.localDescription ?? answer,\n connection: pc,\n channel: channelPromise,\n }\n}\n\n/**\n * Resolve once ICE gathering reaches `'complete'`. Using non-trickle ICE\n * keeps the signaling exchange to a single round-trip — at the cost of\n * a slightly longer initial handshake. Consumers that want trickle ICE\n * can bypass this helper and drive `RTCPeerConnection` directly.\n */\nfunction waitForIceComplete(pc: PeerConnection): Promise<void> {\n if (pc.iceGatheringState === 'complete') return Promise.resolve()\n return new Promise((resolve) => {\n const check = () => {\n if (pc.iceGatheringState === 'complete') {\n pc.removeEventListener('icegatheringstatechange', check)\n resolve()\n }\n }\n pc.addEventListener('icegatheringstatechange', check)\n })\n}\n","/**\n * **@noy-db/by-peer** real-time {@link NoydbMesh} — the shared\n * drain-barrier core for every `by-*` transport (#469).\n *\n * The kernel defines the {@link NoydbMesh} port; the store default\n * polls `_meta/schema-fence`, but a real-time app can inject this push-based\n * provider so a schema cutover fences **instantly** instead of via store\n * polling. The migration cutover and `@klum-db/lobby` both drive the same port\n * through `mesh`, so neither names a `by-*` package.\n *\n * This module owns the transport-agnostic logic for ANY {@link PeerChannel}:\n * `@noy-db/by-tabs` (BroadcastChannel) delegates to {@link channelMesh}\n * here rather than duplicating the protocol, and the WebRTC peer path uses\n * {@link byPeer} (the same factory, by its by-peer public name).\n *\n * ## Wire protocol\n *\n * Payloads are JSON strings (the `PeerChannel` is string-only). Every envelope\n * carries a `k` discriminator, namespaced `co-*` so it never collides with a\n * write-relay sharing the same channel:\n *\n * ```jsonc\n * { \"k\": \"co-fence\", \"vault\": \"acme\", \"fence\": { ... } }\n * { \"k\": \"co-presence\", \"vault\": \"acme\", \"presence\": { ... } }\n * ```\n *\n * ## Local-echo\n *\n * A `PeerChannel` does **not** echo a post back to the sender (neither\n * BroadcastChannel nor a WebRTC DataChannel does), so every local op\n * (`setFence`, `reportPresence`) updates this provider's own in-memory state\n * and notifies its own subscribers *before* broadcasting. Remote ends converge\n * via the `message` listener.\n *\n * @module\n */\n\nimport type { PeerChannel } from './channel.js'\nimport type { NoydbMesh, FenceDoc, WriterPresence } from '@noy-db/hub/by'\n\n/** Default fence when a vault has never been fenced. */\nconst DEFAULT_FENCE: FenceDoc = { currentSchemaVersion: 0, fenceState: 'normal' }\n\n/**\n * Default presence horizon used when pruning before an `observePresence` emit\n * (`reachableWriters` is always given an explicit `staleMs` by the caller). An\n * end that stops heartbeating for longer than this drops out of pushed batches.\n */\nconst DEFAULT_STALE_MS = 30_000\n\ntype FenceEnvelope = { readonly k: 'co-fence'; readonly vault: string; readonly fence: FenceDoc }\ntype PresenceEnvelope = {\n readonly k: 'co-presence'\n readonly vault: string\n readonly presence: WriterPresence\n}\ntype Envelope = FenceEnvelope | PresenceEnvelope\n\ninterface VaultState {\n fence: FenceDoc\n readonly presence: Map<string, WriterPresence>\n readonly fenceObservers: Set<(f: FenceDoc) => void>\n readonly presenceObservers: Set<(w: readonly WriterPresence[]) => void>\n}\n\n/**\n * Build a real-time {@link NoydbMesh} backed by any\n * {@link PeerChannel} — the shared core for every `by-*` transport.\n *\n * The provider owns its in-memory fence + presence maps per vault and keeps\n * them in sync with every other end on the channel. Pass it as\n * `createNoydb({ mesh: channelMesh(ch) })`, or drive it\n * directly via `runDrainBarrier`.\n *\n * `@noy-db/by-tabs` re-exports this under the name `byTabs`, and\n * by-peer exposes the same factory as {@link byPeer}.\n */\nexport function channelMesh(channel: PeerChannel): NoydbMesh {\n const vaults = new Map<string, VaultState>()\n\n function stateFor(vault: string): VaultState {\n let s = vaults.get(vault)\n if (!s) {\n s = {\n fence: DEFAULT_FENCE,\n presence: new Map(),\n fenceObservers: new Set(),\n presenceObservers: new Set(),\n }\n vaults.set(vault, s)\n }\n return s\n }\n\n function notifyFence(s: VaultState): void {\n for (const cb of [...s.fenceObservers]) cb(s.fence)\n }\n\n /**\n * Prune a vault's presence map to writers seen within `staleMs` of `now`,\n * deleting stale entries from the canonical map. Callers pass their own\n * authoritative clock — never a wall clock — so a synthetic test clock and a\n * real `Date.now()` heartbeat stay consistent.\n */\n function prune(s: VaultState, now: number, staleMs: number): WriterPresence[] {\n const live: WriterPresence[] = []\n for (const [id, p] of [...s.presence]) {\n if (now - p.lastSeen <= staleMs) live.push(p)\n else s.presence.delete(id)\n }\n return live\n }\n\n function notifyPresence(s: VaultState): void {\n if (s.presenceObservers.size === 0) return\n // Best-effort prune for the *pushed batch* so an end that stopped\n // heartbeating drops out, but DON'T mutate the canonical map here: emits\n // happen on the provider's local clock, while `reportPresence` callers may\n // stamp `lastSeen` with a synthetic clock. Authoritative deletion belongs to\n // `reachableWriters`, which is handed the caller's clock + staleMs.\n const newest = Math.max(...[...s.presence.values()].map((p) => p.lastSeen))\n const horizon = newest - DEFAULT_STALE_MS\n const live = [...s.presence.values()].filter((p) => p.lastSeen >= horizon)\n for (const cb of [...s.presenceObservers]) cb(live)\n }\n\n function send(env: Envelope): void {\n if (!channel.isOpen) return\n try {\n channel.send(JSON.stringify(env))\n } catch {\n // Channel torn down mid-flight — local state already updated; drop.\n }\n }\n\n channel.on('message', (raw: string) => {\n let env: Envelope\n try {\n const parsed = JSON.parse(raw) as { k?: unknown }\n if (parsed.k !== 'co-fence' && parsed.k !== 'co-presence') return\n env = parsed as Envelope\n } catch {\n return // not our message / malformed JSON\n }\n\n if (env.k === 'co-fence') {\n const s = stateFor(env.vault)\n s.fence = env.fence\n notifyFence(s)\n } else {\n const s = stateFor(env.vault)\n s.presence.set(env.presence.writerId, env.presence)\n notifyPresence(s)\n }\n })\n\n return {\n async setFence(vault: string, fence: FenceDoc): Promise<void> {\n const s = stateFor(vault)\n s.fence = fence\n notifyFence(s)\n send({ k: 'co-fence', vault, fence })\n },\n\n async readFence(vault: string): Promise<FenceDoc> {\n return vaults.get(vault)?.fence ?? DEFAULT_FENCE\n },\n\n observeFence(vault: string, onChange: (f: FenceDoc) => void): () => void {\n const s = stateFor(vault)\n s.fenceObservers.add(onChange)\n return () => s.fenceObservers.delete(onChange)\n },\n\n async reportPresence(vault: string, p: WriterPresence): Promise<void> {\n const s = stateFor(vault)\n s.presence.set(p.writerId, p)\n notifyPresence(s)\n send({ k: 'co-presence', vault, presence: p })\n },\n\n observePresence(vault: string, onChange: (w: readonly WriterPresence[]) => void): () => void {\n const s = stateFor(vault)\n s.presenceObservers.add(onChange)\n return () => s.presenceObservers.delete(onChange)\n },\n\n async reachableWriters(\n vault: string,\n o: { staleMs: number; now: number },\n ): Promise<readonly WriterPresence[]> {\n const s = vaults.get(vault)\n if (!s) return []\n return prune(s, o.now, o.staleMs)\n },\n }\n}\n\n/**\n * by-peer's public name for the shared {@link channelMesh} core — a\n * real-time {@link NoydbMesh} over a WebRTC `PeerChannel` (or any\n * other `PeerChannel`). Inject via\n * `createNoydb({ mesh: byPeer(ch) })`.\n *\n * It is the *same* factory as {@link channelMesh}; both names are\n * exported so callers can pick the one that reads best at the call site.\n */\nexport const byPeer: (channel: PeerChannel) => NoydbMesh = channelMesh\n"],"mappings":";AA2CO,SAAS,eAA2C;AAMzD,WAAS,OAA0E;AACjF,UAAM,YAAuB,EAAE,SAAS,oBAAI,IAAI,GAAG,OAAO,oBAAI,IAAI,EAAE;AACpE,UAAM,SAAS,EAAE,GAAG,MAAM;AAC1B,UAAM,KAAkB;AAAA,MACtB,IAAI,SAAS;AACX,eAAO,CAAC,OAAO;AAAA,MACjB;AAAA,MACA,OAAO;AAAA,MAEP;AAAA,MACA,GAAG,OAA4B,UAAkE;AAC/F,YAAI,UAAU,WAAW;AACvB,oBAAU,QAAQ,IAAI,QAA+B;AACrD,iBAAO,MAAM,UAAU,QAAQ,OAAO,QAA+B;AAAA,QACvE;AACA,kBAAU,MAAM,IAAI,QAAsB;AAC1C,eAAO,MAAM,UAAU,MAAM,OAAO,QAAsB;AAAA,MAC5D;AAAA,MACA,QAAQ;AACN,YAAI,OAAO,EAAG;AACd,eAAO,IAAI;AACX,mBAAW,MAAM,UAAU,MAAO,IAAG;AAAA,MACvC;AAAA,IACF;AACA,WAAO,EAAE,IAAI,WAAW,OAAO;AAAA,EACjC;AAEA,QAAM,IAAI,KAAK;AACf,QAAM,IAAI,KAAK;AAEf,WAAS,YAAkB;AACzB,QAAI,CAAC,EAAE,OAAO,GAAG;AACf,QAAE,OAAO,IAAI;AACb,iBAAW,MAAM,EAAE,UAAU,MAAO,IAAG;AAAA,IACzC;AACA,QAAI,CAAC,EAAE,OAAO,GAAG;AACf,QAAE,OAAO,IAAI;AACb,iBAAW,MAAM,EAAE,UAAU,MAAO,IAAG;AAAA,IACzC;AAAA,EACF;AAEA,IAAE,GAAG,OAAO,CAAC,YAAY;AACvB,QAAI,EAAE,OAAO,EAAG,OAAM,IAAI,MAAM,oBAAoB;AACpD,mBAAe,MAAM;AACnB,iBAAW,MAAM,EAAE,UAAU,QAAS,IAAG,OAAO;AAAA,IAClD,CAAC;AAAA,EACH;AACA,IAAE,GAAG,OAAO,CAAC,YAAY;AACvB,QAAI,EAAE,OAAO,EAAG,OAAM,IAAI,MAAM,oBAAoB;AACpD,mBAAe,MAAM;AACnB,iBAAW,MAAM,EAAE,UAAU,QAAS,IAAG,OAAO;AAAA,IAClD,CAAC;AAAA,EACH;AACA,IAAE,GAAG,QAAQ;AACb,IAAE,GAAG,QAAQ;AAEb,SAAO,CAAC,EAAE,IAAI,EAAE,EAAE;AACpB;AAWO,SAAS,gBAAgB,IAAiC;AAK/D,QAAM,YAAuB,EAAE,SAAS,oBAAI,IAAI,GAAG,OAAO,oBAAI,IAAI,EAAE;AACpE,MAAI,SAAS;AAEb,KAAG,iBAAiB,WAAW,CAAC,OAAqB;AACnD,QAAI,OAAO,GAAG,SAAS,SAAU;AACjC,eAAW,MAAM,UAAU,QAAS,IAAG,GAAG,IAAI;AAAA,EAChD,CAAC;AACD,KAAG,iBAAiB,SAAS,MAAM;AACjC,QAAI,OAAQ;AACZ,aAAS;AACT,eAAW,MAAM,UAAU,MAAO,IAAG;AAAA,EACvC,CAAC;AAED,SAAO;AAAA,IACL,IAAI,SAAS;AACX,aAAO,CAAC,UAAU,GAAG,eAAe;AAAA,IACtC;AAAA,IACA,KAAK,SAAS;AACZ,UAAI,UAAU,GAAG,eAAe,QAAQ;AACtC,cAAM,IAAI,MAAM,qCAAqC,GAAG,UAAU,GAAG;AAAA,MACvE;AACA,SAAG,KAAK,OAAO;AAAA,IACjB;AAAA,IACA,GAAG,OAA4B,UAAkE;AAC/F,UAAI,UAAU,WAAW;AACvB,kBAAU,QAAQ,IAAI,QAA+B;AACrD,eAAO,MAAM,UAAU,QAAQ,OAAO,QAA+B;AAAA,MACvE;AACA,gBAAU,MAAM,IAAI,QAAsB;AAC1C,aAAO,MAAM,UAAU,MAAM,OAAO,QAAsB;AAAA,IAC5D;AAAA,IACA,QAAQ;AACN,UAAI,OAAQ;AACZ,eAAS;AACT,UAAI;AACF,WAAG,MAAM;AAAA,MACX,QAAQ;AAAA,MAER;AACA,iBAAW,MAAM,UAAU,MAAO,IAAG;AAAA,IACvC;AAAA,EACF;AACF;;;ACnFA,SAAS,sBAAsB,MAA8C;AAC3E,MAAI,MAAM,KAAK;AACf,SAAO,MAAM,KAAK,KAAK,MAAM,CAAC,MAAM,OAAW;AAC/C,SAAO,QAAQ,KAAK,SAAS,OAAO,KAAK,MAAM,GAAG,GAAG;AACvD;AAGO,SAAS,gBAAgB,SAAsB,OAAyB,CAAC,GAAG;AACjF,QAAM,YAAY,KAAK,aAAa;AAMpC,QAAM,UAAU,oBAAI,IAAqB;AACzC,MAAI,UAAU;AAEd,QAAM,aAAa,QAAQ,GAAG,WAAW,CAAC,YAAY;AACpD,QAAI;AACJ,QAAI;AACF,YAAM,KAAK,MAAM,OAAO;AAAA,IAC1B,QAAQ;AACN;AAAA,IACF;AACA,QAAI,IAAI,MAAM,MAAO;AACrB,UAAM,QAAQ,QAAQ,IAAI,IAAI,EAAE;AAChC,QAAI,CAAC,MAAO;AACZ,iBAAa,MAAM,KAAK;AACxB,YAAQ,OAAO,IAAI,EAAE;AACrB,QAAI,IAAI,IAAI;AACV,YAAM,QAAQ,IAAI,MAAM;AAAA,IAC1B,OAAO;AACL,YAAM,IAAI,IAAI,SAAS,EAAE,MAAM,SAAS,SAAS,uBAAuB;AACxE,YAAM,MAAM,IAAI,MAAM,EAAE,OAAO;AAC/B,UAAI,OAAO,EAAE;AACb,UAAI,OAAO,EAAE,YAAY,UAAU;AACjC;AAAC,QAAC,IAAqC,UAAU,EAAE;AAAA,MACrD;AACA,YAAM,OAAO,GAAG;AAAA,IAClB;AAAA,EACF,CAAC;AAED,QAAM,WAAW,QAAQ,GAAG,SAAS,MAAM;AACzC,eAAW,CAAC,EAAE,KAAK,KAAK,SAAS;AAC/B,mBAAa,MAAM,KAAK;AACxB,YAAM,OAAO,IAAI,MAAM,oCAAoC,CAAC;AAAA,IAC9D;AACA,YAAQ,MAAM;AAAA,EAChB,CAAC;AAED,SAAO;AAAA,IACL,MAAM,KAAkB,QAAgB,MAAsC;AAC5E,YAAM,KAAK,GAAG,KAAK,IAAI,EAAE,SAAS,EAAE,CAAC,KAAK,EAAE,SAAS,SAAS,EAAE,CAAC;AACjE,YAAM,MAAkB;AAAA,QACtB,GAAG;AAAA,QAAO;AAAA,QAAI;AAAA,QAAQ,MAAM,sBAAsB,IAAI;AAAA,QACtD,GAAI,KAAK,UAAU,UAAa,EAAE,MAAM,KAAK,MAAM;AAAA,MACrD;AACA,aAAO,IAAI,QAAW,CAAC,SAAS,WAAW;AACzC,cAAM,QAAQ,WAAW,MAAM;AAC7B,kBAAQ,OAAO,EAAE;AACjB,iBAAO,IAAI,MAAM,OAAO,MAAM,oBAAoB,SAAS,IAAI,CAAC;AAAA,QAClE,GAAG,SAAS;AACZ,gBAAQ,IAAI,IAAI;AAAA,UACd;AAAA,UACA;AAAA,UACA;AAAA,QACF,CAAC;AACD,YAAI;AACF,kBAAQ,KAAK,KAAK,UAAU,GAAG,CAAC;AAAA,QAClC,SAAS,KAAK;AACZ,uBAAa,KAAK;AAClB,kBAAQ,OAAO,EAAE;AACjB,iBAAO,eAAe,QAAQ,MAAM,IAAI,MAAM,OAAO,GAAG,CAAC,CAAC;AAAA,QAC5D;AAAA,MACF,CAAC;AAAA,IACH;AAAA,IACA,UAAU;AACR,iBAAW;AACX,eAAS;AACT,iBAAW,CAAC,EAAE,KAAK,KAAK,SAAS;AAC/B,qBAAa,MAAM,KAAK;AACxB,cAAM,OAAO,IAAI,MAAM,qBAAqB,CAAC;AAAA,MAC/C;AACA,cAAQ,MAAM;AAAA,IAChB;AAAA,EACF;AACF;AAGO,SAAS,SAAS,SAAsB,SAAiC;AAC9E,iBAAe,OAAO,SAAgC;AACpD,QAAI;AACJ,QAAI;AACF,YAAM,KAAK,MAAM,OAAO;AAAA,IAC1B,QAAQ;AACN;AAAA,IACF;AACA,QAAI,IAAI,MAAM,MAAO;AAErB,QAAI;AACJ,QAAI;AACF,YAAM,SAAS,MAAM,QAAQ,IAAI,QAAQ,IAAI,MAAM,IAAI,IAAI;AAC3D,iBAAW,EAAE,GAAG,OAAO,IAAI,IAAI,IAAI,IAAI,MAAM,OAAO;AAAA,IACtD,SAAS,KAAK;AACZ,YAAM,IAAI;AACV,iBAAW;AAAA,QACT,GAAG;AAAA,QACH,IAAI,IAAI;AAAA,QACR,IAAI;AAAA,QACJ,OAAO;AAAA,UACL,MAAM,EAAE,QAAQ;AAAA,UAChB,SAAS,EAAE,WAAW,OAAO,GAAG;AAAA,UAChC,GAAI,OAAO,EAAE,YAAY,YAAY,EAAE,SAAS,EAAE,QAAQ;AAAA,QAC5D;AAAA,MACF;AAAA,IACF;AAEA,QAAI,CAAC,QAAQ,OAAQ;AACrB,QAAI;AACF,cAAQ,KAAK,KAAK,UAAU,QAAQ,CAAC;AAAA,IACvC,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,QAAM,aAAa,QAAQ,GAAG,WAAW,CAAC,YAAY;AACpD,SAAK,OAAO,OAAO;AAAA,EACrB,CAAC;AAED,SAAO,MAAM,WAAW;AAC1B;;;AClMA,SAAS,qBAAqB;AAuBvB,SAAS,UAAU,MAA8D;AACtF,QAAM,MAAM,gBAAgB,KAAK,SAAS;AAAA,IACxC,WAAW,KAAK,aAAa;AAAA,IAC7B,GAAI,KAAK,UAAU,UAAa,EAAE,OAAO,KAAK,MAAM;AAAA,EACtD,CAAC;AAED,iBAAe,KAAQ,QAAgB,MAAsC;AAC3E,QAAI;AACF,aAAO,MAAM,IAAI,KAAQ,QAAQ,IAAI;AAAA,IACvC,SAAS,KAAK;AAEZ,YAAM,IAAI;AACV,UAAI,EAAE,SAAS,mBAAmB,OAAO,EAAE,YAAY,UAAU;AAC/D,cAAM,IAAI,cAAc,EAAE,SAAS,EAAE,OAAO;AAAA,MAC9C;AACA,YAAM;AAAA,IACR;AAAA,EACF;AAEA,SAAO;AAAA,IACL,MAAM,KAAK,QAAQ;AAAA,IAEnB,MAAM,IAAI,OAAO,YAAY,IAAI;AAC/B,aAAO,KAA+B,OAAO,CAAC,OAAO,YAAY,EAAE,CAAC;AAAA,IACtE;AAAA,IAEA,MAAM,IAAI,OAAO,YAAY,IAAI,UAAU,iBAAiB;AAC1D,YAAM,KAAW,OAAO,CAAC,OAAO,YAAY,IAAI,UAAU,eAAe,CAAC;AAAA,IAC5E;AAAA,IAEA,MAAM,OAAO,OAAO,YAAY,IAAI;AAClC,YAAM,KAAW,UAAU,CAAC,OAAO,YAAY,EAAE,CAAC;AAAA,IACpD;AAAA,IAEA,MAAM,KAAK,OAAO,YAAY;AAC5B,aAAO,KAAe,QAAQ,CAAC,OAAO,UAAU,CAAC;AAAA,IACnD;AAAA,IAEA,MAAM,QAAQ,OAAO;AACnB,aAAO,KAAoB,WAAW,CAAC,KAAK,CAAC;AAAA,IAC/C;AAAA,IAEA,MAAM,QAAQ,OAAO,MAAM;AACzB,YAAM,KAAW,WAAW,CAAC,OAAO,IAAI,CAAC;AAAA,IAC3C;AAAA,IAEA,MAAM,OAAO;AACX,UAAI;AACF,eAAO,MAAM,KAAc,QAAQ,CAAC,CAAC;AAAA,MACvC,QAAQ;AACN,eAAO;AAAA,MACT;AAAA,IACF;AAAA,IAEA,UAAU;AACR,UAAI,QAAQ;AAAA,IACd;AAAA,EACF;AACF;;;AClDA,SAAS,mBAAmB,GAAuB,GAAoB;AACrE,MAAI,MAAM,UAAa,EAAE,WAAW,EAAE,OAAQ,QAAO;AACrD,MAAI,OAAO;AACX,WAAS,IAAI,GAAG,IAAI,EAAE,QAAQ,IAAK,SAAQ,EAAE,WAAW,CAAC,IAAI,EAAE,WAAW,CAAC;AAC3E,SAAO,SAAS;AAClB;AAuDA,IAAM,eAAe,oBAAI,IAAY;AAAA,EACnC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAaM,SAAS,eAAe,MAAyC;AACtE,MAAI,CAAC,KAAK,gBAAgB;AACxB,WAAO,aAAa,IAAI;AAAA,EAC1B;AACA,SAAO,+BAA+B,IAAI;AAC5C;AAMA,SAAS,aAAa,MAAyC;AAC7D,QAAM,EAAE,OAAO,SAAS,OAAO,MAAM,IAAI;AAEzC,SAAO,SAAS,SAAS,OAAO,QAAQ,MAAM,SAAS;AAIrD,QAAI,UAAU,UAAa,CAAC,mBAAmB,MAAM,KAAK,GAAG;AAC3D,YAAM,IAAI;AAAA,QACR;AAAA,MAGF;AAAA,IACF;AACA,QAAI,CAAC,aAAa,IAAI,MAAM,GAAG;AAC7B,YAAM,IAAI,MAAM,uBAAuB,MAAM,EAAE;AAAA,IACjD;AACA,QAAI,SAAS,CAAC,MAAM,IAAI,MAAM,GAAG;AAC/B,YAAM,IAAI,MAAM,uBAAuB,MAAM,EAAE;AAAA,IACjD;AAEA,YAAQ,QAAQ;AAAA,MACd,KAAK,OAAO;AACV,cAAM,CAAC,OAAO,YAAY,EAAE,IAAI;AAChC,eAAO,MAAM,IAAI,OAAO,YAAY,EAAE;AAAA,MACxC;AAAA,MACA,KAAK,OAAO;AACV,cAAM,CAAC,OAAO,YAAY,IAAI,UAAU,eAAe,IAAI;AAc3D,cAAM,MAAM,IAAI,OAAO,YAAY,IAAI,UAAU,mBAAmB,MAAS;AAC7E,eAAO;AAAA,MACT;AAAA,MACA,KAAK,UAAU;AACb,cAAM,CAAC,OAAO,YAAY,EAAE,IAAI;AAChC,cAAM,MAAM,OAAO,OAAO,YAAY,EAAE;AACxC,eAAO;AAAA,MACT;AAAA,MACA,KAAK,QAAQ;AACX,cAAM,CAAC,OAAO,UAAU,IAAI;AAC5B,eAAO,MAAM,KAAK,OAAO,UAAU;AAAA,MACrC;AAAA,MACA,KAAK,WAAW;AACd,cAAM,CAAC,KAAK,IAAI;AAChB,eAAO,MAAM,QAAQ,KAAK;AAAA,MAC5B;AAAA,MACA,KAAK,WAAW;AACd,cAAM,CAAC,OAAO,IAAI,IAAI;AACtB,cAAM,MAAM,QAAQ,OAAO,IAAI;AAC/B,eAAO;AAAA,MACT;AAAA,MACA,KAAK,QAAQ;AACX,YAAI,CAAC,MAAM,KAAM,QAAO;AACxB,eAAO,MAAM,KAAK;AAAA,MACpB;AAAA,MACA,KAAK,aAAa;AAChB,YAAI,CAAC,MAAM,UAAW,OAAM,IAAI,MAAM,yCAAyC;AAC/E,cAAM,CAAC,OAAO,YAAY,KAAK,IAAI;AACnC,eAAO,MAAM,UAAU,OAAO,YAAY,KAAK;AAAA,MACjD;AAAA,MACA,KAAK,YAAY;AACf,YAAI,CAAC,MAAM,SAAU,OAAM,IAAI,MAAM,wCAAwC;AAC7E,cAAM,CAAC,OAAO,YAAY,QAAQ,KAAK,IAAI;AAM3C,eAAO,MAAM,SAAS,OAAO,YAAY,QAAQ,KAAK;AAAA,MACxD;AAAA,MACA,KAAK,cAAc;AACjB,YAAI,CAAC,MAAM,WAAY,OAAM,IAAI,MAAM,0CAA0C;AACjF,eAAO,MAAM,WAAW;AAAA,MAC1B;AAAA,IACF;AAEA,UAAM,IAAI,MAAM,qBAAqB,MAAM,EAAE;AAAA,EAC/C,CAAC;AACH;AAOA,SAAS,+BAA+B,MAAyC;AAE/E,QAAM,SAAS,KAAK;AACpB,QAAM,QAAQ,OAAO,SAAS,gBAAgB;AAC9C,QAAM,KAAK,IAAI,gBAAgB;AAC/B,MAAI,cAAmC;AACvC,MAAI,cAAmC;AACvC,MAAI,WAAW;AAEf,OAAK,MACF,QAAQ,OAAO,UAAU,EAAE,MAAM,aAAa,QAAQ,GAAG,OAAO,GAAG,MAAM;AACxE,QAAI,SAAU,QAAO,QAAQ,QAAQ;AACrC,kBAAc,aAAa,IAAI;AAI/B,WAAO,IAAI,QAAc,CAAC,YAAY;AACpC,oBAAc;AAAA,IAChB,CAAC;AAAA,EACH,CAAC,EACA,MAAM,CAAC,QAAiB;AAIvB,QAAK,KAAkC,SAAS,aAAc;AAC9D,mBAAe,MAAM;AACnB,YAAM;AAAA,IACR,CAAC;AAAA,EACH,CAAC;AAEH,SAAO,MAAM;AACX,QAAI,SAAU;AACd,eAAW;AACX,QAAI,aAAa;AACf,kBAAY;AACZ,oBAAc;AAAA,IAChB;AACA,QAAI,aAAa;AACf,kBAAY;AACZ,oBAAc;AAAA,IAChB,OAAO;AACL,SAAG,MAAM;AAAA,IACX;AAAA,EACF;AACF;AAEA,SAAS,kBAAsC;AAC7C,QAAM,MAAO,WAA8D;AAC3E,MAAI,OAAO,IAAI,MAAO,QAAO,IAAI;AACjC,QAAM,IAAI;AAAA,IACR;AAAA,EAGF;AACF;;;AChMO,SAAS,oBAAoB,MAA+C;AAGjF,QAAM,QAAQ,oBAAI,IAAwB;AAC1C,MAAI,WAAW;AAEf,SAAO;AAAA,IACL,OAAO,QAAQ;AACb,UAAI,SAAU,OAAM,IAAI,MAAM,0EAA0E;AACxG,YAAM,QAAQ,QAAQ;AACtB,UAAI,OAAO,UAAU,YAAY,MAAM,WAAW,GAAG;AACnD,cAAM,IAAI;AAAA,UACR;AAAA,QAGF;AAAA,MACF;AACA,UAAI,MAAM,IAAI,KAAK,GAAG;AACpB,cAAM,IAAI;AAAA,UACR;AAAA,QAEF;AAAA,MACF;AAEA,YAAM,QAAQ,OAAO,SAAS,KAAK;AACnC,YAAM,OAAO,eAAe;AAAA,QAC1B,SAAS,OAAO;AAAA,QAChB,OAAO,OAAO,SAAS,KAAK;AAAA,QAC5B;AAAA,QACA,GAAI,UAAU,UAAa,EAAE,MAAM;AAAA,MACrC,CAAC;AAED,UAAI,OAAO;AACX,YAAM,SAAS,MAAY;AACzB,YAAI,CAAC,KAAM;AACX,eAAO;AACP,aAAK;AACL,iBAAS;AACT,cAAM,OAAO,KAAK;AAAA,MACpB;AAGA,YAAM,WAAW,OAAO,QAAQ,GAAG,SAAS,MAAM;AAClD,YAAM,IAAI,OAAO,MAAM;AACvB,aAAO;AAAA,IACT;AAAA,IAEA,IAAI,OAAO;AACT,aAAO,MAAM;AAAA,IACf;AAAA,IAEA,UAAU;AACR,UAAI,SAAU;AACd,iBAAW;AACX,iBAAW,UAAU,CAAC,GAAG,MAAM,OAAO,CAAC,EAAG,QAAO;AAAA,IACnD;AAAA,EACF;AACF;;;ACjGA,SAAS,aAAuC;AAC9C,QAAM,IAAI;AACV,MAAI,CAAC,EAAE,mBAAmB;AACxB,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACA,SAAO,EAAE;AACX;AAMA,eAAsB,YAAY,OAAsB,CAAC,GAAuB;AAC9E,QAAM,MAAM,WAAW;AACvB,QAAM,KAAK,IAAI,IAAI;AAAA,IACjB,GAAI,KAAK,cAAc,EAAE,YAAY,KAAK,WAAW;AAAA,EACvD,CAAC;AAED,QAAM,KAAK,GAAG,kBAAkB,KAAK,SAAS,SAAS;AAAA,IACrD,SAAS;AAAA,EACX,CAAC;AAED,QAAM,iBAAiB,IAAI,QAAqB,CAAC,SAAS,WAAW;AACnE,OAAG,iBAAiB,QAAQ,MAAM,QAAQ,gBAAgB,EAAE,CAAC,CAAC;AAC9D,OAAG,iBAAiB,SAAS,MAAM,OAAO,IAAI,MAAM,mBAAmB,CAAC,CAAC;AAAA,EAC3E,CAAC;AAED,QAAM,QAAQ,MAAM,GAAG,YAAY;AACnC,QAAM,GAAG,oBAAoB,KAAK;AAClC,QAAM,mBAAmB,EAAE;AAE3B,SAAO;AAAA,IACL,OAAO,GAAG,oBAAoB;AAAA,IAC9B,YAAY;AAAA,IACZ,SAAS;AAAA,IACT,MAAM,OAAO,QAAQ;AACnB,YAAM,GAAG,qBAAqB,MAAM;AAAA,IACtC;AAAA,EACF;AACF;AAOA,eAAsB,YACpB,OACA,OAAsB,CAAC,GACH;AACpB,QAAM,MAAM,WAAW;AACvB,QAAM,KAAK,IAAI,IAAI;AAAA,IACjB,GAAI,KAAK,cAAc,EAAE,YAAY,KAAK,WAAW;AAAA,EACvD,CAAC;AAED,QAAM,iBAAiB,IAAI,QAAqB,CAAC,SAAS,WAAW;AACnE,OAAG,iBAAiB,eAAe,CAAC,OAAO;AACzC,YAAM,KAAK,GAAG;AACd,UAAI,GAAG,eAAe,QAAQ;AAC5B,gBAAQ,gBAAgB,EAAE,CAAC;AAAA,MAC7B,OAAO;AACL,WAAG,iBAAiB,QAAQ,MAAM,QAAQ,gBAAgB,EAAE,CAAC,CAAC;AAC9D,WAAG,iBAAiB,SAAS,MAAM,OAAO,IAAI,MAAM,mBAAmB,CAAC,CAAC;AAAA,MAC3E;AAAA,IACF,CAAC;AAAA,EACH,CAAC;AAED,QAAM,GAAG,qBAAqB,KAAK;AACnC,QAAM,SAAS,MAAM,GAAG,aAAa;AACrC,QAAM,GAAG,oBAAoB,MAAM;AACnC,QAAM,mBAAmB,EAAE;AAE3B,SAAO;AAAA,IACL,QAAQ,GAAG,oBAAoB;AAAA,IAC/B,YAAY;AAAA,IACZ,SAAS;AAAA,EACX;AACF;AAQA,SAAS,mBAAmB,IAAmC;AAC7D,MAAI,GAAG,sBAAsB,WAAY,QAAO,QAAQ,QAAQ;AAChE,SAAO,IAAI,QAAQ,CAAC,YAAY;AAC9B,UAAM,QAAQ,MAAM;AAClB,UAAI,GAAG,sBAAsB,YAAY;AACvC,WAAG,oBAAoB,2BAA2B,KAAK;AACvD,gBAAQ;AAAA,MACV;AAAA,IACF;AACA,OAAG,iBAAiB,2BAA2B,KAAK;AAAA,EACtD,CAAC;AACH;;;ACtHA,IAAM,gBAA0B,EAAE,sBAAsB,GAAG,YAAY,SAAS;AAOhF,IAAM,mBAAmB;AA6BlB,SAAS,YAAY,SAAiC;AAC3D,QAAM,SAAS,oBAAI,IAAwB;AAE3C,WAAS,SAAS,OAA2B;AAC3C,QAAI,IAAI,OAAO,IAAI,KAAK;AACxB,QAAI,CAAC,GAAG;AACN,UAAI;AAAA,QACF,OAAO;AAAA,QACP,UAAU,oBAAI,IAAI;AAAA,QAClB,gBAAgB,oBAAI,IAAI;AAAA,QACxB,mBAAmB,oBAAI,IAAI;AAAA,MAC7B;AACA,aAAO,IAAI,OAAO,CAAC;AAAA,IACrB;AACA,WAAO;AAAA,EACT;AAEA,WAAS,YAAY,GAAqB;AACxC,eAAW,MAAM,CAAC,GAAG,EAAE,cAAc,EAAG,IAAG,EAAE,KAAK;AAAA,EACpD;AAQA,WAAS,MAAM,GAAe,KAAa,SAAmC;AAC5E,UAAM,OAAyB,CAAC;AAChC,eAAW,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,QAAQ,GAAG;AACrC,UAAI,MAAM,EAAE,YAAY,QAAS,MAAK,KAAK,CAAC;AAAA,UACvC,GAAE,SAAS,OAAO,EAAE;AAAA,IAC3B;AACA,WAAO;AAAA,EACT;AAEA,WAAS,eAAe,GAAqB;AAC3C,QAAI,EAAE,kBAAkB,SAAS,EAAG;AAMpC,UAAM,SAAS,KAAK,IAAI,GAAG,CAAC,GAAG,EAAE,SAAS,OAAO,CAAC,EAAE,IAAI,CAAC,MAAM,EAAE,QAAQ,CAAC;AAC1E,UAAM,UAAU,SAAS;AACzB,UAAM,OAAO,CAAC,GAAG,EAAE,SAAS,OAAO,CAAC,EAAE,OAAO,CAAC,MAAM,EAAE,YAAY,OAAO;AACzE,eAAW,MAAM,CAAC,GAAG,EAAE,iBAAiB,EAAG,IAAG,IAAI;AAAA,EACpD;AAEA,WAAS,KAAK,KAAqB;AACjC,QAAI,CAAC,QAAQ,OAAQ;AACrB,QAAI;AACF,cAAQ,KAAK,KAAK,UAAU,GAAG,CAAC;AAAA,IAClC,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,UAAQ,GAAG,WAAW,CAAC,QAAgB;AACrC,QAAI;AACJ,QAAI;AACF,YAAM,SAAS,KAAK,MAAM,GAAG;AAC7B,UAAI,OAAO,MAAM,cAAc,OAAO,MAAM,cAAe;AAC3D,YAAM;AAAA,IACR,QAAQ;AACN;AAAA,IACF;AAEA,QAAI,IAAI,MAAM,YAAY;AACxB,YAAM,IAAI,SAAS,IAAI,KAAK;AAC5B,QAAE,QAAQ,IAAI;AACd,kBAAY,CAAC;AAAA,IACf,OAAO;AACL,YAAM,IAAI,SAAS,IAAI,KAAK;AAC5B,QAAE,SAAS,IAAI,IAAI,SAAS,UAAU,IAAI,QAAQ;AAClD,qBAAe,CAAC;AAAA,IAClB;AAAA,EACF,CAAC;AAED,SAAO;AAAA,IACL,MAAM,SAAS,OAAe,OAAgC;AAC5D,YAAM,IAAI,SAAS,KAAK;AACxB,QAAE,QAAQ;AACV,kBAAY,CAAC;AACb,WAAK,EAAE,GAAG,YAAY,OAAO,MAAM,CAAC;AAAA,IACtC;AAAA,IAEA,MAAM,UAAU,OAAkC;AAChD,aAAO,OAAO,IAAI,KAAK,GAAG,SAAS;AAAA,IACrC;AAAA,IAEA,aAAa,OAAe,UAA6C;AACvE,YAAM,IAAI,SAAS,KAAK;AACxB,QAAE,eAAe,IAAI,QAAQ;AAC7B,aAAO,MAAM,EAAE,eAAe,OAAO,QAAQ;AAAA,IAC/C;AAAA,IAEA,MAAM,eAAe,OAAe,GAAkC;AACpE,YAAM,IAAI,SAAS,KAAK;AACxB,QAAE,SAAS,IAAI,EAAE,UAAU,CAAC;AAC5B,qBAAe,CAAC;AAChB,WAAK,EAAE,GAAG,eAAe,OAAO,UAAU,EAAE,CAAC;AAAA,IAC/C;AAAA,IAEA,gBAAgB,OAAe,UAA8D;AAC3F,YAAM,IAAI,SAAS,KAAK;AACxB,QAAE,kBAAkB,IAAI,QAAQ;AAChC,aAAO,MAAM,EAAE,kBAAkB,OAAO,QAAQ;AAAA,IAClD;AAAA,IAEA,MAAM,iBACJ,OACA,GACoC;AACpC,YAAM,IAAI,OAAO,IAAI,KAAK;AAC1B,UAAI,CAAC,EAAG,QAAO,CAAC;AAChB,aAAO,MAAM,GAAG,EAAE,KAAK,EAAE,OAAO;AAAA,IAClC;AAAA,EACF;AACF;AAWO,IAAM,SAA8C;","names":[]}
package/package.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "@noy-db/by-peer",
3
- "version": "0.7.0",
3
+ "version": "0.8.0-pre.0",
4
4
  "description": "WebRTC peer-to-peer transport for noy-db — direct browser-to-browser channel with signaling-agnostic handshake",
5
- "license": "MIT",
5
+ "license": "Apache-2.0",
6
6
  "author": "vLannaAi <vicio@lanna.ai>",
7
- "homepage": "https://github.com/vLannaAi/noy-db/tree/main/packages/by-peer#readme",
7
+ "homepage": "https://github.com/noy-db/core/tree/main/packages/by-peer#readme",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "git+https://github.com/vLannaAi/noy-db.git",
10
+ "url": "git+https://github.com/noy-db/core.git",
11
11
  "directory": "packages/by-peer"
12
12
  },
13
13
  "bugs": {
14
- "url": "https://github.com/vLannaAi/noy-db/issues"
14
+ "url": "https://github.com/noy-db/core/issues"
15
15
  },
16
16
  "type": "module",
17
17
  "sideEffects": false,
@@ -26,18 +26,18 @@
26
26
  "files": [
27
27
  "dist",
28
28
  "README.md",
29
- "LICENSE"
29
+ "LICENSE",
30
+ "NOTICE"
30
31
  ],
31
32
  "engines": {
32
33
  "node": ">=22.0.0"
33
34
  },
34
35
  "peerDependencies": {
35
- "@noy-db/hub": "^0.7.0"
36
+ "@noy-db/hub": "^0.8.0-pre.0"
36
37
  },
37
38
  "devDependencies": {
38
- "@noy-db/test-mesh-conformance": "0.7.0",
39
- "@noy-db/to-memory": "0.7.0",
40
- "@noy-db/hub": "0.7.0"
39
+ "@noy-db/hub": "0.8.0-pre.0",
40
+ "@noy-db/test-mesh-conformance": "0.8.0-pre.0"
41
41
  },
42
42
  "keywords": [
43
43
  "noy-db",