@noy-db/by-peer 0.1.0-pre.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
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.
package/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # @noy-db/by-peer
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`](../../docs/packages/by-transports.md) for the family contract and roster.
6
+ >
7
+ > Previously published as `@noy-db/p2p` — same code, renamed to fit the prefix family.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ pnpm add @noy-db/by-peer @noy-db/hub
13
+ ```
14
+
15
+ ## Why
16
+
17
+ Classic cloud sync routes every envelope through a central store. `@noy-db/by-peer` lets two browsers (or two Node processes) shake hands over any signaling channel you pick and then sync directly over a WebRTC DataChannel. Any TURN relay in the path only ever sees noy-db's AES-256-GCM ciphertext — the transport is independent of the encryption.
18
+
19
+ ## Use as a SyncTarget
20
+
21
+ ```ts
22
+ import { createNoydb } from '@noy-db/hub'
23
+ import { to } from '@noy-db/to-browser-idb'
24
+ import { peerStore, createOffer, acceptOffer } from '@noy-db/by-peer'
25
+
26
+ // Peer A — initiator
27
+ const initiator = await createOffer()
28
+ shareWithPeerB(initiator.offer) // QR code, Matrix, pastebin, …
29
+ await initiator.accept(await receiveAnswer())
30
+ const channel = await initiator.channel
31
+
32
+ const db = await createNoydb({
33
+ store: to(),
34
+ sync: { store: peerStore({ channel }), role: 'sync-peer' },
35
+ })
36
+ ```
37
+
38
+ Peer B mirrors the handshake with `acceptOffer` and runs `servePeerStore({ channel, store })` so its local store answers the incoming RPC calls.
39
+
40
+ ## Read-only peers
41
+
42
+ ```ts
43
+ servePeerStore({
44
+ channel,
45
+ store: local,
46
+ allow: new Set(['get', 'list', 'loadAll', 'listPage', 'ping']),
47
+ })
48
+ ```
49
+
50
+ Denied methods surface as a remote `Error` at the client.
51
+
52
+ ## Leader election (cross-tab coordination)
53
+
54
+ 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)).
55
+
56
+ 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).
57
+
58
+ ```ts
59
+ import { tabsChannel } from '@noy-db/by-tabs'
60
+ import { servePeerStore } from '@noy-db/by-peer'
61
+
62
+ const channel = tabsChannel({ name: 'my-app:vault' })
63
+
64
+ servePeerStore({
65
+ channel,
66
+ store: localStore,
67
+ leaderElection: { lockName: 'noy-db:peer-server:my-app:vault' },
68
+ })
69
+ ```
70
+
71
+ The same `lockName` MUST be used by every tab participating in the role. Browser support: Chrome 69+, Firefox 96+, Safari 15.4+. For tests or non-browser hosts, pass a stub `MinimalLockManager` via `leaderElection.locks`.
72
+
73
+ The 2-tab case works correctly without `leaderElection` (BroadcastChannel doesn't echo to sender). Enable for any consumer expecting 3+ tabs.
74
+
75
+ ## Transport abstraction
76
+
77
+ `PeerChannel` is the only primitive — any reliable in-order string channel works. The same wrapper is reused by every other `by-*` package:
78
+
79
+ | Transport | Factory | Package |
80
+ |---|---|---|
81
+ | WebRTC DataChannel | `fromDataChannel(dc)` | `@noy-db/by-peer` |
82
+ | In-memory pair (tests) | `pairInMemory()` | `@noy-db/by-peer` |
83
+ | BroadcastChannel | `tabsChannel(name)` | `@noy-db/by-tabs` |
84
+ | WebSocket / SSE relay | (planned) | `@noy-db/by-server` |
85
+ | Liveblocks / Yjs room | (planned) | `@noy-db/by-room` |
86
+
87
+ ## Status
88
+
89
+ `0.1.0-pre.1` — prototype. LAN-first. Multi-peer mesh is a follow-up.
package/dist/index.cjs ADDED
@@ -0,0 +1,491 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ acceptOffer: () => acceptOffer,
24
+ createOffer: () => createOffer,
25
+ createRpcClient: () => createRpcClient,
26
+ fromDataChannel: () => fromDataChannel,
27
+ pairInMemory: () => pairInMemory,
28
+ peerStore: () => peerStore,
29
+ servePeerStore: () => servePeerStore,
30
+ serveRpc: () => serveRpc
31
+ });
32
+ module.exports = __toCommonJS(index_exports);
33
+
34
+ // src/channel.ts
35
+ function pairInMemory() {
36
+ function make() {
37
+ const listeners = { message: /* @__PURE__ */ new Set(), close: /* @__PURE__ */ new Set() };
38
+ const closed = { v: false };
39
+ const ch = {
40
+ get isOpen() {
41
+ return !closed.v;
42
+ },
43
+ send() {
44
+ },
45
+ on(event, listener) {
46
+ if (event === "message") {
47
+ listeners.message.add(listener);
48
+ return () => listeners.message.delete(listener);
49
+ }
50
+ listeners.close.add(listener);
51
+ return () => listeners.close.delete(listener);
52
+ },
53
+ close() {
54
+ if (closed.v) return;
55
+ closed.v = true;
56
+ for (const fn of listeners.close) fn();
57
+ }
58
+ };
59
+ return { ch, listeners, closed };
60
+ }
61
+ const a = make();
62
+ const b = make();
63
+ function closeBoth() {
64
+ if (!a.closed.v) {
65
+ a.closed.v = true;
66
+ for (const fn of a.listeners.close) fn();
67
+ }
68
+ if (!b.closed.v) {
69
+ b.closed.v = true;
70
+ for (const fn of b.listeners.close) fn();
71
+ }
72
+ }
73
+ a.ch.send = (payload) => {
74
+ if (b.closed.v) throw new Error("PeerChannel closed");
75
+ queueMicrotask(() => {
76
+ for (const fn of b.listeners.message) fn(payload);
77
+ });
78
+ };
79
+ b.ch.send = (payload) => {
80
+ if (a.closed.v) throw new Error("PeerChannel closed");
81
+ queueMicrotask(() => {
82
+ for (const fn of a.listeners.message) fn(payload);
83
+ });
84
+ };
85
+ a.ch.close = closeBoth;
86
+ b.ch.close = closeBoth;
87
+ return [a.ch, b.ch];
88
+ }
89
+ function fromDataChannel(dc) {
90
+ const listeners = { message: /* @__PURE__ */ new Set(), close: /* @__PURE__ */ new Set() };
91
+ let closed = false;
92
+ dc.addEventListener("message", (ev) => {
93
+ if (typeof ev.data !== "string") return;
94
+ for (const fn of listeners.message) fn(ev.data);
95
+ });
96
+ dc.addEventListener("close", () => {
97
+ if (closed) return;
98
+ closed = true;
99
+ for (const fn of listeners.close) fn();
100
+ });
101
+ return {
102
+ get isOpen() {
103
+ return !closed && dc.readyState === "open";
104
+ },
105
+ send(payload) {
106
+ if (closed || dc.readyState !== "open") {
107
+ throw new Error(`PeerChannel not open (readyState: ${dc.readyState})`);
108
+ }
109
+ dc.send(payload);
110
+ },
111
+ on(event, listener) {
112
+ if (event === "message") {
113
+ listeners.message.add(listener);
114
+ return () => listeners.message.delete(listener);
115
+ }
116
+ listeners.close.add(listener);
117
+ return () => listeners.close.delete(listener);
118
+ },
119
+ close() {
120
+ if (closed) return;
121
+ closed = true;
122
+ try {
123
+ dc.close();
124
+ } catch {
125
+ }
126
+ for (const fn of listeners.close) fn();
127
+ }
128
+ };
129
+ }
130
+
131
+ // src/rpc.ts
132
+ function createRpcClient(channel, opts = {}) {
133
+ const timeoutMs = opts.timeoutMs ?? 3e4;
134
+ const pending = /* @__PURE__ */ new Map();
135
+ let counter = 0;
136
+ const offMessage = channel.on("message", (payload) => {
137
+ let msg;
138
+ try {
139
+ msg = JSON.parse(payload);
140
+ } catch {
141
+ return;
142
+ }
143
+ if (msg.t !== "res") return;
144
+ const entry = pending.get(msg.id);
145
+ if (!entry) return;
146
+ clearTimeout(entry.timer);
147
+ pending.delete(msg.id);
148
+ if (msg.ok) {
149
+ entry.resolve(msg.result);
150
+ } else {
151
+ const e = msg.error ?? { name: "Error", message: "unknown remote error" };
152
+ const err = new Error(e.message);
153
+ err.name = e.name;
154
+ if (typeof e.version === "number") {
155
+ ;
156
+ err.version = e.version;
157
+ }
158
+ entry.reject(err);
159
+ }
160
+ });
161
+ const offClose = channel.on("close", () => {
162
+ for (const [, entry] of pending) {
163
+ clearTimeout(entry.timer);
164
+ entry.reject(new Error("PeerChannel closed before response"));
165
+ }
166
+ pending.clear();
167
+ });
168
+ return {
169
+ async call(method, args) {
170
+ const id = `${Date.now().toString(36)}-${(++counter).toString(36)}`;
171
+ const req = { t: "req", id, method, args };
172
+ return new Promise((resolve, reject) => {
173
+ const timer = setTimeout(() => {
174
+ pending.delete(id);
175
+ reject(new Error(`RPC ${method} timed out after ${timeoutMs}ms`));
176
+ }, timeoutMs);
177
+ pending.set(id, {
178
+ resolve,
179
+ reject,
180
+ timer
181
+ });
182
+ try {
183
+ channel.send(JSON.stringify(req));
184
+ } catch (err) {
185
+ clearTimeout(timer);
186
+ pending.delete(id);
187
+ reject(err instanceof Error ? err : new Error(String(err)));
188
+ }
189
+ });
190
+ },
191
+ dispose() {
192
+ offMessage();
193
+ offClose();
194
+ for (const [, entry] of pending) {
195
+ clearTimeout(entry.timer);
196
+ entry.reject(new Error("RPC client disposed"));
197
+ }
198
+ pending.clear();
199
+ }
200
+ };
201
+ }
202
+ function serveRpc(channel, handler) {
203
+ async function handle(payload) {
204
+ let msg;
205
+ try {
206
+ msg = JSON.parse(payload);
207
+ } catch {
208
+ return;
209
+ }
210
+ if (msg.t !== "req") return;
211
+ let response;
212
+ try {
213
+ const result = await handler(msg.method, msg.args);
214
+ response = { t: "res", id: msg.id, ok: true, result };
215
+ } catch (err) {
216
+ const e = err;
217
+ response = {
218
+ t: "res",
219
+ id: msg.id,
220
+ ok: false,
221
+ error: {
222
+ name: e.name ?? "Error",
223
+ message: e.message ?? String(err),
224
+ ...typeof e.version === "number" && { version: e.version }
225
+ }
226
+ };
227
+ }
228
+ if (!channel.isOpen) return;
229
+ try {
230
+ channel.send(JSON.stringify(response));
231
+ } catch {
232
+ }
233
+ }
234
+ const offMessage = channel.on("message", (payload) => {
235
+ void handle(payload);
236
+ });
237
+ return () => offMessage();
238
+ }
239
+
240
+ // src/peer-store.ts
241
+ var import_hub = require("@noy-db/hub");
242
+ function peerStore(opts) {
243
+ const rpc = createRpcClient(opts.channel, { timeoutMs: opts.timeoutMs ?? 3e4 });
244
+ async function call(method, args) {
245
+ try {
246
+ return await rpc.call(method, args);
247
+ } catch (err) {
248
+ const e = err;
249
+ if (e.name === "ConflictError" && typeof e.version === "number") {
250
+ throw new import_hub.ConflictError(e.version, e.message);
251
+ }
252
+ throw err;
253
+ }
254
+ }
255
+ return {
256
+ name: opts.name ?? "by-peer",
257
+ async get(vault, collection, id) {
258
+ return call("get", [vault, collection, id]);
259
+ },
260
+ async put(vault, collection, id, envelope, expectedVersion) {
261
+ await call("put", [vault, collection, id, envelope, expectedVersion]);
262
+ },
263
+ async delete(vault, collection, id) {
264
+ await call("delete", [vault, collection, id]);
265
+ },
266
+ async list(vault, collection) {
267
+ return call("list", [vault, collection]);
268
+ },
269
+ async loadAll(vault) {
270
+ return call("loadAll", [vault]);
271
+ },
272
+ async saveAll(vault, data) {
273
+ await call("saveAll", [vault, data]);
274
+ },
275
+ async ping() {
276
+ try {
277
+ return await call("ping", []);
278
+ } catch {
279
+ return false;
280
+ }
281
+ },
282
+ dispose() {
283
+ rpc.dispose();
284
+ }
285
+ };
286
+ }
287
+
288
+ // src/serve.ts
289
+ var CORE_METHODS = /* @__PURE__ */ new Set([
290
+ "get",
291
+ "put",
292
+ "delete",
293
+ "list",
294
+ "loadAll",
295
+ "saveAll",
296
+ "ping",
297
+ "listSince",
298
+ "listPage",
299
+ "listVaults"
300
+ ]);
301
+ function servePeerStore(opts) {
302
+ if (!opts.leaderElection) {
303
+ return startServing(opts);
304
+ }
305
+ return startServingWithLeaderElection(opts);
306
+ }
307
+ function startServing(opts) {
308
+ const { store, channel, allow } = opts;
309
+ return serveRpc(channel, async (method, args) => {
310
+ if (!CORE_METHODS.has(method)) {
311
+ throw new Error(`Unknown RPC method: ${method}`);
312
+ }
313
+ if (allow && !allow.has(method)) {
314
+ throw new Error(`Method not allowed: ${method}`);
315
+ }
316
+ switch (method) {
317
+ case "get": {
318
+ const [vault, collection, id] = args;
319
+ return store.get(vault, collection, id);
320
+ }
321
+ case "put": {
322
+ const [vault, collection, id, envelope, expectedVersion] = args;
323
+ await store.put(vault, collection, id, envelope, expectedVersion);
324
+ return null;
325
+ }
326
+ case "delete": {
327
+ const [vault, collection, id] = args;
328
+ await store.delete(vault, collection, id);
329
+ return null;
330
+ }
331
+ case "list": {
332
+ const [vault, collection] = args;
333
+ return store.list(vault, collection);
334
+ }
335
+ case "loadAll": {
336
+ const [vault] = args;
337
+ return store.loadAll(vault);
338
+ }
339
+ case "saveAll": {
340
+ const [vault, data] = args;
341
+ await store.saveAll(vault, data);
342
+ return null;
343
+ }
344
+ case "ping": {
345
+ if (!store.ping) return true;
346
+ return store.ping();
347
+ }
348
+ case "listSince": {
349
+ if (!store.listSince) throw new Error("listSince not supported by remote store");
350
+ const [vault, collection, since] = args;
351
+ return store.listSince(vault, collection, since);
352
+ }
353
+ case "listPage": {
354
+ if (!store.listPage) throw new Error("listPage not supported by remote store");
355
+ const [vault, collection, cursor, limit] = args;
356
+ return store.listPage(vault, collection, cursor, limit);
357
+ }
358
+ case "listVaults": {
359
+ if (!store.listVaults) throw new Error("listVaults not supported by remote store");
360
+ return store.listVaults();
361
+ }
362
+ }
363
+ throw new Error(`Unhandled method: ${method}`);
364
+ });
365
+ }
366
+ function startServingWithLeaderElection(opts) {
367
+ const leader = opts.leaderElection;
368
+ const locks = leader.locks ?? getDefaultLocks();
369
+ const ac = new AbortController();
370
+ let stopServing = null;
371
+ let releaseLock = null;
372
+ let disposed = false;
373
+ void locks.request(leader.lockName, { mode: "exclusive", signal: ac.signal }, () => {
374
+ if (disposed) return Promise.resolve();
375
+ stopServing = startServing(opts);
376
+ return new Promise((resolve) => {
377
+ releaseLock = resolve;
378
+ });
379
+ }).catch((err) => {
380
+ if (err?.name === "AbortError") return;
381
+ queueMicrotask(() => {
382
+ throw err;
383
+ });
384
+ });
385
+ return () => {
386
+ if (disposed) return;
387
+ disposed = true;
388
+ if (stopServing) {
389
+ stopServing();
390
+ stopServing = null;
391
+ }
392
+ if (releaseLock) {
393
+ releaseLock();
394
+ releaseLock = null;
395
+ } else {
396
+ ac.abort();
397
+ }
398
+ };
399
+ }
400
+ function getDefaultLocks() {
401
+ const nav = globalThis.navigator;
402
+ if (nav && nav.locks) return nav.locks;
403
+ throw new Error(
404
+ "leaderElection requires the Web Locks API (navigator.locks). Use Chrome 69+, Firefox 96+, Safari 15.4+, or pass `leaderElection.locks: <stub>` for tests / non-browser hosts."
405
+ );
406
+ }
407
+
408
+ // src/webrtc.ts
409
+ function requireRTC() {
410
+ const g = globalThis;
411
+ if (!g.RTCPeerConnection) {
412
+ throw new Error(
413
+ "[@noy-db/by-peer] globalThis.RTCPeerConnection is undefined \u2014 use this module in a browser, or polyfill with @roamhq/wrtc in Node"
414
+ );
415
+ }
416
+ return g.RTCPeerConnection;
417
+ }
418
+ async function createOffer(opts = {}) {
419
+ const RTC = requireRTC();
420
+ const pc = new RTC({
421
+ ...opts.iceServers && { iceServers: opts.iceServers }
422
+ });
423
+ const dc = pc.createDataChannel(opts.label ?? "noydb", {
424
+ ordered: true
425
+ });
426
+ const channelPromise = new Promise((resolve, reject) => {
427
+ dc.addEventListener("open", () => resolve(fromDataChannel(dc)));
428
+ dc.addEventListener("error", () => reject(new Error("DataChannel error")));
429
+ });
430
+ const offer = await pc.createOffer();
431
+ await pc.setLocalDescription(offer);
432
+ await waitForIceComplete(pc);
433
+ return {
434
+ offer: pc.localDescription ?? offer,
435
+ connection: pc,
436
+ channel: channelPromise,
437
+ async accept(answer) {
438
+ await pc.setRemoteDescription(answer);
439
+ }
440
+ };
441
+ }
442
+ async function acceptOffer(offer, opts = {}) {
443
+ const RTC = requireRTC();
444
+ const pc = new RTC({
445
+ ...opts.iceServers && { iceServers: opts.iceServers }
446
+ });
447
+ const channelPromise = new Promise((resolve, reject) => {
448
+ pc.addEventListener("datachannel", (ev) => {
449
+ const dc = ev.channel;
450
+ if (dc.readyState === "open") {
451
+ resolve(fromDataChannel(dc));
452
+ } else {
453
+ dc.addEventListener("open", () => resolve(fromDataChannel(dc)));
454
+ dc.addEventListener("error", () => reject(new Error("DataChannel error")));
455
+ }
456
+ });
457
+ });
458
+ await pc.setRemoteDescription(offer);
459
+ const answer = await pc.createAnswer();
460
+ await pc.setLocalDescription(answer);
461
+ await waitForIceComplete(pc);
462
+ return {
463
+ answer: pc.localDescription ?? answer,
464
+ connection: pc,
465
+ channel: channelPromise
466
+ };
467
+ }
468
+ function waitForIceComplete(pc) {
469
+ if (pc.iceGatheringState === "complete") return Promise.resolve();
470
+ return new Promise((resolve) => {
471
+ const check = () => {
472
+ if (pc.iceGatheringState === "complete") {
473
+ pc.removeEventListener("icegatheringstatechange", check);
474
+ resolve();
475
+ }
476
+ };
477
+ pc.addEventListener("icegatheringstatechange", check);
478
+ });
479
+ }
480
+ // Annotate the CommonJS export names for ESM import in node:
481
+ 0 && (module.exports = {
482
+ acceptOffer,
483
+ createOffer,
484
+ createRpcClient,
485
+ fromDataChannel,
486
+ pairInMemory,
487
+ peerStore,
488
+ servePeerStore,
489
+ serveRpc
490
+ });
491
+ //# sourceMappingURL=index.cjs.map