@muhkoo/connect 0.8.0-alpha.0 → 0.9.0-alpha.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/dist/browser/blockEngine.worker.js +349 -0
- package/dist/browser/blockEngine.worker.js.map +1 -0
- package/dist/browser/index.js +909 -80
- package/dist/browser/index.js.map +1 -1
- package/dist/connect.d.ts +445 -3
- package/dist/server/index.js +909 -80
- package/dist/server/index.js.map +1 -1
- package/package.json +5 -2
package/dist/browser/index.js
CHANGED
|
@@ -3814,6 +3814,7 @@ class ShardClient {
|
|
|
3814
3814
|
fetchFn;
|
|
3815
3815
|
cache;
|
|
3816
3816
|
deferUpload;
|
|
3817
|
+
peers;
|
|
3817
3818
|
constructor(opts) {
|
|
3818
3819
|
if (!opts?.baseUrl)
|
|
3819
3820
|
throw new Error("ShardClient: `baseUrl` is required");
|
|
@@ -3831,6 +3832,7 @@ class ShardClient {
|
|
|
3831
3832
|
this.fetchFn = f.bind(globalThis);
|
|
3832
3833
|
this.cache = opts.cache;
|
|
3833
3834
|
this.deferUpload = opts.deferUpload;
|
|
3835
|
+
this.peers = opts.peers;
|
|
3834
3836
|
}
|
|
3835
3837
|
/** Build the URL for a given shard hash — exposed for callers that want to log or pre-resolve. */
|
|
3836
3838
|
urlForHash(hash) {
|
|
@@ -3849,6 +3851,9 @@ class ShardClient {
|
|
|
3849
3851
|
// this is safe to keep regardless of upload outcome.
|
|
3850
3852
|
if (this.cache)
|
|
3851
3853
|
await this.cache.put(hash, bytes);
|
|
3854
|
+
// Tell the swarm we now hold this block, so peers can pull it from us.
|
|
3855
|
+
if (this.peers)
|
|
3856
|
+
this.peers.announce(hash);
|
|
3852
3857
|
let response;
|
|
3853
3858
|
try {
|
|
3854
3859
|
response = await this.fetchFn(url, {
|
|
@@ -3892,6 +3897,22 @@ class ShardClient {
|
|
|
3892
3897
|
if (cached)
|
|
3893
3898
|
return cached;
|
|
3894
3899
|
}
|
|
3900
|
+
// Peers next: ask the Space swarm before spending origin egress. Peer
|
|
3901
|
+
// blocks are hash-verified by the block engine; on miss/timeout we fall
|
|
3902
|
+
// through to origin. Best-effort — never let a peer error block a read.
|
|
3903
|
+
if (this.peers) {
|
|
3904
|
+
try {
|
|
3905
|
+
const fromPeer = await this.peers.getBlock(hash);
|
|
3906
|
+
if (fromPeer) {
|
|
3907
|
+
if (this.cache)
|
|
3908
|
+
void this.cache.put(hash, fromPeer);
|
|
3909
|
+
return fromPeer;
|
|
3910
|
+
}
|
|
3911
|
+
}
|
|
3912
|
+
catch {
|
|
3913
|
+
/* peer layer hiccup — fall through to origin */
|
|
3914
|
+
}
|
|
3915
|
+
}
|
|
3895
3916
|
const url = this.urlForHash(hash);
|
|
3896
3917
|
let response;
|
|
3897
3918
|
try {
|
|
@@ -4940,7 +4961,7 @@ function base64UrlDecode(s) {
|
|
|
4940
4961
|
* expect (and isn't serving a stale cached bundle). Keep this in sync with
|
|
4941
4962
|
* `package.json` `version`.
|
|
4942
4963
|
*/
|
|
4943
|
-
const VERSION = "0.
|
|
4964
|
+
const VERSION = "0.9.0-alpha.0";
|
|
4944
4965
|
|
|
4945
4966
|
/**
|
|
4946
4967
|
* `HttpClient` — the one place the unified {@link Client} talks to the
|
|
@@ -7037,6 +7058,8 @@ class Space {
|
|
|
7037
7058
|
events = new EventTarget();
|
|
7038
7059
|
rawWired = false;
|
|
7039
7060
|
catchUpUnsub = null;
|
|
7061
|
+
/** Optional P2P swarm for this Space (block exchange among members). */
|
|
7062
|
+
peerNet = null;
|
|
7040
7063
|
constructor(deps) {
|
|
7041
7064
|
this.deps = deps;
|
|
7042
7065
|
this.name = deps.name;
|
|
@@ -7085,6 +7108,9 @@ class Space {
|
|
|
7085
7108
|
if (this.deps.keyring) {
|
|
7086
7109
|
this.channel.on(BroadcastChannelEvents.CONNECTED, () => {
|
|
7087
7110
|
this.channel?.sendRaw({ name: this.deps.myId() });
|
|
7111
|
+
// Kick P2P peer discovery now the signaling channel is live
|
|
7112
|
+
// (re-runs on each reconnect to re-form the mesh).
|
|
7113
|
+
this.peerNet?.start();
|
|
7088
7114
|
});
|
|
7089
7115
|
this.wireFanout();
|
|
7090
7116
|
// Register offline catch-up once: on each reconnect the sync
|
|
@@ -7100,6 +7126,27 @@ class Space {
|
|
|
7100
7126
|
this.channel?.disconnect();
|
|
7101
7127
|
this.catchUpUnsub?.();
|
|
7102
7128
|
this.catchUpUnsub = null;
|
|
7129
|
+
this.peerNet?.close();
|
|
7130
|
+
this.peerNet = null;
|
|
7131
|
+
}
|
|
7132
|
+
/** Attach a P2P swarm so this Space's file reads/writes can use peers. */
|
|
7133
|
+
attachPeerNetwork(net) {
|
|
7134
|
+
this.peerNet = net;
|
|
7135
|
+
}
|
|
7136
|
+
/**
|
|
7137
|
+
* Broadcast an app payload directly to connected Space peers over P2P,
|
|
7138
|
+
* **bypassing the server** — for CRDT ops / high-frequency state (live
|
|
7139
|
+
* cursors, presence, collaborative edits) you don't want to push through the
|
|
7140
|
+
* relay. No-op when no P2P mesh is active (requires `client` `p2p`).
|
|
7141
|
+
* Unlike {@link sendEphemeral} (server-relayed, metered, size-limited), this
|
|
7142
|
+
* is peer-direct and fragments large payloads.
|
|
7143
|
+
*/
|
|
7144
|
+
gossipToPeers(data) {
|
|
7145
|
+
this.peerNet?.gossip(data);
|
|
7146
|
+
}
|
|
7147
|
+
/** Subscribe to peer gossip ({@link gossipToPeers}). Returns an unsubscribe fn. */
|
|
7148
|
+
onPeerGossip(handler) {
|
|
7149
|
+
return this.peerNet?.onGossip(handler) ?? (() => { });
|
|
7103
7150
|
}
|
|
7104
7151
|
/** Subscribe to a raw channel event. Buffered until `connect()` if early. */
|
|
7105
7152
|
on(event, handler) {
|
|
@@ -7269,10 +7316,12 @@ class Space {
|
|
|
7269
7316
|
* indicators, presence, or live cursors are built on top of this. No-op if
|
|
7270
7317
|
* the space isn't connected (ephemeral signals aren't worth queuing).
|
|
7271
7318
|
*/
|
|
7272
|
-
sendEphemeral(subject, data) {
|
|
7319
|
+
sendEphemeral(subject, data, to) {
|
|
7273
7320
|
if (!this.isConnected())
|
|
7274
7321
|
return;
|
|
7275
|
-
|
|
7322
|
+
// `to` (a member id) lets the server unicast a *directed* ephemeral
|
|
7323
|
+
// instead of broadcasting; omitted ⇒ broadcast to the room (unchanged).
|
|
7324
|
+
this.sendRaw({ pub: to ? { subject, data, to } : { subject, data } });
|
|
7276
7325
|
}
|
|
7277
7326
|
/** Subscribe to inbound ephemeral signals. `from` is the authenticated sender. */
|
|
7278
7327
|
onEphemeral(handler) {
|
|
@@ -7392,6 +7441,7 @@ class Space {
|
|
|
7392
7441
|
fetch: this.deps.fetch,
|
|
7393
7442
|
cache: this.deps.shardCache,
|
|
7394
7443
|
deferUpload: this.deps.deferShardUpload,
|
|
7444
|
+
peers: this.peerNet?.exchange,
|
|
7395
7445
|
});
|
|
7396
7446
|
return new FileStorage({ shards });
|
|
7397
7447
|
}
|
|
@@ -8310,6 +8360,849 @@ class SpaceCache {
|
|
|
8310
8360
|
}
|
|
8311
8361
|
}
|
|
8312
8362
|
|
|
8363
|
+
/**
|
|
8364
|
+
* `ShardCache` — a Cache-API-backed store for file-shard bytes. Shards are
|
|
8365
|
+
* content-addressed (the key IS the SHA-256 of the ciphertext), so caching them
|
|
8366
|
+
* is uniquely safe: a cache hit is self-validating, immutable, and shareable
|
|
8367
|
+
* across files. The Cache API (rather than IndexedDB) is the right home for
|
|
8368
|
+
* large binary blobs — it streams, it's purpose-built for response bodies, and
|
|
8369
|
+
* it has its own eviction.
|
|
8370
|
+
*
|
|
8371
|
+
* Keys are SYNTHETIC absolute URLs (`https://shard.muhkoo.local/<hash>`) rather
|
|
8372
|
+
* than the real shard endpoint, so the cache is addressable by hash alone — the
|
|
8373
|
+
* sync engine can re-PUT a deferred shard knowing only its hash, without
|
|
8374
|
+
* reconstructing the accelerator URL. We never fetch these synthetic URLs; they
|
|
8375
|
+
* exist only as cache keys.
|
|
8376
|
+
*
|
|
8377
|
+
* Every method degrades to a no-op / null where the Cache API is unavailable
|
|
8378
|
+
* (Node, Workers, insecure contexts), so callers don't need to branch.
|
|
8379
|
+
*/
|
|
8380
|
+
const CACHE_NAME = "muhkoo-shards-v1";
|
|
8381
|
+
class ShardCache {
|
|
8382
|
+
static available() {
|
|
8383
|
+
try {
|
|
8384
|
+
return typeof caches !== "undefined";
|
|
8385
|
+
}
|
|
8386
|
+
catch {
|
|
8387
|
+
return false;
|
|
8388
|
+
}
|
|
8389
|
+
}
|
|
8390
|
+
key(hash) {
|
|
8391
|
+
return `https://shard.muhkoo.local/${encodeURIComponent(hash)}`;
|
|
8392
|
+
}
|
|
8393
|
+
open() {
|
|
8394
|
+
return caches.open(CACHE_NAME);
|
|
8395
|
+
}
|
|
8396
|
+
/** Cached shard bytes, or `null` on miss / when unavailable. */
|
|
8397
|
+
async get(hash) {
|
|
8398
|
+
if (!ShardCache.available())
|
|
8399
|
+
return null;
|
|
8400
|
+
try {
|
|
8401
|
+
const cache = await this.open();
|
|
8402
|
+
const res = await cache.match(this.key(hash));
|
|
8403
|
+
if (!res)
|
|
8404
|
+
return null;
|
|
8405
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
8406
|
+
}
|
|
8407
|
+
catch {
|
|
8408
|
+
return null;
|
|
8409
|
+
}
|
|
8410
|
+
}
|
|
8411
|
+
/** Store shard bytes under their hash. Best-effort. */
|
|
8412
|
+
async put(hash, bytes) {
|
|
8413
|
+
if (!ShardCache.available())
|
|
8414
|
+
return;
|
|
8415
|
+
try {
|
|
8416
|
+
const cache = await this.open();
|
|
8417
|
+
await cache.put(this.key(hash), new Response(bytes, {
|
|
8418
|
+
headers: { "content-type": "application/octet-stream" },
|
|
8419
|
+
}));
|
|
8420
|
+
}
|
|
8421
|
+
catch {
|
|
8422
|
+
/* quota / unavailable — degrade to online-only for this shard */
|
|
8423
|
+
}
|
|
8424
|
+
}
|
|
8425
|
+
/** Whether a shard is present in the cache. */
|
|
8426
|
+
async has(hash) {
|
|
8427
|
+
if (!ShardCache.available())
|
|
8428
|
+
return false;
|
|
8429
|
+
try {
|
|
8430
|
+
const cache = await this.open();
|
|
8431
|
+
return Boolean(await cache.match(this.key(hash)));
|
|
8432
|
+
}
|
|
8433
|
+
catch {
|
|
8434
|
+
return false;
|
|
8435
|
+
}
|
|
8436
|
+
}
|
|
8437
|
+
}
|
|
8438
|
+
|
|
8439
|
+
/**
|
|
8440
|
+
* `PeerExchange` — the thin façade the storage layer consults to try fetching a
|
|
8441
|
+
* block from peers before falling back to origin. It satisfies the structural
|
|
8442
|
+
* `PeerBlockSource` interface that {@link ../storage/transport/ShardClient}
|
|
8443
|
+
* declares (so storage never imports the p2p module — the dependency arrow stays
|
|
8444
|
+
* one-way, p2p → storage).
|
|
8445
|
+
*
|
|
8446
|
+
* It delegates to an {@link EngineHandle} — either an in-process {@link
|
|
8447
|
+
* ./worker/blockEngine.BlockEngine} or the Web Worker proxy — so the same façade
|
|
8448
|
+
* works whether the block engine runs on the main thread or off it.
|
|
8449
|
+
*/
|
|
8450
|
+
class PeerExchange {
|
|
8451
|
+
engine;
|
|
8452
|
+
timeoutMs;
|
|
8453
|
+
constructor(engine, opts = {}) {
|
|
8454
|
+
this.engine = engine;
|
|
8455
|
+
this.timeoutMs = opts.timeoutMs ?? 2500;
|
|
8456
|
+
}
|
|
8457
|
+
getBlock(hash, opts) {
|
|
8458
|
+
return this.engine.want(hash, opts?.timeoutMs ?? this.timeoutMs);
|
|
8459
|
+
}
|
|
8460
|
+
announce(hash) {
|
|
8461
|
+
this.engine.announce(hash);
|
|
8462
|
+
}
|
|
8463
|
+
}
|
|
8464
|
+
|
|
8465
|
+
/**
|
|
8466
|
+
* `SpaceSignaler` — WebRTC signaling carried over the Space's existing ephemeral
|
|
8467
|
+
* relay. No new server channel: `Space.sendEphemeral`/`onEphemeral` already
|
|
8468
|
+
* blind-relay `pub` frames stamped with the authenticated sender, so SDP
|
|
8469
|
+
* offers/answers and ICE candidates ride a reserved subject (`__p2p__`) scoped
|
|
8470
|
+
* to Space membership. The sender (`from`) is server-stamped and can't be
|
|
8471
|
+
* spoofed; the payload is opaque to the server.
|
|
8472
|
+
*
|
|
8473
|
+
* Envelopes carry a `to` (a member id, or `"*"` for discovery) so the
|
|
8474
|
+
* accelerator can optionally unicast peer-pair signaling instead of broadcasting
|
|
8475
|
+
* (a small, backward-compatible `pub` enhancement); the client filters by `to`
|
|
8476
|
+
* regardless.
|
|
8477
|
+
*/
|
|
8478
|
+
const P2P_SUBJECT = "__p2p__";
|
|
8479
|
+
class SpaceSignaler {
|
|
8480
|
+
space;
|
|
8481
|
+
myId;
|
|
8482
|
+
constructor(space, myId) {
|
|
8483
|
+
this.space = space;
|
|
8484
|
+
this.myId = myId;
|
|
8485
|
+
}
|
|
8486
|
+
/** Send a signal to one peer (or `"*"`). */
|
|
8487
|
+
send(to, kind, data) {
|
|
8488
|
+
const env = { to, kind, data };
|
|
8489
|
+
// Ask the server to unicast for a directed target; broadcast for "*".
|
|
8490
|
+
// `to` also stays in the envelope so a broadcast-only server still works
|
|
8491
|
+
// (the client filters by it).
|
|
8492
|
+
this.space.sendEphemeral(P2P_SUBJECT, env, to !== "*" ? to : undefined);
|
|
8493
|
+
}
|
|
8494
|
+
/** Announce/withdraw P2P availability to all Space members. */
|
|
8495
|
+
broadcast(kind, data) {
|
|
8496
|
+
this.send("*", kind, data);
|
|
8497
|
+
}
|
|
8498
|
+
/**
|
|
8499
|
+
* Subscribe to inbound signals addressed to us (or broadcast). Skips our own
|
|
8500
|
+
* echoes. Returns an unsubscribe function.
|
|
8501
|
+
*/
|
|
8502
|
+
onSignal(cb) {
|
|
8503
|
+
return this.space.onEphemeral((e) => {
|
|
8504
|
+
if (e.subject !== P2P_SUBJECT || e.from === this.myId)
|
|
8505
|
+
return;
|
|
8506
|
+
const env = e.data;
|
|
8507
|
+
if (!env || typeof env.kind !== "string")
|
|
8508
|
+
return;
|
|
8509
|
+
if (env.to !== this.myId && env.to !== "*")
|
|
8510
|
+
return;
|
|
8511
|
+
cb(e.from, env.kind, env.data);
|
|
8512
|
+
});
|
|
8513
|
+
}
|
|
8514
|
+
}
|
|
8515
|
+
|
|
8516
|
+
/**
|
|
8517
|
+
* `WebRtcTransport` — a {@link ./PeerTransport.PeerTransport} over RTCDataChannels,
|
|
8518
|
+
* signaled by {@link ../signaling/SpaceSignaler}. Runs on the **main thread**
|
|
8519
|
+
* (`RTCPeerConnection` is Window-only — not available in Workers), so the block
|
|
8520
|
+
* engine lives in a Worker and this only shuttles bytes.
|
|
8521
|
+
*
|
|
8522
|
+
* Connection setup uses a deterministic initiator (the peer with the smaller id
|
|
8523
|
+
* dials) to avoid offer/answer glare. STUN handles NAT traversal; there is no
|
|
8524
|
+
* TURN — an unreachable peer simply never connects and the caller falls back to
|
|
8525
|
+
* origin (P2P is best-effort).
|
|
8526
|
+
*
|
|
8527
|
+
* Frames (up to a 4 MiB shard) exceed an RTCDataChannel's max message size, so
|
|
8528
|
+
* each frame is fragmented into `CHUNK`-sized pieces with a 12-byte header
|
|
8529
|
+
* `[msgId u32][index u32][total u32]` and reassembled per peer.
|
|
8530
|
+
*/
|
|
8531
|
+
const CHUNK = 16 * 1024;
|
|
8532
|
+
// [msgId u32][index u32][total u32][channel u32]
|
|
8533
|
+
const HEADER$1 = 16;
|
|
8534
|
+
/** Pause sends when a channel has this many bytes buffered (backpressure). */
|
|
8535
|
+
const MAX_BUFFERED = 4 * 1024 * 1024;
|
|
8536
|
+
const DEFAULT_ICE = [{ urls: "stun:stun.cloudflare.com:3478" }];
|
|
8537
|
+
class WebRtcTransport {
|
|
8538
|
+
signaler;
|
|
8539
|
+
myId;
|
|
8540
|
+
conns = new Map();
|
|
8541
|
+
msgCbs = new Set();
|
|
8542
|
+
peerCbs = new Set();
|
|
8543
|
+
offSignal;
|
|
8544
|
+
iceServers;
|
|
8545
|
+
maxPeers;
|
|
8546
|
+
nextMsgId = 1;
|
|
8547
|
+
constructor(signaler, myId, opts = {}) {
|
|
8548
|
+
this.signaler = signaler;
|
|
8549
|
+
this.myId = myId;
|
|
8550
|
+
this.iceServers = opts.iceServers ?? DEFAULT_ICE;
|
|
8551
|
+
this.maxPeers = opts.maxPeers ?? 8;
|
|
8552
|
+
this.offSignal = this.signaler.onSignal((from, kind, data) => this.onSignal(from, kind, data));
|
|
8553
|
+
}
|
|
8554
|
+
peers() {
|
|
8555
|
+
return [...this.conns].filter(([, c]) => c.dc?.readyState === "open").map(([id]) => id);
|
|
8556
|
+
}
|
|
8557
|
+
/** Dial a peer (no-op unless we're the deterministic initiator for the pair). */
|
|
8558
|
+
connectTo(peerId) {
|
|
8559
|
+
if (peerId === this.myId || this.conns.has(peerId))
|
|
8560
|
+
return;
|
|
8561
|
+
if (this.conns.size >= this.maxPeers)
|
|
8562
|
+
return; // mesh cap
|
|
8563
|
+
if (this.myId < peerId)
|
|
8564
|
+
void this.initiate(peerId); // smaller id dials
|
|
8565
|
+
}
|
|
8566
|
+
send(peer, data, channel = 0) {
|
|
8567
|
+
const dc = this.conns.get(peer)?.dc;
|
|
8568
|
+
if (dc?.readyState === "open")
|
|
8569
|
+
void this.sendFragmented(dc, data, channel);
|
|
8570
|
+
}
|
|
8571
|
+
broadcast(data, channel = 0) {
|
|
8572
|
+
for (const [, c] of this.conns) {
|
|
8573
|
+
if (c.dc?.readyState === "open")
|
|
8574
|
+
void this.sendFragmented(c.dc, data, channel);
|
|
8575
|
+
}
|
|
8576
|
+
}
|
|
8577
|
+
onMessage(cb) {
|
|
8578
|
+
this.msgCbs.add(cb);
|
|
8579
|
+
return () => this.msgCbs.delete(cb);
|
|
8580
|
+
}
|
|
8581
|
+
onPeerChange(cb) {
|
|
8582
|
+
this.peerCbs.add(cb);
|
|
8583
|
+
return () => this.peerCbs.delete(cb);
|
|
8584
|
+
}
|
|
8585
|
+
close() {
|
|
8586
|
+
this.offSignal();
|
|
8587
|
+
for (const [, c] of this.conns) {
|
|
8588
|
+
try {
|
|
8589
|
+
c.dc?.close();
|
|
8590
|
+
}
|
|
8591
|
+
catch { /* already closing */ }
|
|
8592
|
+
try {
|
|
8593
|
+
c.pc.close();
|
|
8594
|
+
}
|
|
8595
|
+
catch { /* already closing */ }
|
|
8596
|
+
}
|
|
8597
|
+
this.conns.clear();
|
|
8598
|
+
this.msgCbs.clear();
|
|
8599
|
+
this.peerCbs.clear();
|
|
8600
|
+
}
|
|
8601
|
+
// -- internals -----------------------------------------------------------
|
|
8602
|
+
newPc(peerId) {
|
|
8603
|
+
const pc = new RTCPeerConnection({ iceServers: this.iceServers });
|
|
8604
|
+
const conn = { pc, reasm: new Map() };
|
|
8605
|
+
this.conns.set(peerId, conn);
|
|
8606
|
+
pc.onicecandidate = (e) => {
|
|
8607
|
+
if (e.candidate)
|
|
8608
|
+
this.signaler.send(peerId, "ice", e.candidate.toJSON());
|
|
8609
|
+
};
|
|
8610
|
+
pc.onconnectionstatechange = () => {
|
|
8611
|
+
if (["failed", "closed", "disconnected"].includes(pc.connectionState))
|
|
8612
|
+
this.drop(peerId);
|
|
8613
|
+
};
|
|
8614
|
+
pc.ondatachannel = (e) => this.bindChannel(peerId, conn, e.channel);
|
|
8615
|
+
return conn;
|
|
8616
|
+
}
|
|
8617
|
+
async initiate(peerId) {
|
|
8618
|
+
const conn = this.newPc(peerId);
|
|
8619
|
+
const dc = conn.pc.createDataChannel("blocks");
|
|
8620
|
+
this.bindChannel(peerId, conn, dc);
|
|
8621
|
+
const offer = await conn.pc.createOffer();
|
|
8622
|
+
await conn.pc.setLocalDescription(offer);
|
|
8623
|
+
this.signaler.send(peerId, "offer", offer);
|
|
8624
|
+
}
|
|
8625
|
+
bindChannel(peerId, conn, dc) {
|
|
8626
|
+
dc.binaryType = "arraybuffer";
|
|
8627
|
+
conn.dc = dc;
|
|
8628
|
+
dc.onopen = () => this.firePeerChange();
|
|
8629
|
+
dc.onclose = () => this.drop(peerId);
|
|
8630
|
+
dc.onmessage = (e) => this.onChunk(peerId, conn, new Uint8Array(e.data));
|
|
8631
|
+
}
|
|
8632
|
+
async onSignal(from, kind, data) {
|
|
8633
|
+
if (kind === "offer") {
|
|
8634
|
+
if (!this.conns.has(from) && this.conns.size >= this.maxPeers)
|
|
8635
|
+
return; // mesh cap
|
|
8636
|
+
const conn = this.conns.get(from) ?? this.newPc(from);
|
|
8637
|
+
await conn.pc.setRemoteDescription(data);
|
|
8638
|
+
const answer = await conn.pc.createAnswer();
|
|
8639
|
+
await conn.pc.setLocalDescription(answer);
|
|
8640
|
+
this.signaler.send(from, "answer", answer);
|
|
8641
|
+
}
|
|
8642
|
+
else if (kind === "answer") {
|
|
8643
|
+
const conn = this.conns.get(from);
|
|
8644
|
+
if (conn)
|
|
8645
|
+
await conn.pc.setRemoteDescription(data);
|
|
8646
|
+
}
|
|
8647
|
+
else if (kind === "ice") {
|
|
8648
|
+
const conn = this.conns.get(from);
|
|
8649
|
+
if (conn && data) {
|
|
8650
|
+
try {
|
|
8651
|
+
await conn.pc.addIceCandidate(data);
|
|
8652
|
+
}
|
|
8653
|
+
catch { /* pre-SRD race */ }
|
|
8654
|
+
}
|
|
8655
|
+
}
|
|
8656
|
+
else if (kind === "bye") {
|
|
8657
|
+
this.drop(from);
|
|
8658
|
+
}
|
|
8659
|
+
}
|
|
8660
|
+
drop(peerId) {
|
|
8661
|
+
const conn = this.conns.get(peerId);
|
|
8662
|
+
if (!conn)
|
|
8663
|
+
return;
|
|
8664
|
+
this.conns.delete(peerId);
|
|
8665
|
+
try {
|
|
8666
|
+
conn.dc?.close();
|
|
8667
|
+
}
|
|
8668
|
+
catch { /* already closing */ }
|
|
8669
|
+
try {
|
|
8670
|
+
conn.pc.close();
|
|
8671
|
+
}
|
|
8672
|
+
catch { /* already closing */ }
|
|
8673
|
+
this.firePeerChange();
|
|
8674
|
+
}
|
|
8675
|
+
firePeerChange() {
|
|
8676
|
+
const peers = this.peers();
|
|
8677
|
+
for (const cb of this.peerCbs)
|
|
8678
|
+
cb(peers);
|
|
8679
|
+
}
|
|
8680
|
+
// -- fragmentation -------------------------------------------------------
|
|
8681
|
+
async sendFragmented(dc, data, channel) {
|
|
8682
|
+
const msgId = this.nextMsgId++ >>> 0;
|
|
8683
|
+
const total = Math.max(1, Math.ceil(data.length / CHUNK));
|
|
8684
|
+
for (let i = 0; i < total; i++) {
|
|
8685
|
+
const slice = data.subarray(i * CHUNK, (i + 1) * CHUNK);
|
|
8686
|
+
const frame = new Uint8Array(HEADER$1 + slice.length);
|
|
8687
|
+
const dv = new DataView(frame.buffer);
|
|
8688
|
+
dv.setUint32(0, msgId);
|
|
8689
|
+
dv.setUint32(4, i);
|
|
8690
|
+
dv.setUint32(8, total);
|
|
8691
|
+
dv.setUint32(12, channel);
|
|
8692
|
+
frame.set(slice, HEADER$1);
|
|
8693
|
+
if (dc.bufferedAmount > MAX_BUFFERED)
|
|
8694
|
+
await this.drain(dc);
|
|
8695
|
+
dc.send(frame);
|
|
8696
|
+
}
|
|
8697
|
+
}
|
|
8698
|
+
drain(dc) {
|
|
8699
|
+
return new Promise((resolve) => {
|
|
8700
|
+
dc.bufferedAmountLowThreshold = MAX_BUFFERED / 2;
|
|
8701
|
+
const onLow = () => { dc.removeEventListener("bufferedamountlow", onLow); resolve(); };
|
|
8702
|
+
dc.addEventListener("bufferedamountlow", onLow);
|
|
8703
|
+
});
|
|
8704
|
+
}
|
|
8705
|
+
onChunk(peerId, conn, frame) {
|
|
8706
|
+
if (frame.length < HEADER$1)
|
|
8707
|
+
return;
|
|
8708
|
+
const dv = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
|
|
8709
|
+
const msgId = dv.getUint32(0);
|
|
8710
|
+
const index = dv.getUint32(4);
|
|
8711
|
+
const total = dv.getUint32(8);
|
|
8712
|
+
const channel = dv.getUint32(12);
|
|
8713
|
+
const chunk = frame.slice(HEADER$1);
|
|
8714
|
+
let r = conn.reasm.get(msgId);
|
|
8715
|
+
if (!r) {
|
|
8716
|
+
r = { chunks: new Array(total), received: 0, total, size: 0, channel };
|
|
8717
|
+
conn.reasm.set(msgId, r);
|
|
8718
|
+
}
|
|
8719
|
+
if (r.chunks[index])
|
|
8720
|
+
return; // dup
|
|
8721
|
+
r.chunks[index] = chunk;
|
|
8722
|
+
r.received++;
|
|
8723
|
+
r.size += chunk.length;
|
|
8724
|
+
if (r.received < r.total)
|
|
8725
|
+
return;
|
|
8726
|
+
conn.reasm.delete(msgId);
|
|
8727
|
+
const out = new Uint8Array(r.size);
|
|
8728
|
+
let off = 0;
|
|
8729
|
+
for (const c of r.chunks) {
|
|
8730
|
+
if (!c)
|
|
8731
|
+
return; // gap — shouldn't happen on reliable ordered
|
|
8732
|
+
out.set(c, off);
|
|
8733
|
+
off += c.length;
|
|
8734
|
+
}
|
|
8735
|
+
for (const cb of this.msgCbs)
|
|
8736
|
+
cb(peerId, out, r.channel);
|
|
8737
|
+
}
|
|
8738
|
+
}
|
|
8739
|
+
|
|
8740
|
+
/**
|
|
8741
|
+
* `PeerTransport` — the pluggable wire under the block engine. It moves opaque
|
|
8742
|
+
* frames between Space peers; it knows nothing about blocks, hashes, or the
|
|
8743
|
+
* protocol. {@link ./WebRtcTransport} implements it over RTCDataChannels today;
|
|
8744
|
+
* a future libp2p/Helia transport can implement the same interface without the
|
|
8745
|
+
* block engine or `ShardClient` changing.
|
|
8746
|
+
*
|
|
8747
|
+
* Implementations are responsible for fragmenting/ reassembling frames larger
|
|
8748
|
+
* than the underlying channel's max message size (a 4 MiB shard won't fit in one
|
|
8749
|
+
* `RTCDataChannel.send`); the engine always deals in whole frames.
|
|
8750
|
+
*/
|
|
8751
|
+
/**
|
|
8752
|
+
* Logical channels multiplexed over the one data link. `0` is block exchange
|
|
8753
|
+
* (the engine), `1` is gossip (app/CRDT data). The tag rides the framing header
|
|
8754
|
+
* so payloads aren't copied to prepend it.
|
|
8755
|
+
*/
|
|
8756
|
+
const CHANNEL_BLOCK = 0;
|
|
8757
|
+
const CHANNEL_GOSSIP = 1;
|
|
8758
|
+
|
|
8759
|
+
/**
|
|
8760
|
+
* Bitswap-lite wire protocol for peer block exchange. Four frame types over a
|
|
8761
|
+
* {@link ../transport/PeerTransport} data channel:
|
|
8762
|
+
*
|
|
8763
|
+
* WANT(hash) — "does anyone have this block?" / "send it to me"
|
|
8764
|
+
* HAVE(hash) — "I hold this block" (announce / reply to a WANT)
|
|
8765
|
+
* BLOCK(hash, bytes) — the block itself (verified by hash on receipt)
|
|
8766
|
+
* CANCEL(hash) — "stop, I got it elsewhere"
|
|
8767
|
+
*
|
|
8768
|
+
* Frames are compact binary so a 4 MiB shard isn't base64-bloated through JSON:
|
|
8769
|
+
*
|
|
8770
|
+
* byte 0 : type (0 WANT | 1 HAVE | 2 BLOCK | 3 CANCEL)
|
|
8771
|
+
* bytes 1..33 : 32-byte raw SHA-256 of the block (our shard hash, hex-decoded)
|
|
8772
|
+
* bytes 33.. : payload (BLOCK only)
|
|
8773
|
+
*
|
|
8774
|
+
* Blocks are AES-GCM ciphertext, so a frame carries nothing readable; the hash
|
|
8775
|
+
* is the capability check (the receiver re-hashes the payload and drops a
|
|
8776
|
+
* mismatch). See {@link ./blockEngine}.
|
|
8777
|
+
*/
|
|
8778
|
+
var FrameType;
|
|
8779
|
+
(function (FrameType) {
|
|
8780
|
+
FrameType[FrameType["WANT"] = 0] = "WANT";
|
|
8781
|
+
FrameType[FrameType["HAVE"] = 1] = "HAVE";
|
|
8782
|
+
FrameType[FrameType["BLOCK"] = 2] = "BLOCK";
|
|
8783
|
+
FrameType[FrameType["CANCEL"] = 3] = "CANCEL";
|
|
8784
|
+
})(FrameType || (FrameType = {}));
|
|
8785
|
+
const HASH_BYTES = 32;
|
|
8786
|
+
const HEADER = 1 + HASH_BYTES; // type + hash
|
|
8787
|
+
function hexToBytes(hex) {
|
|
8788
|
+
if (hex.length !== HASH_BYTES * 2) {
|
|
8789
|
+
throw new Error(`protocol: expected a ${HASH_BYTES}-byte hex hash, got ${hex.length / 2} bytes`);
|
|
8790
|
+
}
|
|
8791
|
+
const out = new Uint8Array(HASH_BYTES);
|
|
8792
|
+
for (let i = 0; i < HASH_BYTES; i++)
|
|
8793
|
+
out[i] = parseInt(hex.substr(i * 2, 2), 16);
|
|
8794
|
+
return out;
|
|
8795
|
+
}
|
|
8796
|
+
const HEX = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
|
|
8797
|
+
function bytesToHex(bytes) {
|
|
8798
|
+
let s = "";
|
|
8799
|
+
for (let i = 0; i < bytes.length; i++)
|
|
8800
|
+
s += HEX[bytes[i]];
|
|
8801
|
+
return s;
|
|
8802
|
+
}
|
|
8803
|
+
function header(type, hash, extra = 0) {
|
|
8804
|
+
const buf = new Uint8Array(HEADER + extra);
|
|
8805
|
+
buf[0] = type;
|
|
8806
|
+
buf.set(hexToBytes(hash), 1);
|
|
8807
|
+
return { buf, offset: HEADER };
|
|
8808
|
+
}
|
|
8809
|
+
function encodeWant(hash) {
|
|
8810
|
+
return header(FrameType.WANT, hash).buf;
|
|
8811
|
+
}
|
|
8812
|
+
function encodeHave(hash) {
|
|
8813
|
+
return header(FrameType.HAVE, hash).buf;
|
|
8814
|
+
}
|
|
8815
|
+
function encodeCancel(hash) {
|
|
8816
|
+
return header(FrameType.CANCEL, hash).buf;
|
|
8817
|
+
}
|
|
8818
|
+
function encodeBlock(hash, payload) {
|
|
8819
|
+
const { buf, offset } = header(FrameType.BLOCK, hash, payload.length);
|
|
8820
|
+
buf.set(payload, offset);
|
|
8821
|
+
return buf;
|
|
8822
|
+
}
|
|
8823
|
+
function decode(frame) {
|
|
8824
|
+
if (frame.length < HEADER)
|
|
8825
|
+
throw new Error("protocol: frame too short");
|
|
8826
|
+
const type = frame[0];
|
|
8827
|
+
const hash = bytesToHex(frame.subarray(1, HEADER));
|
|
8828
|
+
if (type === FrameType.BLOCK) {
|
|
8829
|
+
// Copy the payload out so it doesn't alias a transferred/pooled buffer.
|
|
8830
|
+
return { type, hash, payload: frame.slice(HEADER) };
|
|
8831
|
+
}
|
|
8832
|
+
return { type, hash };
|
|
8833
|
+
}
|
|
8834
|
+
|
|
8835
|
+
var protocol = /*#__PURE__*/Object.freeze({
|
|
8836
|
+
__proto__: null,
|
|
8837
|
+
get FrameType () { return FrameType; },
|
|
8838
|
+
decode: decode,
|
|
8839
|
+
encodeBlock: encodeBlock,
|
|
8840
|
+
encodeCancel: encodeCancel,
|
|
8841
|
+
encodeHave: encodeHave,
|
|
8842
|
+
encodeWant: encodeWant
|
|
8843
|
+
});
|
|
8844
|
+
|
|
8845
|
+
/**
|
|
8846
|
+
* `BlockEngine` — the bitswap-lite want/have ledger. Host-agnostic: it speaks
|
|
8847
|
+
* only to a {@link BlockStore} (get/put/has) and an `out` sink that ships
|
|
8848
|
+
* encoded frames to a peer (or `"*"` to broadcast). The host wires `out` and
|
|
8849
|
+
* `handleFrame` to a real transport — on the main thread or inside the Web
|
|
8850
|
+
* Worker (see {@link ./engineClient}) — so this class is pure and unit-testable.
|
|
8851
|
+
*
|
|
8852
|
+
* It never sees keys: blocks are AES-GCM ciphertext, addressed by SHA-256. The
|
|
8853
|
+
* one trust check is integrity — an inbound BLOCK is re-hashed and dropped on
|
|
8854
|
+
* mismatch, so a malicious peer can't poison the store (the requester just
|
|
8855
|
+
* falls back to origin).
|
|
8856
|
+
*/
|
|
8857
|
+
class BlockEngine {
|
|
8858
|
+
store;
|
|
8859
|
+
out;
|
|
8860
|
+
hash;
|
|
8861
|
+
wants = new Map();
|
|
8862
|
+
constructor(store, out, hash) {
|
|
8863
|
+
this.store = store;
|
|
8864
|
+
this.out = out;
|
|
8865
|
+
this.hash = hash;
|
|
8866
|
+
}
|
|
8867
|
+
/**
|
|
8868
|
+
* Ask the swarm for a block. Resolves with the verified bytes, or `null` on
|
|
8869
|
+
* timeout (caller then falls back to origin). Returns immediately if we
|
|
8870
|
+
* already hold it; concurrent wants for the same hash share one request.
|
|
8871
|
+
*/
|
|
8872
|
+
want(hash, timeoutMs) {
|
|
8873
|
+
return new Promise((resolve) => {
|
|
8874
|
+
void this.store.get(hash).then((local) => {
|
|
8875
|
+
if (local)
|
|
8876
|
+
return resolve(local);
|
|
8877
|
+
const existing = this.wants.get(hash);
|
|
8878
|
+
if (existing) {
|
|
8879
|
+
existing.resolvers.push(resolve);
|
|
8880
|
+
return;
|
|
8881
|
+
}
|
|
8882
|
+
const timer = setTimeout(() => {
|
|
8883
|
+
const w = this.wants.get(hash);
|
|
8884
|
+
this.wants.delete(hash);
|
|
8885
|
+
this.out("*", encodeCancel(hash));
|
|
8886
|
+
w?.resolvers.forEach((r) => r(null));
|
|
8887
|
+
}, timeoutMs);
|
|
8888
|
+
this.wants.set(hash, { resolvers: [resolve], timer });
|
|
8889
|
+
this.out("*", encodeWant(hash));
|
|
8890
|
+
});
|
|
8891
|
+
});
|
|
8892
|
+
}
|
|
8893
|
+
/** Tell the swarm we now hold `hash` (so peers wanting it can pull from us). */
|
|
8894
|
+
announce(hash) {
|
|
8895
|
+
this.out("*", encodeHave(hash));
|
|
8896
|
+
}
|
|
8897
|
+
/** Handle one inbound frame from `peer`. */
|
|
8898
|
+
async handleFrame(peer, frame) {
|
|
8899
|
+
let f;
|
|
8900
|
+
try {
|
|
8901
|
+
f = decode(frame);
|
|
8902
|
+
}
|
|
8903
|
+
catch {
|
|
8904
|
+
return; // malformed — ignore
|
|
8905
|
+
}
|
|
8906
|
+
switch (f.type) {
|
|
8907
|
+
case FrameType.WANT: {
|
|
8908
|
+
const bytes = await this.store.get(f.hash);
|
|
8909
|
+
if (bytes)
|
|
8910
|
+
this.out(peer, encodeBlock(f.hash, bytes));
|
|
8911
|
+
return;
|
|
8912
|
+
}
|
|
8913
|
+
case FrameType.HAVE: {
|
|
8914
|
+
// A peer announced a block we're still waiting for → pull from them.
|
|
8915
|
+
if (this.wants.has(f.hash))
|
|
8916
|
+
this.out(peer, encodeWant(f.hash));
|
|
8917
|
+
return;
|
|
8918
|
+
}
|
|
8919
|
+
case FrameType.BLOCK: {
|
|
8920
|
+
const pending = this.wants.get(f.hash);
|
|
8921
|
+
if (!pending || !f.payload)
|
|
8922
|
+
return; // unsolicited / empty — ignore
|
|
8923
|
+
// Integrity: a peer can't poison the store. Drop a mismatch and
|
|
8924
|
+
// keep waiting (the timeout will fall back to origin).
|
|
8925
|
+
const got = await this.hash(f.payload);
|
|
8926
|
+
if (got !== f.hash)
|
|
8927
|
+
return;
|
|
8928
|
+
await this.store.put(f.hash, f.payload);
|
|
8929
|
+
this.wants.delete(f.hash);
|
|
8930
|
+
clearTimeout(pending.timer);
|
|
8931
|
+
this.out("*", encodeCancel(f.hash)); // others can stop sending
|
|
8932
|
+
pending.resolvers.forEach((r) => r(f.payload));
|
|
8933
|
+
return;
|
|
8934
|
+
}
|
|
8935
|
+
case FrameType.CANCEL:
|
|
8936
|
+
// We serve WANTs synchronously, so there's nothing in flight to
|
|
8937
|
+
// cancel yet. Reserved for chunked-send cancellation later.
|
|
8938
|
+
return;
|
|
8939
|
+
}
|
|
8940
|
+
}
|
|
8941
|
+
/** Drop all pending waits (resolve null) — on teardown. */
|
|
8942
|
+
close() {
|
|
8943
|
+
for (const [, w] of this.wants) {
|
|
8944
|
+
clearTimeout(w.timer);
|
|
8945
|
+
w.resolvers.forEach((r) => r(null));
|
|
8946
|
+
}
|
|
8947
|
+
this.wants.clear();
|
|
8948
|
+
}
|
|
8949
|
+
}
|
|
8950
|
+
|
|
8951
|
+
/**
|
|
8952
|
+
* `EngineHost` — the boundary {@link ../PeerNetwork} wires a transport to. It
|
|
8953
|
+
* abstracts *where* the {@link ./blockEngine.BlockEngine} runs:
|
|
8954
|
+
* - {@link LocalEngineHost} runs it on the calling thread (tests, or a
|
|
8955
|
+
* fallback when the Web Worker can't be built by a consumer's bundler);
|
|
8956
|
+
* - the Worker proxy ({@link ./engineClient}) runs it off-thread.
|
|
8957
|
+
*
|
|
8958
|
+
* Either way the host exposes the same surface: drive `want`/`announce`, feed it
|
|
8959
|
+
* inbound frames via `handleFrame`, and receive its outbound frames via
|
|
8960
|
+
* `onOutbound` (which the network forwards to the transport).
|
|
8961
|
+
*/
|
|
8962
|
+
/** Runs the engine in-process (main thread or test). */
|
|
8963
|
+
class LocalEngineHost {
|
|
8964
|
+
engine;
|
|
8965
|
+
cbs = new Set();
|
|
8966
|
+
constructor(store, hasher) {
|
|
8967
|
+
this.engine = new BlockEngine(store, (target, frame) => {
|
|
8968
|
+
for (const cb of this.cbs)
|
|
8969
|
+
cb(target, frame);
|
|
8970
|
+
}, hasher);
|
|
8971
|
+
}
|
|
8972
|
+
want(hash, timeoutMs) {
|
|
8973
|
+
return this.engine.want(hash, timeoutMs);
|
|
8974
|
+
}
|
|
8975
|
+
announce(hash) {
|
|
8976
|
+
this.engine.announce(hash);
|
|
8977
|
+
}
|
|
8978
|
+
handleFrame(peer, frame) {
|
|
8979
|
+
void this.engine.handleFrame(peer, frame);
|
|
8980
|
+
}
|
|
8981
|
+
onOutbound(cb) {
|
|
8982
|
+
this.cbs.add(cb);
|
|
8983
|
+
return () => this.cbs.delete(cb);
|
|
8984
|
+
}
|
|
8985
|
+
close() {
|
|
8986
|
+
this.engine.close();
|
|
8987
|
+
this.cbs.clear();
|
|
8988
|
+
}
|
|
8989
|
+
}
|
|
8990
|
+
|
|
8991
|
+
/**
|
|
8992
|
+
* `WorkerEngineHost` — the main-thread proxy to a {@link ./blockEngine.BlockEngine}
|
|
8993
|
+
* running inside a dedicated Web Worker (so hashing, blockstore I/O, and the
|
|
8994
|
+
* protocol codec stay off the main thread). It speaks the small message protocol
|
|
8995
|
+
* in {@link ./blockEngine.worker} and satisfies {@link ./engineHost.EngineHost},
|
|
8996
|
+
* so {@link ../PeerNetwork} treats it identically to the in-process host.
|
|
8997
|
+
*
|
|
8998
|
+
* Block bytes cross the worker boundary as **transferable** ArrayBuffers
|
|
8999
|
+
* (zero-copy). Only encrypted blocks ever cross — keys never enter the worker.
|
|
9000
|
+
*/
|
|
9001
|
+
class WorkerEngineHost {
|
|
9002
|
+
worker;
|
|
9003
|
+
wants = new Map();
|
|
9004
|
+
cbs = new Set();
|
|
9005
|
+
nextId = 1;
|
|
9006
|
+
constructor(worker) {
|
|
9007
|
+
this.worker = worker;
|
|
9008
|
+
this.worker.onmessage = (e) => {
|
|
9009
|
+
const m = e.data;
|
|
9010
|
+
if (m.t === "out") {
|
|
9011
|
+
const frame = new Uint8Array(m.data);
|
|
9012
|
+
for (const cb of this.cbs)
|
|
9013
|
+
cb(m.target, frame);
|
|
9014
|
+
}
|
|
9015
|
+
else if (m.t === "want-result") {
|
|
9016
|
+
const resolve = this.wants.get(m.id);
|
|
9017
|
+
if (resolve) {
|
|
9018
|
+
this.wants.delete(m.id);
|
|
9019
|
+
resolve(m.data ? new Uint8Array(m.data) : null);
|
|
9020
|
+
}
|
|
9021
|
+
}
|
|
9022
|
+
};
|
|
9023
|
+
}
|
|
9024
|
+
want(hash, timeoutMs) {
|
|
9025
|
+
return new Promise((resolve) => {
|
|
9026
|
+
const id = this.nextId++;
|
|
9027
|
+
this.wants.set(id, resolve);
|
|
9028
|
+
this.worker.postMessage({ t: "want", id, hash, timeout: timeoutMs });
|
|
9029
|
+
});
|
|
9030
|
+
}
|
|
9031
|
+
announce(hash) {
|
|
9032
|
+
this.worker.postMessage({ t: "announce", hash });
|
|
9033
|
+
}
|
|
9034
|
+
handleFrame(peer, frame) {
|
|
9035
|
+
// Transfer an exact-size buffer; copy if the view doesn't own the whole buffer.
|
|
9036
|
+
const buf = frame.byteOffset === 0 && frame.byteLength === frame.buffer.byteLength
|
|
9037
|
+
? frame.buffer
|
|
9038
|
+
: frame.slice().buffer;
|
|
9039
|
+
this.worker.postMessage({ t: "frame", peer, data: buf }, [buf]);
|
|
9040
|
+
}
|
|
9041
|
+
onOutbound(cb) {
|
|
9042
|
+
this.cbs.add(cb);
|
|
9043
|
+
return () => this.cbs.delete(cb);
|
|
9044
|
+
}
|
|
9045
|
+
close() {
|
|
9046
|
+
try {
|
|
9047
|
+
this.worker.postMessage({ t: "close" });
|
|
9048
|
+
}
|
|
9049
|
+
catch { /* already gone */ }
|
|
9050
|
+
this.worker.terminate();
|
|
9051
|
+
this.wants.forEach((r) => r(null));
|
|
9052
|
+
this.wants.clear();
|
|
9053
|
+
this.cbs.clear();
|
|
9054
|
+
}
|
|
9055
|
+
}
|
|
9056
|
+
|
|
9057
|
+
/**
|
|
9058
|
+
* `PeerNetwork` — the per-Space orchestrator that ties the pieces together on
|
|
9059
|
+
* the main thread:
|
|
9060
|
+
*
|
|
9061
|
+
* transport (WebRTC) ⇄ engine host (Worker or in-process) → PeerExchange
|
|
9062
|
+
* ▲ │
|
|
9063
|
+
* └────────────── signaling over the Space relay ─────────────┘
|
|
9064
|
+
*
|
|
9065
|
+
* It owns one {@link ./transport/WebRtcTransport} + one {@link
|
|
9066
|
+
* ./worker/engineHost.EngineHost}, wires inbound frames to the engine and the
|
|
9067
|
+
* engine's outbound frames back to the transport, runs peer discovery over the
|
|
9068
|
+
* Space's ephemeral channel, and exposes a {@link ./PeerExchange.PeerExchange}
|
|
9069
|
+
* for `ShardClient` to consult. Scope is one Space (private swarm).
|
|
9070
|
+
*
|
|
9071
|
+
* `engineHost` defaults to in-process; pass `workerFactory` to run the block
|
|
9072
|
+
* engine off the main thread (the consumer's bundler resolves the worker URL).
|
|
9073
|
+
*/
|
|
9074
|
+
/**
|
|
9075
|
+
* Build the engine host, preferring an off-thread Web Worker:
|
|
9076
|
+
* 1. an explicit `workerFactory` (most reliable in app bundlers), else
|
|
9077
|
+
* 2. the bundled worker resolved relative to this module (works where the
|
|
9078
|
+
* bundler supports `new Worker(new URL(...))`, e.g. Vite builds), else
|
|
9079
|
+
* 3. the in-process engine (always works; tests + non-worker runtimes).
|
|
9080
|
+
* Any worker construction failure degrades to in-process.
|
|
9081
|
+
*/
|
|
9082
|
+
function buildEngineHost(opts) {
|
|
9083
|
+
try {
|
|
9084
|
+
const worker = opts.workerFactory ? opts.workerFactory() : defaultWorker();
|
|
9085
|
+
if (worker)
|
|
9086
|
+
return new WorkerEngineHost(worker);
|
|
9087
|
+
}
|
|
9088
|
+
catch {
|
|
9089
|
+
/* no Worker global / bundler can't resolve it → in-process */
|
|
9090
|
+
}
|
|
9091
|
+
return new LocalEngineHost(opts.store ?? new ShardCache(), shardHash);
|
|
9092
|
+
}
|
|
9093
|
+
/** Construct the bundled worker if the runtime + bundler support it, else null. */
|
|
9094
|
+
function defaultWorker() {
|
|
9095
|
+
try {
|
|
9096
|
+
if (typeof Worker === "undefined")
|
|
9097
|
+
return null;
|
|
9098
|
+
// The string is resolved by the consumer's bundler relative to the
|
|
9099
|
+
// built connect module (sibling of dist/browser/index.js).
|
|
9100
|
+
return new Worker(new URL("./blockEngine.worker.js", import.meta.url), { type: "module" });
|
|
9101
|
+
}
|
|
9102
|
+
catch {
|
|
9103
|
+
return null;
|
|
9104
|
+
}
|
|
9105
|
+
}
|
|
9106
|
+
class PeerNetwork {
|
|
9107
|
+
/** Pass this to a `ShardClient` as `peers`. */
|
|
9108
|
+
exchange;
|
|
9109
|
+
transport;
|
|
9110
|
+
host;
|
|
9111
|
+
signaler;
|
|
9112
|
+
offs = [];
|
|
9113
|
+
gossipCbs = new Set();
|
|
9114
|
+
closed = false;
|
|
9115
|
+
constructor(opts) {
|
|
9116
|
+
this.signaler = new SpaceSignaler(opts.space, opts.myId);
|
|
9117
|
+
this.transport =
|
|
9118
|
+
opts.transport ??
|
|
9119
|
+
new WebRtcTransport(this.signaler, opts.myId, {
|
|
9120
|
+
iceServers: opts.iceServers,
|
|
9121
|
+
maxPeers: opts.maxPeers,
|
|
9122
|
+
});
|
|
9123
|
+
this.host = buildEngineHost(opts);
|
|
9124
|
+
// Wire transport ⇄ engine, demuxing block (engine) from gossip (app).
|
|
9125
|
+
this.offs.push(this.transport.onMessage((peer, data, channel) => {
|
|
9126
|
+
if (channel === CHANNEL_GOSSIP) {
|
|
9127
|
+
for (const cb of this.gossipCbs)
|
|
9128
|
+
cb(peer, data);
|
|
9129
|
+
}
|
|
9130
|
+
else {
|
|
9131
|
+
this.host.handleFrame(peer, data);
|
|
9132
|
+
}
|
|
9133
|
+
}));
|
|
9134
|
+
this.offs.push(this.host.onOutbound((target, frame) => target === "*"
|
|
9135
|
+
? this.transport.broadcast(frame, CHANNEL_BLOCK)
|
|
9136
|
+
: this.transport.send(target, frame, CHANNEL_BLOCK)));
|
|
9137
|
+
// Discovery: a broadcast hello triggers a unicast hello back (so a
|
|
9138
|
+
// newcomer learns existing members); both sides connectTo (the smaller
|
|
9139
|
+
// id actually dials). Deterministic + idempotent.
|
|
9140
|
+
this.offs.push(this.signaler.onSignal((from, kind, data) => {
|
|
9141
|
+
if (kind !== "hello")
|
|
9142
|
+
return;
|
|
9143
|
+
this.transport.connectTo?.(from);
|
|
9144
|
+
if (data?.broadcast) {
|
|
9145
|
+
this.signaler.send(from, "hello", { broadcast: false });
|
|
9146
|
+
}
|
|
9147
|
+
}));
|
|
9148
|
+
this.exchange = new PeerExchange(this.host, { timeoutMs: opts.timeoutMs });
|
|
9149
|
+
}
|
|
9150
|
+
/**
|
|
9151
|
+
* Begin peer discovery — broadcast a hello so existing members dial us and
|
|
9152
|
+
* we learn them. Call once the signaling channel is live (the Space fires
|
|
9153
|
+
* this on CONNECTED); safe to call again on reconnect.
|
|
9154
|
+
*/
|
|
9155
|
+
start() {
|
|
9156
|
+
if (this.closed)
|
|
9157
|
+
return;
|
|
9158
|
+
this.signaler.broadcast("hello", { broadcast: true });
|
|
9159
|
+
}
|
|
9160
|
+
/**
|
|
9161
|
+
* Broadcast an app payload to all connected peers over the gossip channel —
|
|
9162
|
+
* a direct, server-bypassing data path for CRDT ops / high-frequency state
|
|
9163
|
+
* (cursors, presence, collaborative edits). Single-hop (no re-forwarding).
|
|
9164
|
+
*/
|
|
9165
|
+
gossip(data) {
|
|
9166
|
+
this.transport.broadcast(data, CHANNEL_GOSSIP);
|
|
9167
|
+
}
|
|
9168
|
+
/** Subscribe to gossip from peers. Returns an unsubscribe function. */
|
|
9169
|
+
onGossip(cb) {
|
|
9170
|
+
this.gossipCbs.add(cb);
|
|
9171
|
+
return () => this.gossipCbs.delete(cb);
|
|
9172
|
+
}
|
|
9173
|
+
/** Connected peer count (open data channels). */
|
|
9174
|
+
peerCount() {
|
|
9175
|
+
return this.transport.peers().length;
|
|
9176
|
+
}
|
|
9177
|
+
close() {
|
|
9178
|
+
if (this.closed)
|
|
9179
|
+
return;
|
|
9180
|
+
this.closed = true;
|
|
9181
|
+
try {
|
|
9182
|
+
this.signaler.broadcast("bye");
|
|
9183
|
+
}
|
|
9184
|
+
catch { /* socket may be gone */ }
|
|
9185
|
+
for (const off of this.offs)
|
|
9186
|
+
off();
|
|
9187
|
+
this.transport.close();
|
|
9188
|
+
this.host.close();
|
|
9189
|
+
}
|
|
9190
|
+
}
|
|
9191
|
+
|
|
9192
|
+
/**
|
|
9193
|
+
* Whether this environment can do WebRTC peering. `RTCPeerConnection` is
|
|
9194
|
+
* Window-only (absent in Node/Workers/SSR), so this gates whether a
|
|
9195
|
+
* {@link ./PeerNetwork} can be built at all.
|
|
9196
|
+
*/
|
|
9197
|
+
function isP2pCapable() {
|
|
9198
|
+
try {
|
|
9199
|
+
return typeof RTCPeerConnection !== "undefined";
|
|
9200
|
+
}
|
|
9201
|
+
catch {
|
|
9202
|
+
return false;
|
|
9203
|
+
}
|
|
9204
|
+
}
|
|
9205
|
+
|
|
8313
9206
|
/**
|
|
8314
9207
|
* `client.space` — the fan-out group-encryption surface.
|
|
8315
9208
|
*
|
|
@@ -8541,6 +9434,17 @@ class SpaceNamespace {
|
|
|
8541
9434
|
: undefined,
|
|
8542
9435
|
});
|
|
8543
9436
|
this.openSpaces.set(spaceId, space);
|
|
9437
|
+
// Attach a private P2P swarm scoped to this Space (block exchange among
|
|
9438
|
+
// members; signaled over the Space relay). Discovery starts on connect.
|
|
9439
|
+
if (this.deps.p2p?.enabled && isP2pCapable()) {
|
|
9440
|
+
space.attachPeerNetwork(new PeerNetwork({
|
|
9441
|
+
space,
|
|
9442
|
+
myId: memberId,
|
|
9443
|
+
workerFactory: this.deps.p2p.workerFactory,
|
|
9444
|
+
iceServers: this.deps.p2p.iceServers,
|
|
9445
|
+
maxPeers: this.deps.p2p.maxPeers,
|
|
9446
|
+
}));
|
|
9447
|
+
}
|
|
8544
9448
|
return space;
|
|
8545
9449
|
}
|
|
8546
9450
|
/** Ensure the member has an identity keypair; return its public ECDH + ECDSA keys. */
|
|
@@ -9304,82 +10208,6 @@ class SnapshotNamespace {
|
|
|
9304
10208
|
}
|
|
9305
10209
|
}
|
|
9306
10210
|
|
|
9307
|
-
/**
|
|
9308
|
-
* `ShardCache` — a Cache-API-backed store for file-shard bytes. Shards are
|
|
9309
|
-
* content-addressed (the key IS the SHA-256 of the ciphertext), so caching them
|
|
9310
|
-
* is uniquely safe: a cache hit is self-validating, immutable, and shareable
|
|
9311
|
-
* across files. The Cache API (rather than IndexedDB) is the right home for
|
|
9312
|
-
* large binary blobs — it streams, it's purpose-built for response bodies, and
|
|
9313
|
-
* it has its own eviction.
|
|
9314
|
-
*
|
|
9315
|
-
* Keys are SYNTHETIC absolute URLs (`https://shard.muhkoo.local/<hash>`) rather
|
|
9316
|
-
* than the real shard endpoint, so the cache is addressable by hash alone — the
|
|
9317
|
-
* sync engine can re-PUT a deferred shard knowing only its hash, without
|
|
9318
|
-
* reconstructing the accelerator URL. We never fetch these synthetic URLs; they
|
|
9319
|
-
* exist only as cache keys.
|
|
9320
|
-
*
|
|
9321
|
-
* Every method degrades to a no-op / null where the Cache API is unavailable
|
|
9322
|
-
* (Node, Workers, insecure contexts), so callers don't need to branch.
|
|
9323
|
-
*/
|
|
9324
|
-
const CACHE_NAME = "muhkoo-shards-v1";
|
|
9325
|
-
class ShardCache {
|
|
9326
|
-
static available() {
|
|
9327
|
-
try {
|
|
9328
|
-
return typeof caches !== "undefined";
|
|
9329
|
-
}
|
|
9330
|
-
catch {
|
|
9331
|
-
return false;
|
|
9332
|
-
}
|
|
9333
|
-
}
|
|
9334
|
-
key(hash) {
|
|
9335
|
-
return `https://shard.muhkoo.local/${encodeURIComponent(hash)}`;
|
|
9336
|
-
}
|
|
9337
|
-
open() {
|
|
9338
|
-
return caches.open(CACHE_NAME);
|
|
9339
|
-
}
|
|
9340
|
-
/** Cached shard bytes, or `null` on miss / when unavailable. */
|
|
9341
|
-
async get(hash) {
|
|
9342
|
-
if (!ShardCache.available())
|
|
9343
|
-
return null;
|
|
9344
|
-
try {
|
|
9345
|
-
const cache = await this.open();
|
|
9346
|
-
const res = await cache.match(this.key(hash));
|
|
9347
|
-
if (!res)
|
|
9348
|
-
return null;
|
|
9349
|
-
return new Uint8Array(await res.arrayBuffer());
|
|
9350
|
-
}
|
|
9351
|
-
catch {
|
|
9352
|
-
return null;
|
|
9353
|
-
}
|
|
9354
|
-
}
|
|
9355
|
-
/** Store shard bytes under their hash. Best-effort. */
|
|
9356
|
-
async put(hash, bytes) {
|
|
9357
|
-
if (!ShardCache.available())
|
|
9358
|
-
return;
|
|
9359
|
-
try {
|
|
9360
|
-
const cache = await this.open();
|
|
9361
|
-
await cache.put(this.key(hash), new Response(bytes, {
|
|
9362
|
-
headers: { "content-type": "application/octet-stream" },
|
|
9363
|
-
}));
|
|
9364
|
-
}
|
|
9365
|
-
catch {
|
|
9366
|
-
/* quota / unavailable — degrade to online-only for this shard */
|
|
9367
|
-
}
|
|
9368
|
-
}
|
|
9369
|
-
/** Whether a shard is present in the cache. */
|
|
9370
|
-
async has(hash) {
|
|
9371
|
-
if (!ShardCache.available())
|
|
9372
|
-
return false;
|
|
9373
|
-
try {
|
|
9374
|
-
const cache = await this.open();
|
|
9375
|
-
return Boolean(await cache.match(this.key(hash)));
|
|
9376
|
-
}
|
|
9377
|
-
catch {
|
|
9378
|
-
return false;
|
|
9379
|
-
}
|
|
9380
|
-
}
|
|
9381
|
-
}
|
|
9382
|
-
|
|
9383
10211
|
/**
|
|
9384
10212
|
* IndexedDB schema for the offline layer — the single source of truth for the
|
|
9385
10213
|
* database name, version, and object stores. Bump {@link DB_VERSION} whenever
|
|
@@ -10102,6 +10930,7 @@ class Client {
|
|
|
10102
10930
|
wsBaseUrl,
|
|
10103
10931
|
cache: spaceKeyCache,
|
|
10104
10932
|
offline: this.offline,
|
|
10933
|
+
p2p: options.p2p,
|
|
10105
10934
|
});
|
|
10106
10935
|
this.agents = new AgentsNamespace({ http: this.http });
|
|
10107
10936
|
this.functions = new FunctionsNamespace({ http: this.http, fnHostSuffix: options.functionsHostSuffix });
|
|
@@ -10523,5 +11352,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
|
|
|
10523
11352
|
default: loadRsWasm
|
|
10524
11353
|
});
|
|
10525
11354
|
|
|
10526
|
-
export { AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, ConnectivityManager, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DbCache, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HttpClient, HttpError, IndexedDbStore, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvCache, KvNamespace, LocalStorageSessionStore, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PersonalSpaceClient, Poseidon, PreimagePoK, ReedSolomonCodec, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, StorageNamespace, SyncEngine, VaultUnavailableError, WSTransport, ZERO_HLC, ZkAuth, buildCommitment, compareHlc, compilePrograms, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, generateAuthProof, generatePkce, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isOfflineCapable, isProvisional, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, poseidonHash, provisionalHandle, quickVerify, randomBytes, randomState, sealSeed, shardHash, signMessage, stableStringify, toField, tombstoneRow, unpack, unsealSeed, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
|
|
11355
|
+
export { AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, ConnectivityManager, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DbCache, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HttpClient, HttpError, IndexedDbStore, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, ReedSolomonCodec, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VaultUnavailableError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, buildCommitment, compareHlc, compilePrograms, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, generateAuthProof, generatePkce, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isOfflineCapable, isP2pCapable, isProvisional, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, sealSeed, shardHash, signMessage, stableStringify, toField, tombstoneRow, unpack, unsealSeed, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
|
|
10527
11356
|
//# sourceMappingURL=index.js.map
|