@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/server/index.js
CHANGED
|
@@ -2976,7 +2976,7 @@ function base64UrlDecode(s) {
|
|
|
2976
2976
|
* expect (and isn't serving a stale cached bundle). Keep this in sync with
|
|
2977
2977
|
* `package.json` `version`.
|
|
2978
2978
|
*/
|
|
2979
|
-
const VERSION = "0.
|
|
2979
|
+
const VERSION = "0.9.0-alpha.0";
|
|
2980
2980
|
|
|
2981
2981
|
/**
|
|
2982
2982
|
* `HttpClient` — the one place the unified {@link Client} talks to the
|
|
@@ -5463,6 +5463,7 @@ class ShardClient {
|
|
|
5463
5463
|
fetchFn;
|
|
5464
5464
|
cache;
|
|
5465
5465
|
deferUpload;
|
|
5466
|
+
peers;
|
|
5466
5467
|
constructor(opts) {
|
|
5467
5468
|
if (!opts?.baseUrl)
|
|
5468
5469
|
throw new Error("ShardClient: `baseUrl` is required");
|
|
@@ -5480,6 +5481,7 @@ class ShardClient {
|
|
|
5480
5481
|
this.fetchFn = f.bind(globalThis);
|
|
5481
5482
|
this.cache = opts.cache;
|
|
5482
5483
|
this.deferUpload = opts.deferUpload;
|
|
5484
|
+
this.peers = opts.peers;
|
|
5483
5485
|
}
|
|
5484
5486
|
/** Build the URL for a given shard hash — exposed for callers that want to log or pre-resolve. */
|
|
5485
5487
|
urlForHash(hash) {
|
|
@@ -5498,6 +5500,9 @@ class ShardClient {
|
|
|
5498
5500
|
// this is safe to keep regardless of upload outcome.
|
|
5499
5501
|
if (this.cache)
|
|
5500
5502
|
await this.cache.put(hash, bytes);
|
|
5503
|
+
// Tell the swarm we now hold this block, so peers can pull it from us.
|
|
5504
|
+
if (this.peers)
|
|
5505
|
+
this.peers.announce(hash);
|
|
5501
5506
|
let response;
|
|
5502
5507
|
try {
|
|
5503
5508
|
response = await this.fetchFn(url, {
|
|
@@ -5541,6 +5546,22 @@ class ShardClient {
|
|
|
5541
5546
|
if (cached)
|
|
5542
5547
|
return cached;
|
|
5543
5548
|
}
|
|
5549
|
+
// Peers next: ask the Space swarm before spending origin egress. Peer
|
|
5550
|
+
// blocks are hash-verified by the block engine; on miss/timeout we fall
|
|
5551
|
+
// through to origin. Best-effort — never let a peer error block a read.
|
|
5552
|
+
if (this.peers) {
|
|
5553
|
+
try {
|
|
5554
|
+
const fromPeer = await this.peers.getBlock(hash);
|
|
5555
|
+
if (fromPeer) {
|
|
5556
|
+
if (this.cache)
|
|
5557
|
+
void this.cache.put(hash, fromPeer);
|
|
5558
|
+
return fromPeer;
|
|
5559
|
+
}
|
|
5560
|
+
}
|
|
5561
|
+
catch {
|
|
5562
|
+
/* peer layer hiccup — fall through to origin */
|
|
5563
|
+
}
|
|
5564
|
+
}
|
|
5544
5565
|
const url = this.urlForHash(hash);
|
|
5545
5566
|
let response;
|
|
5546
5567
|
try {
|
|
@@ -6768,6 +6789,8 @@ class Space {
|
|
|
6768
6789
|
events = new EventTarget();
|
|
6769
6790
|
rawWired = false;
|
|
6770
6791
|
catchUpUnsub = null;
|
|
6792
|
+
/** Optional P2P swarm for this Space (block exchange among members). */
|
|
6793
|
+
peerNet = null;
|
|
6771
6794
|
constructor(deps) {
|
|
6772
6795
|
this.deps = deps;
|
|
6773
6796
|
this.name = deps.name;
|
|
@@ -6816,6 +6839,9 @@ class Space {
|
|
|
6816
6839
|
if (this.deps.keyring) {
|
|
6817
6840
|
this.channel.on(BroadcastChannelEvents.CONNECTED, () => {
|
|
6818
6841
|
this.channel?.sendRaw({ name: this.deps.myId() });
|
|
6842
|
+
// Kick P2P peer discovery now the signaling channel is live
|
|
6843
|
+
// (re-runs on each reconnect to re-form the mesh).
|
|
6844
|
+
this.peerNet?.start();
|
|
6819
6845
|
});
|
|
6820
6846
|
this.wireFanout();
|
|
6821
6847
|
// Register offline catch-up once: on each reconnect the sync
|
|
@@ -6831,6 +6857,27 @@ class Space {
|
|
|
6831
6857
|
this.channel?.disconnect();
|
|
6832
6858
|
this.catchUpUnsub?.();
|
|
6833
6859
|
this.catchUpUnsub = null;
|
|
6860
|
+
this.peerNet?.close();
|
|
6861
|
+
this.peerNet = null;
|
|
6862
|
+
}
|
|
6863
|
+
/** Attach a P2P swarm so this Space's file reads/writes can use peers. */
|
|
6864
|
+
attachPeerNetwork(net) {
|
|
6865
|
+
this.peerNet = net;
|
|
6866
|
+
}
|
|
6867
|
+
/**
|
|
6868
|
+
* Broadcast an app payload directly to connected Space peers over P2P,
|
|
6869
|
+
* **bypassing the server** — for CRDT ops / high-frequency state (live
|
|
6870
|
+
* cursors, presence, collaborative edits) you don't want to push through the
|
|
6871
|
+
* relay. No-op when no P2P mesh is active (requires `client` `p2p`).
|
|
6872
|
+
* Unlike {@link sendEphemeral} (server-relayed, metered, size-limited), this
|
|
6873
|
+
* is peer-direct and fragments large payloads.
|
|
6874
|
+
*/
|
|
6875
|
+
gossipToPeers(data) {
|
|
6876
|
+
this.peerNet?.gossip(data);
|
|
6877
|
+
}
|
|
6878
|
+
/** Subscribe to peer gossip ({@link gossipToPeers}). Returns an unsubscribe fn. */
|
|
6879
|
+
onPeerGossip(handler) {
|
|
6880
|
+
return this.peerNet?.onGossip(handler) ?? (() => { });
|
|
6834
6881
|
}
|
|
6835
6882
|
/** Subscribe to a raw channel event. Buffered until `connect()` if early. */
|
|
6836
6883
|
on(event, handler) {
|
|
@@ -7000,10 +7047,12 @@ class Space {
|
|
|
7000
7047
|
* indicators, presence, or live cursors are built on top of this. No-op if
|
|
7001
7048
|
* the space isn't connected (ephemeral signals aren't worth queuing).
|
|
7002
7049
|
*/
|
|
7003
|
-
sendEphemeral(subject, data) {
|
|
7050
|
+
sendEphemeral(subject, data, to) {
|
|
7004
7051
|
if (!this.isConnected())
|
|
7005
7052
|
return;
|
|
7006
|
-
|
|
7053
|
+
// `to` (a member id) lets the server unicast a *directed* ephemeral
|
|
7054
|
+
// instead of broadcasting; omitted ⇒ broadcast to the room (unchanged).
|
|
7055
|
+
this.sendRaw({ pub: to ? { subject, data, to } : { subject, data } });
|
|
7007
7056
|
}
|
|
7008
7057
|
/** Subscribe to inbound ephemeral signals. `from` is the authenticated sender. */
|
|
7009
7058
|
onEphemeral(handler) {
|
|
@@ -7123,6 +7172,7 @@ class Space {
|
|
|
7123
7172
|
fetch: this.deps.fetch,
|
|
7124
7173
|
cache: this.deps.shardCache,
|
|
7125
7174
|
deferUpload: this.deps.deferShardUpload,
|
|
7175
|
+
peers: this.peerNet?.exchange,
|
|
7126
7176
|
});
|
|
7127
7177
|
return new FileStorage({ shards });
|
|
7128
7178
|
}
|
|
@@ -8041,6 +8091,849 @@ class SpaceCache {
|
|
|
8041
8091
|
}
|
|
8042
8092
|
}
|
|
8043
8093
|
|
|
8094
|
+
/**
|
|
8095
|
+
* `ShardCache` — a Cache-API-backed store for file-shard bytes. Shards are
|
|
8096
|
+
* content-addressed (the key IS the SHA-256 of the ciphertext), so caching them
|
|
8097
|
+
* is uniquely safe: a cache hit is self-validating, immutable, and shareable
|
|
8098
|
+
* across files. The Cache API (rather than IndexedDB) is the right home for
|
|
8099
|
+
* large binary blobs — it streams, it's purpose-built for response bodies, and
|
|
8100
|
+
* it has its own eviction.
|
|
8101
|
+
*
|
|
8102
|
+
* Keys are SYNTHETIC absolute URLs (`https://shard.muhkoo.local/<hash>`) rather
|
|
8103
|
+
* than the real shard endpoint, so the cache is addressable by hash alone — the
|
|
8104
|
+
* sync engine can re-PUT a deferred shard knowing only its hash, without
|
|
8105
|
+
* reconstructing the accelerator URL. We never fetch these synthetic URLs; they
|
|
8106
|
+
* exist only as cache keys.
|
|
8107
|
+
*
|
|
8108
|
+
* Every method degrades to a no-op / null where the Cache API is unavailable
|
|
8109
|
+
* (Node, Workers, insecure contexts), so callers don't need to branch.
|
|
8110
|
+
*/
|
|
8111
|
+
const CACHE_NAME = "muhkoo-shards-v1";
|
|
8112
|
+
class ShardCache {
|
|
8113
|
+
static available() {
|
|
8114
|
+
try {
|
|
8115
|
+
return typeof caches !== "undefined";
|
|
8116
|
+
}
|
|
8117
|
+
catch {
|
|
8118
|
+
return false;
|
|
8119
|
+
}
|
|
8120
|
+
}
|
|
8121
|
+
key(hash) {
|
|
8122
|
+
return `https://shard.muhkoo.local/${encodeURIComponent(hash)}`;
|
|
8123
|
+
}
|
|
8124
|
+
open() {
|
|
8125
|
+
return caches.open(CACHE_NAME);
|
|
8126
|
+
}
|
|
8127
|
+
/** Cached shard bytes, or `null` on miss / when unavailable. */
|
|
8128
|
+
async get(hash) {
|
|
8129
|
+
if (!ShardCache.available())
|
|
8130
|
+
return null;
|
|
8131
|
+
try {
|
|
8132
|
+
const cache = await this.open();
|
|
8133
|
+
const res = await cache.match(this.key(hash));
|
|
8134
|
+
if (!res)
|
|
8135
|
+
return null;
|
|
8136
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
8137
|
+
}
|
|
8138
|
+
catch {
|
|
8139
|
+
return null;
|
|
8140
|
+
}
|
|
8141
|
+
}
|
|
8142
|
+
/** Store shard bytes under their hash. Best-effort. */
|
|
8143
|
+
async put(hash, bytes) {
|
|
8144
|
+
if (!ShardCache.available())
|
|
8145
|
+
return;
|
|
8146
|
+
try {
|
|
8147
|
+
const cache = await this.open();
|
|
8148
|
+
await cache.put(this.key(hash), new Response(bytes, {
|
|
8149
|
+
headers: { "content-type": "application/octet-stream" },
|
|
8150
|
+
}));
|
|
8151
|
+
}
|
|
8152
|
+
catch {
|
|
8153
|
+
/* quota / unavailable — degrade to online-only for this shard */
|
|
8154
|
+
}
|
|
8155
|
+
}
|
|
8156
|
+
/** Whether a shard is present in the cache. */
|
|
8157
|
+
async has(hash) {
|
|
8158
|
+
if (!ShardCache.available())
|
|
8159
|
+
return false;
|
|
8160
|
+
try {
|
|
8161
|
+
const cache = await this.open();
|
|
8162
|
+
return Boolean(await cache.match(this.key(hash)));
|
|
8163
|
+
}
|
|
8164
|
+
catch {
|
|
8165
|
+
return false;
|
|
8166
|
+
}
|
|
8167
|
+
}
|
|
8168
|
+
}
|
|
8169
|
+
|
|
8170
|
+
/**
|
|
8171
|
+
* `PeerExchange` — the thin façade the storage layer consults to try fetching a
|
|
8172
|
+
* block from peers before falling back to origin. It satisfies the structural
|
|
8173
|
+
* `PeerBlockSource` interface that {@link ../storage/transport/ShardClient}
|
|
8174
|
+
* declares (so storage never imports the p2p module — the dependency arrow stays
|
|
8175
|
+
* one-way, p2p → storage).
|
|
8176
|
+
*
|
|
8177
|
+
* It delegates to an {@link EngineHandle} — either an in-process {@link
|
|
8178
|
+
* ./worker/blockEngine.BlockEngine} or the Web Worker proxy — so the same façade
|
|
8179
|
+
* works whether the block engine runs on the main thread or off it.
|
|
8180
|
+
*/
|
|
8181
|
+
class PeerExchange {
|
|
8182
|
+
engine;
|
|
8183
|
+
timeoutMs;
|
|
8184
|
+
constructor(engine, opts = {}) {
|
|
8185
|
+
this.engine = engine;
|
|
8186
|
+
this.timeoutMs = opts.timeoutMs ?? 2500;
|
|
8187
|
+
}
|
|
8188
|
+
getBlock(hash, opts) {
|
|
8189
|
+
return this.engine.want(hash, opts?.timeoutMs ?? this.timeoutMs);
|
|
8190
|
+
}
|
|
8191
|
+
announce(hash) {
|
|
8192
|
+
this.engine.announce(hash);
|
|
8193
|
+
}
|
|
8194
|
+
}
|
|
8195
|
+
|
|
8196
|
+
/**
|
|
8197
|
+
* `SpaceSignaler` — WebRTC signaling carried over the Space's existing ephemeral
|
|
8198
|
+
* relay. No new server channel: `Space.sendEphemeral`/`onEphemeral` already
|
|
8199
|
+
* blind-relay `pub` frames stamped with the authenticated sender, so SDP
|
|
8200
|
+
* offers/answers and ICE candidates ride a reserved subject (`__p2p__`) scoped
|
|
8201
|
+
* to Space membership. The sender (`from`) is server-stamped and can't be
|
|
8202
|
+
* spoofed; the payload is opaque to the server.
|
|
8203
|
+
*
|
|
8204
|
+
* Envelopes carry a `to` (a member id, or `"*"` for discovery) so the
|
|
8205
|
+
* accelerator can optionally unicast peer-pair signaling instead of broadcasting
|
|
8206
|
+
* (a small, backward-compatible `pub` enhancement); the client filters by `to`
|
|
8207
|
+
* regardless.
|
|
8208
|
+
*/
|
|
8209
|
+
const P2P_SUBJECT = "__p2p__";
|
|
8210
|
+
class SpaceSignaler {
|
|
8211
|
+
space;
|
|
8212
|
+
myId;
|
|
8213
|
+
constructor(space, myId) {
|
|
8214
|
+
this.space = space;
|
|
8215
|
+
this.myId = myId;
|
|
8216
|
+
}
|
|
8217
|
+
/** Send a signal to one peer (or `"*"`). */
|
|
8218
|
+
send(to, kind, data) {
|
|
8219
|
+
const env = { to, kind, data };
|
|
8220
|
+
// Ask the server to unicast for a directed target; broadcast for "*".
|
|
8221
|
+
// `to` also stays in the envelope so a broadcast-only server still works
|
|
8222
|
+
// (the client filters by it).
|
|
8223
|
+
this.space.sendEphemeral(P2P_SUBJECT, env, to !== "*" ? to : undefined);
|
|
8224
|
+
}
|
|
8225
|
+
/** Announce/withdraw P2P availability to all Space members. */
|
|
8226
|
+
broadcast(kind, data) {
|
|
8227
|
+
this.send("*", kind, data);
|
|
8228
|
+
}
|
|
8229
|
+
/**
|
|
8230
|
+
* Subscribe to inbound signals addressed to us (or broadcast). Skips our own
|
|
8231
|
+
* echoes. Returns an unsubscribe function.
|
|
8232
|
+
*/
|
|
8233
|
+
onSignal(cb) {
|
|
8234
|
+
return this.space.onEphemeral((e) => {
|
|
8235
|
+
if (e.subject !== P2P_SUBJECT || e.from === this.myId)
|
|
8236
|
+
return;
|
|
8237
|
+
const env = e.data;
|
|
8238
|
+
if (!env || typeof env.kind !== "string")
|
|
8239
|
+
return;
|
|
8240
|
+
if (env.to !== this.myId && env.to !== "*")
|
|
8241
|
+
return;
|
|
8242
|
+
cb(e.from, env.kind, env.data);
|
|
8243
|
+
});
|
|
8244
|
+
}
|
|
8245
|
+
}
|
|
8246
|
+
|
|
8247
|
+
/**
|
|
8248
|
+
* `WebRtcTransport` — a {@link ./PeerTransport.PeerTransport} over RTCDataChannels,
|
|
8249
|
+
* signaled by {@link ../signaling/SpaceSignaler}. Runs on the **main thread**
|
|
8250
|
+
* (`RTCPeerConnection` is Window-only — not available in Workers), so the block
|
|
8251
|
+
* engine lives in a Worker and this only shuttles bytes.
|
|
8252
|
+
*
|
|
8253
|
+
* Connection setup uses a deterministic initiator (the peer with the smaller id
|
|
8254
|
+
* dials) to avoid offer/answer glare. STUN handles NAT traversal; there is no
|
|
8255
|
+
* TURN — an unreachable peer simply never connects and the caller falls back to
|
|
8256
|
+
* origin (P2P is best-effort).
|
|
8257
|
+
*
|
|
8258
|
+
* Frames (up to a 4 MiB shard) exceed an RTCDataChannel's max message size, so
|
|
8259
|
+
* each frame is fragmented into `CHUNK`-sized pieces with a 12-byte header
|
|
8260
|
+
* `[msgId u32][index u32][total u32]` and reassembled per peer.
|
|
8261
|
+
*/
|
|
8262
|
+
const CHUNK = 16 * 1024;
|
|
8263
|
+
// [msgId u32][index u32][total u32][channel u32]
|
|
8264
|
+
const HEADER$1 = 16;
|
|
8265
|
+
/** Pause sends when a channel has this many bytes buffered (backpressure). */
|
|
8266
|
+
const MAX_BUFFERED = 4 * 1024 * 1024;
|
|
8267
|
+
const DEFAULT_ICE = [{ urls: "stun:stun.cloudflare.com:3478" }];
|
|
8268
|
+
class WebRtcTransport {
|
|
8269
|
+
signaler;
|
|
8270
|
+
myId;
|
|
8271
|
+
conns = new Map();
|
|
8272
|
+
msgCbs = new Set();
|
|
8273
|
+
peerCbs = new Set();
|
|
8274
|
+
offSignal;
|
|
8275
|
+
iceServers;
|
|
8276
|
+
maxPeers;
|
|
8277
|
+
nextMsgId = 1;
|
|
8278
|
+
constructor(signaler, myId, opts = {}) {
|
|
8279
|
+
this.signaler = signaler;
|
|
8280
|
+
this.myId = myId;
|
|
8281
|
+
this.iceServers = opts.iceServers ?? DEFAULT_ICE;
|
|
8282
|
+
this.maxPeers = opts.maxPeers ?? 8;
|
|
8283
|
+
this.offSignal = this.signaler.onSignal((from, kind, data) => this.onSignal(from, kind, data));
|
|
8284
|
+
}
|
|
8285
|
+
peers() {
|
|
8286
|
+
return [...this.conns].filter(([, c]) => c.dc?.readyState === "open").map(([id]) => id);
|
|
8287
|
+
}
|
|
8288
|
+
/** Dial a peer (no-op unless we're the deterministic initiator for the pair). */
|
|
8289
|
+
connectTo(peerId) {
|
|
8290
|
+
if (peerId === this.myId || this.conns.has(peerId))
|
|
8291
|
+
return;
|
|
8292
|
+
if (this.conns.size >= this.maxPeers)
|
|
8293
|
+
return; // mesh cap
|
|
8294
|
+
if (this.myId < peerId)
|
|
8295
|
+
void this.initiate(peerId); // smaller id dials
|
|
8296
|
+
}
|
|
8297
|
+
send(peer, data, channel = 0) {
|
|
8298
|
+
const dc = this.conns.get(peer)?.dc;
|
|
8299
|
+
if (dc?.readyState === "open")
|
|
8300
|
+
void this.sendFragmented(dc, data, channel);
|
|
8301
|
+
}
|
|
8302
|
+
broadcast(data, channel = 0) {
|
|
8303
|
+
for (const [, c] of this.conns) {
|
|
8304
|
+
if (c.dc?.readyState === "open")
|
|
8305
|
+
void this.sendFragmented(c.dc, data, channel);
|
|
8306
|
+
}
|
|
8307
|
+
}
|
|
8308
|
+
onMessage(cb) {
|
|
8309
|
+
this.msgCbs.add(cb);
|
|
8310
|
+
return () => this.msgCbs.delete(cb);
|
|
8311
|
+
}
|
|
8312
|
+
onPeerChange(cb) {
|
|
8313
|
+
this.peerCbs.add(cb);
|
|
8314
|
+
return () => this.peerCbs.delete(cb);
|
|
8315
|
+
}
|
|
8316
|
+
close() {
|
|
8317
|
+
this.offSignal();
|
|
8318
|
+
for (const [, c] of this.conns) {
|
|
8319
|
+
try {
|
|
8320
|
+
c.dc?.close();
|
|
8321
|
+
}
|
|
8322
|
+
catch { /* already closing */ }
|
|
8323
|
+
try {
|
|
8324
|
+
c.pc.close();
|
|
8325
|
+
}
|
|
8326
|
+
catch { /* already closing */ }
|
|
8327
|
+
}
|
|
8328
|
+
this.conns.clear();
|
|
8329
|
+
this.msgCbs.clear();
|
|
8330
|
+
this.peerCbs.clear();
|
|
8331
|
+
}
|
|
8332
|
+
// -- internals -----------------------------------------------------------
|
|
8333
|
+
newPc(peerId) {
|
|
8334
|
+
const pc = new RTCPeerConnection({ iceServers: this.iceServers });
|
|
8335
|
+
const conn = { pc, reasm: new Map() };
|
|
8336
|
+
this.conns.set(peerId, conn);
|
|
8337
|
+
pc.onicecandidate = (e) => {
|
|
8338
|
+
if (e.candidate)
|
|
8339
|
+
this.signaler.send(peerId, "ice", e.candidate.toJSON());
|
|
8340
|
+
};
|
|
8341
|
+
pc.onconnectionstatechange = () => {
|
|
8342
|
+
if (["failed", "closed", "disconnected"].includes(pc.connectionState))
|
|
8343
|
+
this.drop(peerId);
|
|
8344
|
+
};
|
|
8345
|
+
pc.ondatachannel = (e) => this.bindChannel(peerId, conn, e.channel);
|
|
8346
|
+
return conn;
|
|
8347
|
+
}
|
|
8348
|
+
async initiate(peerId) {
|
|
8349
|
+
const conn = this.newPc(peerId);
|
|
8350
|
+
const dc = conn.pc.createDataChannel("blocks");
|
|
8351
|
+
this.bindChannel(peerId, conn, dc);
|
|
8352
|
+
const offer = await conn.pc.createOffer();
|
|
8353
|
+
await conn.pc.setLocalDescription(offer);
|
|
8354
|
+
this.signaler.send(peerId, "offer", offer);
|
|
8355
|
+
}
|
|
8356
|
+
bindChannel(peerId, conn, dc) {
|
|
8357
|
+
dc.binaryType = "arraybuffer";
|
|
8358
|
+
conn.dc = dc;
|
|
8359
|
+
dc.onopen = () => this.firePeerChange();
|
|
8360
|
+
dc.onclose = () => this.drop(peerId);
|
|
8361
|
+
dc.onmessage = (e) => this.onChunk(peerId, conn, new Uint8Array(e.data));
|
|
8362
|
+
}
|
|
8363
|
+
async onSignal(from, kind, data) {
|
|
8364
|
+
if (kind === "offer") {
|
|
8365
|
+
if (!this.conns.has(from) && this.conns.size >= this.maxPeers)
|
|
8366
|
+
return; // mesh cap
|
|
8367
|
+
const conn = this.conns.get(from) ?? this.newPc(from);
|
|
8368
|
+
await conn.pc.setRemoteDescription(data);
|
|
8369
|
+
const answer = await conn.pc.createAnswer();
|
|
8370
|
+
await conn.pc.setLocalDescription(answer);
|
|
8371
|
+
this.signaler.send(from, "answer", answer);
|
|
8372
|
+
}
|
|
8373
|
+
else if (kind === "answer") {
|
|
8374
|
+
const conn = this.conns.get(from);
|
|
8375
|
+
if (conn)
|
|
8376
|
+
await conn.pc.setRemoteDescription(data);
|
|
8377
|
+
}
|
|
8378
|
+
else if (kind === "ice") {
|
|
8379
|
+
const conn = this.conns.get(from);
|
|
8380
|
+
if (conn && data) {
|
|
8381
|
+
try {
|
|
8382
|
+
await conn.pc.addIceCandidate(data);
|
|
8383
|
+
}
|
|
8384
|
+
catch { /* pre-SRD race */ }
|
|
8385
|
+
}
|
|
8386
|
+
}
|
|
8387
|
+
else if (kind === "bye") {
|
|
8388
|
+
this.drop(from);
|
|
8389
|
+
}
|
|
8390
|
+
}
|
|
8391
|
+
drop(peerId) {
|
|
8392
|
+
const conn = this.conns.get(peerId);
|
|
8393
|
+
if (!conn)
|
|
8394
|
+
return;
|
|
8395
|
+
this.conns.delete(peerId);
|
|
8396
|
+
try {
|
|
8397
|
+
conn.dc?.close();
|
|
8398
|
+
}
|
|
8399
|
+
catch { /* already closing */ }
|
|
8400
|
+
try {
|
|
8401
|
+
conn.pc.close();
|
|
8402
|
+
}
|
|
8403
|
+
catch { /* already closing */ }
|
|
8404
|
+
this.firePeerChange();
|
|
8405
|
+
}
|
|
8406
|
+
firePeerChange() {
|
|
8407
|
+
const peers = this.peers();
|
|
8408
|
+
for (const cb of this.peerCbs)
|
|
8409
|
+
cb(peers);
|
|
8410
|
+
}
|
|
8411
|
+
// -- fragmentation -------------------------------------------------------
|
|
8412
|
+
async sendFragmented(dc, data, channel) {
|
|
8413
|
+
const msgId = this.nextMsgId++ >>> 0;
|
|
8414
|
+
const total = Math.max(1, Math.ceil(data.length / CHUNK));
|
|
8415
|
+
for (let i = 0; i < total; i++) {
|
|
8416
|
+
const slice = data.subarray(i * CHUNK, (i + 1) * CHUNK);
|
|
8417
|
+
const frame = new Uint8Array(HEADER$1 + slice.length);
|
|
8418
|
+
const dv = new DataView(frame.buffer);
|
|
8419
|
+
dv.setUint32(0, msgId);
|
|
8420
|
+
dv.setUint32(4, i);
|
|
8421
|
+
dv.setUint32(8, total);
|
|
8422
|
+
dv.setUint32(12, channel);
|
|
8423
|
+
frame.set(slice, HEADER$1);
|
|
8424
|
+
if (dc.bufferedAmount > MAX_BUFFERED)
|
|
8425
|
+
await this.drain(dc);
|
|
8426
|
+
dc.send(frame);
|
|
8427
|
+
}
|
|
8428
|
+
}
|
|
8429
|
+
drain(dc) {
|
|
8430
|
+
return new Promise((resolve) => {
|
|
8431
|
+
dc.bufferedAmountLowThreshold = MAX_BUFFERED / 2;
|
|
8432
|
+
const onLow = () => { dc.removeEventListener("bufferedamountlow", onLow); resolve(); };
|
|
8433
|
+
dc.addEventListener("bufferedamountlow", onLow);
|
|
8434
|
+
});
|
|
8435
|
+
}
|
|
8436
|
+
onChunk(peerId, conn, frame) {
|
|
8437
|
+
if (frame.length < HEADER$1)
|
|
8438
|
+
return;
|
|
8439
|
+
const dv = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
|
|
8440
|
+
const msgId = dv.getUint32(0);
|
|
8441
|
+
const index = dv.getUint32(4);
|
|
8442
|
+
const total = dv.getUint32(8);
|
|
8443
|
+
const channel = dv.getUint32(12);
|
|
8444
|
+
const chunk = frame.slice(HEADER$1);
|
|
8445
|
+
let r = conn.reasm.get(msgId);
|
|
8446
|
+
if (!r) {
|
|
8447
|
+
r = { chunks: new Array(total), received: 0, total, size: 0, channel };
|
|
8448
|
+
conn.reasm.set(msgId, r);
|
|
8449
|
+
}
|
|
8450
|
+
if (r.chunks[index])
|
|
8451
|
+
return; // dup
|
|
8452
|
+
r.chunks[index] = chunk;
|
|
8453
|
+
r.received++;
|
|
8454
|
+
r.size += chunk.length;
|
|
8455
|
+
if (r.received < r.total)
|
|
8456
|
+
return;
|
|
8457
|
+
conn.reasm.delete(msgId);
|
|
8458
|
+
const out = new Uint8Array(r.size);
|
|
8459
|
+
let off = 0;
|
|
8460
|
+
for (const c of r.chunks) {
|
|
8461
|
+
if (!c)
|
|
8462
|
+
return; // gap — shouldn't happen on reliable ordered
|
|
8463
|
+
out.set(c, off);
|
|
8464
|
+
off += c.length;
|
|
8465
|
+
}
|
|
8466
|
+
for (const cb of this.msgCbs)
|
|
8467
|
+
cb(peerId, out, r.channel);
|
|
8468
|
+
}
|
|
8469
|
+
}
|
|
8470
|
+
|
|
8471
|
+
/**
|
|
8472
|
+
* `PeerTransport` — the pluggable wire under the block engine. It moves opaque
|
|
8473
|
+
* frames between Space peers; it knows nothing about blocks, hashes, or the
|
|
8474
|
+
* protocol. {@link ./WebRtcTransport} implements it over RTCDataChannels today;
|
|
8475
|
+
* a future libp2p/Helia transport can implement the same interface without the
|
|
8476
|
+
* block engine or `ShardClient` changing.
|
|
8477
|
+
*
|
|
8478
|
+
* Implementations are responsible for fragmenting/ reassembling frames larger
|
|
8479
|
+
* than the underlying channel's max message size (a 4 MiB shard won't fit in one
|
|
8480
|
+
* `RTCDataChannel.send`); the engine always deals in whole frames.
|
|
8481
|
+
*/
|
|
8482
|
+
/**
|
|
8483
|
+
* Logical channels multiplexed over the one data link. `0` is block exchange
|
|
8484
|
+
* (the engine), `1` is gossip (app/CRDT data). The tag rides the framing header
|
|
8485
|
+
* so payloads aren't copied to prepend it.
|
|
8486
|
+
*/
|
|
8487
|
+
const CHANNEL_BLOCK = 0;
|
|
8488
|
+
const CHANNEL_GOSSIP = 1;
|
|
8489
|
+
|
|
8490
|
+
/**
|
|
8491
|
+
* Bitswap-lite wire protocol for peer block exchange. Four frame types over a
|
|
8492
|
+
* {@link ../transport/PeerTransport} data channel:
|
|
8493
|
+
*
|
|
8494
|
+
* WANT(hash) — "does anyone have this block?" / "send it to me"
|
|
8495
|
+
* HAVE(hash) — "I hold this block" (announce / reply to a WANT)
|
|
8496
|
+
* BLOCK(hash, bytes) — the block itself (verified by hash on receipt)
|
|
8497
|
+
* CANCEL(hash) — "stop, I got it elsewhere"
|
|
8498
|
+
*
|
|
8499
|
+
* Frames are compact binary so a 4 MiB shard isn't base64-bloated through JSON:
|
|
8500
|
+
*
|
|
8501
|
+
* byte 0 : type (0 WANT | 1 HAVE | 2 BLOCK | 3 CANCEL)
|
|
8502
|
+
* bytes 1..33 : 32-byte raw SHA-256 of the block (our shard hash, hex-decoded)
|
|
8503
|
+
* bytes 33.. : payload (BLOCK only)
|
|
8504
|
+
*
|
|
8505
|
+
* Blocks are AES-GCM ciphertext, so a frame carries nothing readable; the hash
|
|
8506
|
+
* is the capability check (the receiver re-hashes the payload and drops a
|
|
8507
|
+
* mismatch). See {@link ./blockEngine}.
|
|
8508
|
+
*/
|
|
8509
|
+
var FrameType;
|
|
8510
|
+
(function (FrameType) {
|
|
8511
|
+
FrameType[FrameType["WANT"] = 0] = "WANT";
|
|
8512
|
+
FrameType[FrameType["HAVE"] = 1] = "HAVE";
|
|
8513
|
+
FrameType[FrameType["BLOCK"] = 2] = "BLOCK";
|
|
8514
|
+
FrameType[FrameType["CANCEL"] = 3] = "CANCEL";
|
|
8515
|
+
})(FrameType || (FrameType = {}));
|
|
8516
|
+
const HASH_BYTES = 32;
|
|
8517
|
+
const HEADER = 1 + HASH_BYTES; // type + hash
|
|
8518
|
+
function hexToBytes(hex) {
|
|
8519
|
+
if (hex.length !== HASH_BYTES * 2) {
|
|
8520
|
+
throw new Error(`protocol: expected a ${HASH_BYTES}-byte hex hash, got ${hex.length / 2} bytes`);
|
|
8521
|
+
}
|
|
8522
|
+
const out = new Uint8Array(HASH_BYTES);
|
|
8523
|
+
for (let i = 0; i < HASH_BYTES; i++)
|
|
8524
|
+
out[i] = parseInt(hex.substr(i * 2, 2), 16);
|
|
8525
|
+
return out;
|
|
8526
|
+
}
|
|
8527
|
+
const HEX = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
|
|
8528
|
+
function bytesToHex(bytes) {
|
|
8529
|
+
let s = "";
|
|
8530
|
+
for (let i = 0; i < bytes.length; i++)
|
|
8531
|
+
s += HEX[bytes[i]];
|
|
8532
|
+
return s;
|
|
8533
|
+
}
|
|
8534
|
+
function header(type, hash, extra = 0) {
|
|
8535
|
+
const buf = new Uint8Array(HEADER + extra);
|
|
8536
|
+
buf[0] = type;
|
|
8537
|
+
buf.set(hexToBytes(hash), 1);
|
|
8538
|
+
return { buf, offset: HEADER };
|
|
8539
|
+
}
|
|
8540
|
+
function encodeWant(hash) {
|
|
8541
|
+
return header(FrameType.WANT, hash).buf;
|
|
8542
|
+
}
|
|
8543
|
+
function encodeHave(hash) {
|
|
8544
|
+
return header(FrameType.HAVE, hash).buf;
|
|
8545
|
+
}
|
|
8546
|
+
function encodeCancel(hash) {
|
|
8547
|
+
return header(FrameType.CANCEL, hash).buf;
|
|
8548
|
+
}
|
|
8549
|
+
function encodeBlock(hash, payload) {
|
|
8550
|
+
const { buf, offset } = header(FrameType.BLOCK, hash, payload.length);
|
|
8551
|
+
buf.set(payload, offset);
|
|
8552
|
+
return buf;
|
|
8553
|
+
}
|
|
8554
|
+
function decode(frame) {
|
|
8555
|
+
if (frame.length < HEADER)
|
|
8556
|
+
throw new Error("protocol: frame too short");
|
|
8557
|
+
const type = frame[0];
|
|
8558
|
+
const hash = bytesToHex(frame.subarray(1, HEADER));
|
|
8559
|
+
if (type === FrameType.BLOCK) {
|
|
8560
|
+
// Copy the payload out so it doesn't alias a transferred/pooled buffer.
|
|
8561
|
+
return { type, hash, payload: frame.slice(HEADER) };
|
|
8562
|
+
}
|
|
8563
|
+
return { type, hash };
|
|
8564
|
+
}
|
|
8565
|
+
|
|
8566
|
+
var protocol = /*#__PURE__*/Object.freeze({
|
|
8567
|
+
__proto__: null,
|
|
8568
|
+
get FrameType () { return FrameType; },
|
|
8569
|
+
decode: decode,
|
|
8570
|
+
encodeBlock: encodeBlock,
|
|
8571
|
+
encodeCancel: encodeCancel,
|
|
8572
|
+
encodeHave: encodeHave,
|
|
8573
|
+
encodeWant: encodeWant
|
|
8574
|
+
});
|
|
8575
|
+
|
|
8576
|
+
/**
|
|
8577
|
+
* `BlockEngine` — the bitswap-lite want/have ledger. Host-agnostic: it speaks
|
|
8578
|
+
* only to a {@link BlockStore} (get/put/has) and an `out` sink that ships
|
|
8579
|
+
* encoded frames to a peer (or `"*"` to broadcast). The host wires `out` and
|
|
8580
|
+
* `handleFrame` to a real transport — on the main thread or inside the Web
|
|
8581
|
+
* Worker (see {@link ./engineClient}) — so this class is pure and unit-testable.
|
|
8582
|
+
*
|
|
8583
|
+
* It never sees keys: blocks are AES-GCM ciphertext, addressed by SHA-256. The
|
|
8584
|
+
* one trust check is integrity — an inbound BLOCK is re-hashed and dropped on
|
|
8585
|
+
* mismatch, so a malicious peer can't poison the store (the requester just
|
|
8586
|
+
* falls back to origin).
|
|
8587
|
+
*/
|
|
8588
|
+
class BlockEngine {
|
|
8589
|
+
store;
|
|
8590
|
+
out;
|
|
8591
|
+
hash;
|
|
8592
|
+
wants = new Map();
|
|
8593
|
+
constructor(store, out, hash) {
|
|
8594
|
+
this.store = store;
|
|
8595
|
+
this.out = out;
|
|
8596
|
+
this.hash = hash;
|
|
8597
|
+
}
|
|
8598
|
+
/**
|
|
8599
|
+
* Ask the swarm for a block. Resolves with the verified bytes, or `null` on
|
|
8600
|
+
* timeout (caller then falls back to origin). Returns immediately if we
|
|
8601
|
+
* already hold it; concurrent wants for the same hash share one request.
|
|
8602
|
+
*/
|
|
8603
|
+
want(hash, timeoutMs) {
|
|
8604
|
+
return new Promise((resolve) => {
|
|
8605
|
+
void this.store.get(hash).then((local) => {
|
|
8606
|
+
if (local)
|
|
8607
|
+
return resolve(local);
|
|
8608
|
+
const existing = this.wants.get(hash);
|
|
8609
|
+
if (existing) {
|
|
8610
|
+
existing.resolvers.push(resolve);
|
|
8611
|
+
return;
|
|
8612
|
+
}
|
|
8613
|
+
const timer = setTimeout(() => {
|
|
8614
|
+
const w = this.wants.get(hash);
|
|
8615
|
+
this.wants.delete(hash);
|
|
8616
|
+
this.out("*", encodeCancel(hash));
|
|
8617
|
+
w?.resolvers.forEach((r) => r(null));
|
|
8618
|
+
}, timeoutMs);
|
|
8619
|
+
this.wants.set(hash, { resolvers: [resolve], timer });
|
|
8620
|
+
this.out("*", encodeWant(hash));
|
|
8621
|
+
});
|
|
8622
|
+
});
|
|
8623
|
+
}
|
|
8624
|
+
/** Tell the swarm we now hold `hash` (so peers wanting it can pull from us). */
|
|
8625
|
+
announce(hash) {
|
|
8626
|
+
this.out("*", encodeHave(hash));
|
|
8627
|
+
}
|
|
8628
|
+
/** Handle one inbound frame from `peer`. */
|
|
8629
|
+
async handleFrame(peer, frame) {
|
|
8630
|
+
let f;
|
|
8631
|
+
try {
|
|
8632
|
+
f = decode(frame);
|
|
8633
|
+
}
|
|
8634
|
+
catch {
|
|
8635
|
+
return; // malformed — ignore
|
|
8636
|
+
}
|
|
8637
|
+
switch (f.type) {
|
|
8638
|
+
case FrameType.WANT: {
|
|
8639
|
+
const bytes = await this.store.get(f.hash);
|
|
8640
|
+
if (bytes)
|
|
8641
|
+
this.out(peer, encodeBlock(f.hash, bytes));
|
|
8642
|
+
return;
|
|
8643
|
+
}
|
|
8644
|
+
case FrameType.HAVE: {
|
|
8645
|
+
// A peer announced a block we're still waiting for → pull from them.
|
|
8646
|
+
if (this.wants.has(f.hash))
|
|
8647
|
+
this.out(peer, encodeWant(f.hash));
|
|
8648
|
+
return;
|
|
8649
|
+
}
|
|
8650
|
+
case FrameType.BLOCK: {
|
|
8651
|
+
const pending = this.wants.get(f.hash);
|
|
8652
|
+
if (!pending || !f.payload)
|
|
8653
|
+
return; // unsolicited / empty — ignore
|
|
8654
|
+
// Integrity: a peer can't poison the store. Drop a mismatch and
|
|
8655
|
+
// keep waiting (the timeout will fall back to origin).
|
|
8656
|
+
const got = await this.hash(f.payload);
|
|
8657
|
+
if (got !== f.hash)
|
|
8658
|
+
return;
|
|
8659
|
+
await this.store.put(f.hash, f.payload);
|
|
8660
|
+
this.wants.delete(f.hash);
|
|
8661
|
+
clearTimeout(pending.timer);
|
|
8662
|
+
this.out("*", encodeCancel(f.hash)); // others can stop sending
|
|
8663
|
+
pending.resolvers.forEach((r) => r(f.payload));
|
|
8664
|
+
return;
|
|
8665
|
+
}
|
|
8666
|
+
case FrameType.CANCEL:
|
|
8667
|
+
// We serve WANTs synchronously, so there's nothing in flight to
|
|
8668
|
+
// cancel yet. Reserved for chunked-send cancellation later.
|
|
8669
|
+
return;
|
|
8670
|
+
}
|
|
8671
|
+
}
|
|
8672
|
+
/** Drop all pending waits (resolve null) — on teardown. */
|
|
8673
|
+
close() {
|
|
8674
|
+
for (const [, w] of this.wants) {
|
|
8675
|
+
clearTimeout(w.timer);
|
|
8676
|
+
w.resolvers.forEach((r) => r(null));
|
|
8677
|
+
}
|
|
8678
|
+
this.wants.clear();
|
|
8679
|
+
}
|
|
8680
|
+
}
|
|
8681
|
+
|
|
8682
|
+
/**
|
|
8683
|
+
* `EngineHost` — the boundary {@link ../PeerNetwork} wires a transport to. It
|
|
8684
|
+
* abstracts *where* the {@link ./blockEngine.BlockEngine} runs:
|
|
8685
|
+
* - {@link LocalEngineHost} runs it on the calling thread (tests, or a
|
|
8686
|
+
* fallback when the Web Worker can't be built by a consumer's bundler);
|
|
8687
|
+
* - the Worker proxy ({@link ./engineClient}) runs it off-thread.
|
|
8688
|
+
*
|
|
8689
|
+
* Either way the host exposes the same surface: drive `want`/`announce`, feed it
|
|
8690
|
+
* inbound frames via `handleFrame`, and receive its outbound frames via
|
|
8691
|
+
* `onOutbound` (which the network forwards to the transport).
|
|
8692
|
+
*/
|
|
8693
|
+
/** Runs the engine in-process (main thread or test). */
|
|
8694
|
+
class LocalEngineHost {
|
|
8695
|
+
engine;
|
|
8696
|
+
cbs = new Set();
|
|
8697
|
+
constructor(store, hasher) {
|
|
8698
|
+
this.engine = new BlockEngine(store, (target, frame) => {
|
|
8699
|
+
for (const cb of this.cbs)
|
|
8700
|
+
cb(target, frame);
|
|
8701
|
+
}, hasher);
|
|
8702
|
+
}
|
|
8703
|
+
want(hash, timeoutMs) {
|
|
8704
|
+
return this.engine.want(hash, timeoutMs);
|
|
8705
|
+
}
|
|
8706
|
+
announce(hash) {
|
|
8707
|
+
this.engine.announce(hash);
|
|
8708
|
+
}
|
|
8709
|
+
handleFrame(peer, frame) {
|
|
8710
|
+
void this.engine.handleFrame(peer, frame);
|
|
8711
|
+
}
|
|
8712
|
+
onOutbound(cb) {
|
|
8713
|
+
this.cbs.add(cb);
|
|
8714
|
+
return () => this.cbs.delete(cb);
|
|
8715
|
+
}
|
|
8716
|
+
close() {
|
|
8717
|
+
this.engine.close();
|
|
8718
|
+
this.cbs.clear();
|
|
8719
|
+
}
|
|
8720
|
+
}
|
|
8721
|
+
|
|
8722
|
+
/**
|
|
8723
|
+
* `WorkerEngineHost` — the main-thread proxy to a {@link ./blockEngine.BlockEngine}
|
|
8724
|
+
* running inside a dedicated Web Worker (so hashing, blockstore I/O, and the
|
|
8725
|
+
* protocol codec stay off the main thread). It speaks the small message protocol
|
|
8726
|
+
* in {@link ./blockEngine.worker} and satisfies {@link ./engineHost.EngineHost},
|
|
8727
|
+
* so {@link ../PeerNetwork} treats it identically to the in-process host.
|
|
8728
|
+
*
|
|
8729
|
+
* Block bytes cross the worker boundary as **transferable** ArrayBuffers
|
|
8730
|
+
* (zero-copy). Only encrypted blocks ever cross — keys never enter the worker.
|
|
8731
|
+
*/
|
|
8732
|
+
class WorkerEngineHost {
|
|
8733
|
+
worker;
|
|
8734
|
+
wants = new Map();
|
|
8735
|
+
cbs = new Set();
|
|
8736
|
+
nextId = 1;
|
|
8737
|
+
constructor(worker) {
|
|
8738
|
+
this.worker = worker;
|
|
8739
|
+
this.worker.onmessage = (e) => {
|
|
8740
|
+
const m = e.data;
|
|
8741
|
+
if (m.t === "out") {
|
|
8742
|
+
const frame = new Uint8Array(m.data);
|
|
8743
|
+
for (const cb of this.cbs)
|
|
8744
|
+
cb(m.target, frame);
|
|
8745
|
+
}
|
|
8746
|
+
else if (m.t === "want-result") {
|
|
8747
|
+
const resolve = this.wants.get(m.id);
|
|
8748
|
+
if (resolve) {
|
|
8749
|
+
this.wants.delete(m.id);
|
|
8750
|
+
resolve(m.data ? new Uint8Array(m.data) : null);
|
|
8751
|
+
}
|
|
8752
|
+
}
|
|
8753
|
+
};
|
|
8754
|
+
}
|
|
8755
|
+
want(hash, timeoutMs) {
|
|
8756
|
+
return new Promise((resolve) => {
|
|
8757
|
+
const id = this.nextId++;
|
|
8758
|
+
this.wants.set(id, resolve);
|
|
8759
|
+
this.worker.postMessage({ t: "want", id, hash, timeout: timeoutMs });
|
|
8760
|
+
});
|
|
8761
|
+
}
|
|
8762
|
+
announce(hash) {
|
|
8763
|
+
this.worker.postMessage({ t: "announce", hash });
|
|
8764
|
+
}
|
|
8765
|
+
handleFrame(peer, frame) {
|
|
8766
|
+
// Transfer an exact-size buffer; copy if the view doesn't own the whole buffer.
|
|
8767
|
+
const buf = frame.byteOffset === 0 && frame.byteLength === frame.buffer.byteLength
|
|
8768
|
+
? frame.buffer
|
|
8769
|
+
: frame.slice().buffer;
|
|
8770
|
+
this.worker.postMessage({ t: "frame", peer, data: buf }, [buf]);
|
|
8771
|
+
}
|
|
8772
|
+
onOutbound(cb) {
|
|
8773
|
+
this.cbs.add(cb);
|
|
8774
|
+
return () => this.cbs.delete(cb);
|
|
8775
|
+
}
|
|
8776
|
+
close() {
|
|
8777
|
+
try {
|
|
8778
|
+
this.worker.postMessage({ t: "close" });
|
|
8779
|
+
}
|
|
8780
|
+
catch { /* already gone */ }
|
|
8781
|
+
this.worker.terminate();
|
|
8782
|
+
this.wants.forEach((r) => r(null));
|
|
8783
|
+
this.wants.clear();
|
|
8784
|
+
this.cbs.clear();
|
|
8785
|
+
}
|
|
8786
|
+
}
|
|
8787
|
+
|
|
8788
|
+
/**
|
|
8789
|
+
* `PeerNetwork` — the per-Space orchestrator that ties the pieces together on
|
|
8790
|
+
* the main thread:
|
|
8791
|
+
*
|
|
8792
|
+
* transport (WebRTC) ⇄ engine host (Worker or in-process) → PeerExchange
|
|
8793
|
+
* ▲ │
|
|
8794
|
+
* └────────────── signaling over the Space relay ─────────────┘
|
|
8795
|
+
*
|
|
8796
|
+
* It owns one {@link ./transport/WebRtcTransport} + one {@link
|
|
8797
|
+
* ./worker/engineHost.EngineHost}, wires inbound frames to the engine and the
|
|
8798
|
+
* engine's outbound frames back to the transport, runs peer discovery over the
|
|
8799
|
+
* Space's ephemeral channel, and exposes a {@link ./PeerExchange.PeerExchange}
|
|
8800
|
+
* for `ShardClient` to consult. Scope is one Space (private swarm).
|
|
8801
|
+
*
|
|
8802
|
+
* `engineHost` defaults to in-process; pass `workerFactory` to run the block
|
|
8803
|
+
* engine off the main thread (the consumer's bundler resolves the worker URL).
|
|
8804
|
+
*/
|
|
8805
|
+
/**
|
|
8806
|
+
* Build the engine host, preferring an off-thread Web Worker:
|
|
8807
|
+
* 1. an explicit `workerFactory` (most reliable in app bundlers), else
|
|
8808
|
+
* 2. the bundled worker resolved relative to this module (works where the
|
|
8809
|
+
* bundler supports `new Worker(new URL(...))`, e.g. Vite builds), else
|
|
8810
|
+
* 3. the in-process engine (always works; tests + non-worker runtimes).
|
|
8811
|
+
* Any worker construction failure degrades to in-process.
|
|
8812
|
+
*/
|
|
8813
|
+
function buildEngineHost(opts) {
|
|
8814
|
+
try {
|
|
8815
|
+
const worker = opts.workerFactory ? opts.workerFactory() : defaultWorker();
|
|
8816
|
+
if (worker)
|
|
8817
|
+
return new WorkerEngineHost(worker);
|
|
8818
|
+
}
|
|
8819
|
+
catch {
|
|
8820
|
+
/* no Worker global / bundler can't resolve it → in-process */
|
|
8821
|
+
}
|
|
8822
|
+
return new LocalEngineHost(opts.store ?? new ShardCache(), shardHash);
|
|
8823
|
+
}
|
|
8824
|
+
/** Construct the bundled worker if the runtime + bundler support it, else null. */
|
|
8825
|
+
function defaultWorker() {
|
|
8826
|
+
try {
|
|
8827
|
+
if (typeof Worker === "undefined")
|
|
8828
|
+
return null;
|
|
8829
|
+
// The string is resolved by the consumer's bundler relative to the
|
|
8830
|
+
// built connect module (sibling of dist/browser/index.js).
|
|
8831
|
+
return new Worker(new URL("./blockEngine.worker.js", import.meta.url), { type: "module" });
|
|
8832
|
+
}
|
|
8833
|
+
catch {
|
|
8834
|
+
return null;
|
|
8835
|
+
}
|
|
8836
|
+
}
|
|
8837
|
+
class PeerNetwork {
|
|
8838
|
+
/** Pass this to a `ShardClient` as `peers`. */
|
|
8839
|
+
exchange;
|
|
8840
|
+
transport;
|
|
8841
|
+
host;
|
|
8842
|
+
signaler;
|
|
8843
|
+
offs = [];
|
|
8844
|
+
gossipCbs = new Set();
|
|
8845
|
+
closed = false;
|
|
8846
|
+
constructor(opts) {
|
|
8847
|
+
this.signaler = new SpaceSignaler(opts.space, opts.myId);
|
|
8848
|
+
this.transport =
|
|
8849
|
+
opts.transport ??
|
|
8850
|
+
new WebRtcTransport(this.signaler, opts.myId, {
|
|
8851
|
+
iceServers: opts.iceServers,
|
|
8852
|
+
maxPeers: opts.maxPeers,
|
|
8853
|
+
});
|
|
8854
|
+
this.host = buildEngineHost(opts);
|
|
8855
|
+
// Wire transport ⇄ engine, demuxing block (engine) from gossip (app).
|
|
8856
|
+
this.offs.push(this.transport.onMessage((peer, data, channel) => {
|
|
8857
|
+
if (channel === CHANNEL_GOSSIP) {
|
|
8858
|
+
for (const cb of this.gossipCbs)
|
|
8859
|
+
cb(peer, data);
|
|
8860
|
+
}
|
|
8861
|
+
else {
|
|
8862
|
+
this.host.handleFrame(peer, data);
|
|
8863
|
+
}
|
|
8864
|
+
}));
|
|
8865
|
+
this.offs.push(this.host.onOutbound((target, frame) => target === "*"
|
|
8866
|
+
? this.transport.broadcast(frame, CHANNEL_BLOCK)
|
|
8867
|
+
: this.transport.send(target, frame, CHANNEL_BLOCK)));
|
|
8868
|
+
// Discovery: a broadcast hello triggers a unicast hello back (so a
|
|
8869
|
+
// newcomer learns existing members); both sides connectTo (the smaller
|
|
8870
|
+
// id actually dials). Deterministic + idempotent.
|
|
8871
|
+
this.offs.push(this.signaler.onSignal((from, kind, data) => {
|
|
8872
|
+
if (kind !== "hello")
|
|
8873
|
+
return;
|
|
8874
|
+
this.transport.connectTo?.(from);
|
|
8875
|
+
if (data?.broadcast) {
|
|
8876
|
+
this.signaler.send(from, "hello", { broadcast: false });
|
|
8877
|
+
}
|
|
8878
|
+
}));
|
|
8879
|
+
this.exchange = new PeerExchange(this.host, { timeoutMs: opts.timeoutMs });
|
|
8880
|
+
}
|
|
8881
|
+
/**
|
|
8882
|
+
* Begin peer discovery — broadcast a hello so existing members dial us and
|
|
8883
|
+
* we learn them. Call once the signaling channel is live (the Space fires
|
|
8884
|
+
* this on CONNECTED); safe to call again on reconnect.
|
|
8885
|
+
*/
|
|
8886
|
+
start() {
|
|
8887
|
+
if (this.closed)
|
|
8888
|
+
return;
|
|
8889
|
+
this.signaler.broadcast("hello", { broadcast: true });
|
|
8890
|
+
}
|
|
8891
|
+
/**
|
|
8892
|
+
* Broadcast an app payload to all connected peers over the gossip channel —
|
|
8893
|
+
* a direct, server-bypassing data path for CRDT ops / high-frequency state
|
|
8894
|
+
* (cursors, presence, collaborative edits). Single-hop (no re-forwarding).
|
|
8895
|
+
*/
|
|
8896
|
+
gossip(data) {
|
|
8897
|
+
this.transport.broadcast(data, CHANNEL_GOSSIP);
|
|
8898
|
+
}
|
|
8899
|
+
/** Subscribe to gossip from peers. Returns an unsubscribe function. */
|
|
8900
|
+
onGossip(cb) {
|
|
8901
|
+
this.gossipCbs.add(cb);
|
|
8902
|
+
return () => this.gossipCbs.delete(cb);
|
|
8903
|
+
}
|
|
8904
|
+
/** Connected peer count (open data channels). */
|
|
8905
|
+
peerCount() {
|
|
8906
|
+
return this.transport.peers().length;
|
|
8907
|
+
}
|
|
8908
|
+
close() {
|
|
8909
|
+
if (this.closed)
|
|
8910
|
+
return;
|
|
8911
|
+
this.closed = true;
|
|
8912
|
+
try {
|
|
8913
|
+
this.signaler.broadcast("bye");
|
|
8914
|
+
}
|
|
8915
|
+
catch { /* socket may be gone */ }
|
|
8916
|
+
for (const off of this.offs)
|
|
8917
|
+
off();
|
|
8918
|
+
this.transport.close();
|
|
8919
|
+
this.host.close();
|
|
8920
|
+
}
|
|
8921
|
+
}
|
|
8922
|
+
|
|
8923
|
+
/**
|
|
8924
|
+
* Whether this environment can do WebRTC peering. `RTCPeerConnection` is
|
|
8925
|
+
* Window-only (absent in Node/Workers/SSR), so this gates whether a
|
|
8926
|
+
* {@link ./PeerNetwork} can be built at all.
|
|
8927
|
+
*/
|
|
8928
|
+
function isP2pCapable() {
|
|
8929
|
+
try {
|
|
8930
|
+
return typeof RTCPeerConnection !== "undefined";
|
|
8931
|
+
}
|
|
8932
|
+
catch {
|
|
8933
|
+
return false;
|
|
8934
|
+
}
|
|
8935
|
+
}
|
|
8936
|
+
|
|
8044
8937
|
/**
|
|
8045
8938
|
* `client.space` — the fan-out group-encryption surface.
|
|
8046
8939
|
*
|
|
@@ -8272,6 +9165,17 @@ class SpaceNamespace {
|
|
|
8272
9165
|
: undefined,
|
|
8273
9166
|
});
|
|
8274
9167
|
this.openSpaces.set(spaceId, space);
|
|
9168
|
+
// Attach a private P2P swarm scoped to this Space (block exchange among
|
|
9169
|
+
// members; signaled over the Space relay). Discovery starts on connect.
|
|
9170
|
+
if (this.deps.p2p?.enabled && isP2pCapable()) {
|
|
9171
|
+
space.attachPeerNetwork(new PeerNetwork({
|
|
9172
|
+
space,
|
|
9173
|
+
myId: memberId,
|
|
9174
|
+
workerFactory: this.deps.p2p.workerFactory,
|
|
9175
|
+
iceServers: this.deps.p2p.iceServers,
|
|
9176
|
+
maxPeers: this.deps.p2p.maxPeers,
|
|
9177
|
+
}));
|
|
9178
|
+
}
|
|
8275
9179
|
return space;
|
|
8276
9180
|
}
|
|
8277
9181
|
/** Ensure the member has an identity keypair; return its public ECDH + ECDSA keys. */
|
|
@@ -9035,82 +9939,6 @@ class SnapshotNamespace {
|
|
|
9035
9939
|
}
|
|
9036
9940
|
}
|
|
9037
9941
|
|
|
9038
|
-
/**
|
|
9039
|
-
* `ShardCache` — a Cache-API-backed store for file-shard bytes. Shards are
|
|
9040
|
-
* content-addressed (the key IS the SHA-256 of the ciphertext), so caching them
|
|
9041
|
-
* is uniquely safe: a cache hit is self-validating, immutable, and shareable
|
|
9042
|
-
* across files. The Cache API (rather than IndexedDB) is the right home for
|
|
9043
|
-
* large binary blobs — it streams, it's purpose-built for response bodies, and
|
|
9044
|
-
* it has its own eviction.
|
|
9045
|
-
*
|
|
9046
|
-
* Keys are SYNTHETIC absolute URLs (`https://shard.muhkoo.local/<hash>`) rather
|
|
9047
|
-
* than the real shard endpoint, so the cache is addressable by hash alone — the
|
|
9048
|
-
* sync engine can re-PUT a deferred shard knowing only its hash, without
|
|
9049
|
-
* reconstructing the accelerator URL. We never fetch these synthetic URLs; they
|
|
9050
|
-
* exist only as cache keys.
|
|
9051
|
-
*
|
|
9052
|
-
* Every method degrades to a no-op / null where the Cache API is unavailable
|
|
9053
|
-
* (Node, Workers, insecure contexts), so callers don't need to branch.
|
|
9054
|
-
*/
|
|
9055
|
-
const CACHE_NAME = "muhkoo-shards-v1";
|
|
9056
|
-
class ShardCache {
|
|
9057
|
-
static available() {
|
|
9058
|
-
try {
|
|
9059
|
-
return typeof caches !== "undefined";
|
|
9060
|
-
}
|
|
9061
|
-
catch {
|
|
9062
|
-
return false;
|
|
9063
|
-
}
|
|
9064
|
-
}
|
|
9065
|
-
key(hash) {
|
|
9066
|
-
return `https://shard.muhkoo.local/${encodeURIComponent(hash)}`;
|
|
9067
|
-
}
|
|
9068
|
-
open() {
|
|
9069
|
-
return caches.open(CACHE_NAME);
|
|
9070
|
-
}
|
|
9071
|
-
/** Cached shard bytes, or `null` on miss / when unavailable. */
|
|
9072
|
-
async get(hash) {
|
|
9073
|
-
if (!ShardCache.available())
|
|
9074
|
-
return null;
|
|
9075
|
-
try {
|
|
9076
|
-
const cache = await this.open();
|
|
9077
|
-
const res = await cache.match(this.key(hash));
|
|
9078
|
-
if (!res)
|
|
9079
|
-
return null;
|
|
9080
|
-
return new Uint8Array(await res.arrayBuffer());
|
|
9081
|
-
}
|
|
9082
|
-
catch {
|
|
9083
|
-
return null;
|
|
9084
|
-
}
|
|
9085
|
-
}
|
|
9086
|
-
/** Store shard bytes under their hash. Best-effort. */
|
|
9087
|
-
async put(hash, bytes) {
|
|
9088
|
-
if (!ShardCache.available())
|
|
9089
|
-
return;
|
|
9090
|
-
try {
|
|
9091
|
-
const cache = await this.open();
|
|
9092
|
-
await cache.put(this.key(hash), new Response(bytes, {
|
|
9093
|
-
headers: { "content-type": "application/octet-stream" },
|
|
9094
|
-
}));
|
|
9095
|
-
}
|
|
9096
|
-
catch {
|
|
9097
|
-
/* quota / unavailable — degrade to online-only for this shard */
|
|
9098
|
-
}
|
|
9099
|
-
}
|
|
9100
|
-
/** Whether a shard is present in the cache. */
|
|
9101
|
-
async has(hash) {
|
|
9102
|
-
if (!ShardCache.available())
|
|
9103
|
-
return false;
|
|
9104
|
-
try {
|
|
9105
|
-
const cache = await this.open();
|
|
9106
|
-
return Boolean(await cache.match(this.key(hash)));
|
|
9107
|
-
}
|
|
9108
|
-
catch {
|
|
9109
|
-
return false;
|
|
9110
|
-
}
|
|
9111
|
-
}
|
|
9112
|
-
}
|
|
9113
|
-
|
|
9114
9942
|
/**
|
|
9115
9943
|
* IndexedDB schema for the offline layer — the single source of truth for the
|
|
9116
9944
|
* database name, version, and object stores. Bump {@link DB_VERSION} whenever
|
|
@@ -9833,6 +10661,7 @@ class Client {
|
|
|
9833
10661
|
wsBaseUrl,
|
|
9834
10662
|
cache: spaceKeyCache,
|
|
9835
10663
|
offline: this.offline,
|
|
10664
|
+
p2p: options.p2p,
|
|
9836
10665
|
});
|
|
9837
10666
|
this.agents = new AgentsNamespace({ http: this.http });
|
|
9838
10667
|
this.functions = new FunctionsNamespace({ http: this.http, fnHostSuffix: options.functionsHostSuffix });
|
|
@@ -10724,5 +11553,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
|
|
|
10724
11553
|
default: loadRsWasm
|
|
10725
11554
|
});
|
|
10726
11555
|
|
|
10727
|
-
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_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DbCache, DbNamespace, DbTable, 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, Logger, 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, Ready, ReedSolomonCodec, Retry, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, StorageNamespace, SyncEngine, VERSION, VaultUnavailableError, WSTransport, ZERO_HLC, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, compareHlc, compilePrograms, concatBytes, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, fromBase64$1 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isNumber, isOfflineCapable, isProvisional, log, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, poseidonHash, provisionalHandle, quickVerify, randomBytes, randomState, sealSeed, serialize, shardHash, signMessage, stableStringify, toBase64$1 as toBase64, toBase64Url, toField, toHex, tombstoneRow, unpack, unsealSeed, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
|
|
11556
|
+
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_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DbCache, DbNamespace, DbTable, 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, Logger, 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, Ready, ReedSolomonCodec, Retry, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VERSION, VaultUnavailableError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, compareHlc, compilePrograms, concatBytes, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, fromBase64$1 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isNumber, isOfflineCapable, isP2pCapable, isProvisional, log, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, sealSeed, serialize, shardHash, signMessage, stableStringify, toBase64$1 as toBase64, toBase64Url, toField, toHex, tombstoneRow, unpack, unsealSeed, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
|
|
10728
11557
|
//# sourceMappingURL=index.js.map
|