@muhkoo/connect 0.8.0-alpha.0 → 0.10.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 +1332 -133
- package/dist/browser/index.js.map +1 -1
- package/dist/connect.d.ts +548 -5
- package/dist/server/index.js +1332 -133
- 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.10.0-alpha.0";
|
|
2980
2980
|
|
|
2981
2981
|
/**
|
|
2982
2982
|
* `HttpClient` — the one place the unified {@link Client} talks to the
|
|
@@ -2999,6 +2999,28 @@ const VERSION = "0.8.0-alpha.0";
|
|
|
2999
2999
|
* convenience JSON helpers (`get`/`post`/`del`) for code that talks to the
|
|
3000
3000
|
* accelerator directly.
|
|
3001
3001
|
*/
|
|
3002
|
+
/** Per-request timing logs, enabled on staging only (temporary diagnostic). */
|
|
3003
|
+
const HTTP_DEBUG = (() => {
|
|
3004
|
+
try {
|
|
3005
|
+
return typeof location !== "undefined" && /(^|\.)staging\./.test(location.hostname);
|
|
3006
|
+
}
|
|
3007
|
+
catch {
|
|
3008
|
+
return false;
|
|
3009
|
+
}
|
|
3010
|
+
})();
|
|
3011
|
+
/** Short, readable request path (pathname + search) for the timing log. */
|
|
3012
|
+
function reqPath(input) {
|
|
3013
|
+
try {
|
|
3014
|
+
const u = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
3015
|
+
if (!/^https?:\/\//i.test(u))
|
|
3016
|
+
return u;
|
|
3017
|
+
const parsed = new URL(u);
|
|
3018
|
+
return parsed.pathname + parsed.search;
|
|
3019
|
+
}
|
|
3020
|
+
catch {
|
|
3021
|
+
return "?";
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3002
3024
|
/** Thrown for any non-2xx accelerator response. Carries the HTTP status. */
|
|
3003
3025
|
class HttpError extends Error {
|
|
3004
3026
|
status;
|
|
@@ -3055,7 +3077,14 @@ class HttpClient {
|
|
|
3055
3077
|
if (token)
|
|
3056
3078
|
headers.set("X-Muhkoo-Session", token);
|
|
3057
3079
|
}
|
|
3058
|
-
|
|
3080
|
+
const finalInit = { ...init, headers };
|
|
3081
|
+
if (!HTTP_DEBUG)
|
|
3082
|
+
return this.fetchFn(input, finalInit);
|
|
3083
|
+
// Staging diagnostic: time every accelerator request (this `fetch` is the
|
|
3084
|
+
// single chokepoint — JSON helpers + the roster/shard fetches ride it).
|
|
3085
|
+
const t = performance.now();
|
|
3086
|
+
const tag = `${init?.method ?? "GET"} ${reqPath(input)}`;
|
|
3087
|
+
return this.fetchFn(input, finalInit).then((res) => { console.info(`[muhkoo:http] ${tag} → ${res.status} ${Math.round(performance.now() - t)}ms`); return res; }, (err) => { console.info(`[muhkoo:http] ${tag} → ERROR ${Math.round(performance.now() - t)}ms`); throw err; });
|
|
3059
3088
|
};
|
|
3060
3089
|
/** True when `input` resolves to {@link baseOrigin} (relative URLs always do). */
|
|
3061
3090
|
isSameOrigin(input) {
|
|
@@ -3869,6 +3898,34 @@ class ZkAuth {
|
|
|
3869
3898
|
return null;
|
|
3870
3899
|
}
|
|
3871
3900
|
}
|
|
3901
|
+
/**
|
|
3902
|
+
* Boot helper — call this once on app start. Restores the session AND, when a
|
|
3903
|
+
* passkey is enrolled, silently unlocks the identity via WebAuthn (recovering
|
|
3904
|
+
* the master seed so the ratchet keypair rehydrates and `client.kv` works).
|
|
3905
|
+
* The seed is never persisted, so a passkey (or password) is the only way to
|
|
3906
|
+
* unlock after a reload — this keeps that off the app's plate.
|
|
3907
|
+
*
|
|
3908
|
+
* Returns `{ user, unlocked }`. When `unlocked` is false (no passkey, the
|
|
3909
|
+
* user cancelled, or the browser required a gesture), prompt for a password
|
|
3910
|
+
* and call {@link unlock}. Apps need no key-management scaffolding beyond this.
|
|
3911
|
+
*/
|
|
3912
|
+
async resume() {
|
|
3913
|
+
const user = await this.restore();
|
|
3914
|
+
if (!user)
|
|
3915
|
+
return { user: null, unlocked: false };
|
|
3916
|
+
if (this.deps.session.isUnlocked)
|
|
3917
|
+
return { user, unlocked: true };
|
|
3918
|
+
try {
|
|
3919
|
+
const factors = await this.listFactors();
|
|
3920
|
+
if (factors.some((f) => f.type === "passkey")) {
|
|
3921
|
+
await this.loginWithPasskey(user.username);
|
|
3922
|
+
}
|
|
3923
|
+
}
|
|
3924
|
+
catch {
|
|
3925
|
+
/* no passkey / cancelled / no user gesture — leave locked for a prompt */
|
|
3926
|
+
}
|
|
3927
|
+
return { user, unlocked: this.deps.session.isUnlocked };
|
|
3928
|
+
}
|
|
3872
3929
|
/**
|
|
3873
3930
|
* Re-derive identity material for an already-authenticated (restored)
|
|
3874
3931
|
* session, so the client can decrypt storage and run the ratchet. Verifies
|
|
@@ -5445,6 +5502,8 @@ class ReedSolomonCodec {
|
|
|
5445
5502
|
* skip a PUT they'd otherwise dedup-rewrite. The
|
|
5446
5503
|
* server can ignore the optimization if it wants.
|
|
5447
5504
|
*/
|
|
5505
|
+
/** Peer head-start (ms) before origin joins the race in {@link ShardClient.getShard}. */
|
|
5506
|
+
const HEDGE_MS = 500;
|
|
5448
5507
|
/**
|
|
5449
5508
|
* Compute the lowercase hex SHA-256 of `bytes`. Available via WebCrypto in
|
|
5450
5509
|
* every runtime this SDK targets.
|
|
@@ -5463,6 +5522,7 @@ class ShardClient {
|
|
|
5463
5522
|
fetchFn;
|
|
5464
5523
|
cache;
|
|
5465
5524
|
deferUpload;
|
|
5525
|
+
peers;
|
|
5466
5526
|
constructor(opts) {
|
|
5467
5527
|
if (!opts?.baseUrl)
|
|
5468
5528
|
throw new Error("ShardClient: `baseUrl` is required");
|
|
@@ -5480,6 +5540,7 @@ class ShardClient {
|
|
|
5480
5540
|
this.fetchFn = f.bind(globalThis);
|
|
5481
5541
|
this.cache = opts.cache;
|
|
5482
5542
|
this.deferUpload = opts.deferUpload;
|
|
5543
|
+
this.peers = opts.peers;
|
|
5483
5544
|
}
|
|
5484
5545
|
/** Build the URL for a given shard hash — exposed for callers that want to log or pre-resolve. */
|
|
5485
5546
|
urlForHash(hash) {
|
|
@@ -5498,6 +5559,9 @@ class ShardClient {
|
|
|
5498
5559
|
// this is safe to keep regardless of upload outcome.
|
|
5499
5560
|
if (this.cache)
|
|
5500
5561
|
await this.cache.put(hash, bytes);
|
|
5562
|
+
// Tell the swarm we now hold this block, so peers can pull it from us.
|
|
5563
|
+
if (this.peers)
|
|
5564
|
+
this.peers.announce(hash);
|
|
5501
5565
|
let response;
|
|
5502
5566
|
try {
|
|
5503
5567
|
response = await this.fetchFn(url, {
|
|
@@ -5541,15 +5605,63 @@ class ShardClient {
|
|
|
5541
5605
|
if (cached)
|
|
5542
5606
|
return cached;
|
|
5543
5607
|
}
|
|
5608
|
+
// No peers → straight to origin.
|
|
5609
|
+
if (!this.peers)
|
|
5610
|
+
return this.fetchFromOrigin(hash);
|
|
5611
|
+
// HEDGED RACE: prefer a peer (saves origin egress) but never let a slow
|
|
5612
|
+
// or missing peer stall delivery — give the peer a short head start, then
|
|
5613
|
+
// start origin in parallel and take whichever returns the block first.
|
|
5614
|
+
const peers = this.peers;
|
|
5615
|
+
return new Promise((resolve, reject) => {
|
|
5616
|
+
let done = false;
|
|
5617
|
+
let originStarted = false;
|
|
5618
|
+
let timer = null;
|
|
5619
|
+
const win = (b) => {
|
|
5620
|
+
if (done)
|
|
5621
|
+
return;
|
|
5622
|
+
done = true;
|
|
5623
|
+
if (timer)
|
|
5624
|
+
clearTimeout(timer);
|
|
5625
|
+
resolve(b);
|
|
5626
|
+
};
|
|
5627
|
+
const fail = (e) => {
|
|
5628
|
+
if (!done) {
|
|
5629
|
+
done = true;
|
|
5630
|
+
reject(e);
|
|
5631
|
+
}
|
|
5632
|
+
};
|
|
5633
|
+
const startOrigin = () => {
|
|
5634
|
+
if (originStarted || done)
|
|
5635
|
+
return;
|
|
5636
|
+
originStarted = true;
|
|
5637
|
+
if (timer) {
|
|
5638
|
+
clearTimeout(timer);
|
|
5639
|
+
timer = null;
|
|
5640
|
+
}
|
|
5641
|
+
this.fetchFromOrigin(hash).then(win, fail);
|
|
5642
|
+
};
|
|
5643
|
+
timer = setTimeout(startOrigin, HEDGE_MS);
|
|
5644
|
+
peers.getBlock(hash).then((b) => {
|
|
5645
|
+
if (b) {
|
|
5646
|
+
if (this.cache)
|
|
5647
|
+
void this.cache.put(hash, b);
|
|
5648
|
+
win(b); // peer delivered first
|
|
5649
|
+
}
|
|
5650
|
+
else {
|
|
5651
|
+
startOrigin(); // peer miss → don't wait out the hedge
|
|
5652
|
+
}
|
|
5653
|
+
}, () => startOrigin());
|
|
5654
|
+
});
|
|
5655
|
+
}
|
|
5656
|
+
/** Fetch a shard from origin. Returns `null` for 404 (or offline when a
|
|
5657
|
+
* cache is present, so RS recovery can proceed); throws otherwise. */
|
|
5658
|
+
async fetchFromOrigin(hash) {
|
|
5544
5659
|
const url = this.urlForHash(hash);
|
|
5545
5660
|
let response;
|
|
5546
5661
|
try {
|
|
5547
5662
|
response = await this.fetchFn(url, { method: "GET" });
|
|
5548
5663
|
}
|
|
5549
5664
|
catch (err) {
|
|
5550
|
-
// Offline with no cache hit: report the shard as missing so
|
|
5551
|
-
// FileStorage's Reed–Solomon recovery can still reconstruct from the
|
|
5552
|
-
// shards that ARE cached. Rethrow when there's no cache to fall back on.
|
|
5553
5665
|
if (this.cache)
|
|
5554
5666
|
return null;
|
|
5555
5667
|
throw err;
|
|
@@ -5560,8 +5672,7 @@ class ShardClient {
|
|
|
5560
5672
|
const text = await safeText(response);
|
|
5561
5673
|
throw new Error(`ShardClient.getShard(${hash}) failed (${response.status}): ${text}`);
|
|
5562
5674
|
}
|
|
5563
|
-
const
|
|
5564
|
-
const bytes = new Uint8Array(buf);
|
|
5675
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
5565
5676
|
if (this.cache)
|
|
5566
5677
|
void this.cache.put(hash, bytes);
|
|
5567
5678
|
return bytes;
|
|
@@ -6768,6 +6879,8 @@ class Space {
|
|
|
6768
6879
|
events = new EventTarget();
|
|
6769
6880
|
rawWired = false;
|
|
6770
6881
|
catchUpUnsub = null;
|
|
6882
|
+
/** Optional P2P swarm for this Space (block exchange among members). */
|
|
6883
|
+
peerNet = null;
|
|
6771
6884
|
constructor(deps) {
|
|
6772
6885
|
this.deps = deps;
|
|
6773
6886
|
this.name = deps.name;
|
|
@@ -6816,6 +6929,9 @@ class Space {
|
|
|
6816
6929
|
if (this.deps.keyring) {
|
|
6817
6930
|
this.channel.on(BroadcastChannelEvents.CONNECTED, () => {
|
|
6818
6931
|
this.channel?.sendRaw({ name: this.deps.myId() });
|
|
6932
|
+
// Kick P2P peer discovery now the signaling channel is live
|
|
6933
|
+
// (re-runs on each reconnect to re-form the mesh).
|
|
6934
|
+
this.peerNet?.start();
|
|
6819
6935
|
});
|
|
6820
6936
|
this.wireFanout();
|
|
6821
6937
|
// Register offline catch-up once: on each reconnect the sync
|
|
@@ -6831,6 +6947,27 @@ class Space {
|
|
|
6831
6947
|
this.channel?.disconnect();
|
|
6832
6948
|
this.catchUpUnsub?.();
|
|
6833
6949
|
this.catchUpUnsub = null;
|
|
6950
|
+
this.peerNet?.close();
|
|
6951
|
+
this.peerNet = null;
|
|
6952
|
+
}
|
|
6953
|
+
/** Attach a P2P swarm so this Space's file reads/writes can use peers. */
|
|
6954
|
+
attachPeerNetwork(net) {
|
|
6955
|
+
this.peerNet = net;
|
|
6956
|
+
}
|
|
6957
|
+
/**
|
|
6958
|
+
* Broadcast an app payload directly to connected Space peers over P2P,
|
|
6959
|
+
* **bypassing the server** — for CRDT ops / high-frequency state (live
|
|
6960
|
+
* cursors, presence, collaborative edits) you don't want to push through the
|
|
6961
|
+
* relay. No-op when no P2P mesh is active (requires `client` `p2p`).
|
|
6962
|
+
* Unlike {@link sendEphemeral} (server-relayed, metered, size-limited), this
|
|
6963
|
+
* is peer-direct and fragments large payloads.
|
|
6964
|
+
*/
|
|
6965
|
+
gossipToPeers(data) {
|
|
6966
|
+
this.peerNet?.gossip(data);
|
|
6967
|
+
}
|
|
6968
|
+
/** Subscribe to peer gossip ({@link gossipToPeers}). Returns an unsubscribe fn. */
|
|
6969
|
+
onPeerGossip(handler) {
|
|
6970
|
+
return this.peerNet?.onGossip(handler) ?? (() => { });
|
|
6834
6971
|
}
|
|
6835
6972
|
/** Subscribe to a raw channel event. Buffered until `connect()` if early. */
|
|
6836
6973
|
on(event, handler) {
|
|
@@ -6889,18 +7026,23 @@ class Space {
|
|
|
6889
7026
|
const hlc = await offline.nextHlc();
|
|
6890
7027
|
const packet = await this.sealMessage(payload, opts.channel, opts.contentType, clientId);
|
|
6891
7028
|
const frame = { spaceMessage: packet };
|
|
6892
|
-
|
|
7029
|
+
const entry = {
|
|
6893
7030
|
handle: provisionalSpaceKey(clientId),
|
|
6894
7031
|
packet,
|
|
6895
7032
|
op: "msg",
|
|
6896
7033
|
clientId,
|
|
6897
7034
|
hlc,
|
|
6898
7035
|
pending: true,
|
|
6899
|
-
}
|
|
6900
|
-
if (this.isConnected())
|
|
6901
|
-
this.sendRaw(frame);
|
|
6902
|
-
|
|
7036
|
+
};
|
|
7037
|
+
if (this.isConnected()) {
|
|
7038
|
+
this.sendRaw(frame); // send first — don't block the wire on IndexedDB
|
|
7039
|
+
void offline.putMessage(this.id, entry); // optimistic cache off the hot path
|
|
7040
|
+
}
|
|
7041
|
+
else {
|
|
7042
|
+
// Offline: persist the optimistic entry + durable replay queue.
|
|
7043
|
+
void offline.putMessage(this.id, entry);
|
|
6903
7044
|
await offline.enqueueFrame(this.id, frame, clientId, hlc);
|
|
7045
|
+
}
|
|
6904
7046
|
}
|
|
6905
7047
|
/** Subscribe to decrypted fan-out messages. Returns an unsubscribe fn. */
|
|
6906
7048
|
onMessage(handler) {
|
|
@@ -6964,18 +7106,14 @@ class Space {
|
|
|
6964
7106
|
const offline = this.deps.offline;
|
|
6965
7107
|
if (!offline?.enabled || !this.cipher)
|
|
6966
7108
|
return [];
|
|
6967
|
-
const entries = await offline.loadMessages(this.id);
|
|
7109
|
+
const entries = (await offline.loadMessages(this.id)).filter((e) => !e.deleted && e.packet);
|
|
7110
|
+
// Decode in parallel (already verified when cached → skipVerify).
|
|
7111
|
+
const decodedAll = await Promise.all(entries.map((e) => this.decodeFrame(e.packet, this.cipher, /*isRawFrame*/ true, e.pending ? 0 : Number(e.handle), /*skipVerify*/ true)));
|
|
6968
7112
|
const out = [];
|
|
6969
|
-
for (
|
|
6970
|
-
|
|
6971
|
-
continue;
|
|
6972
|
-
// Already verified when first cached → skip re-verification (which
|
|
6973
|
-
// would also fail offline without a warm member directory).
|
|
6974
|
-
const decoded = await this.decodeFrame(e.packet, this.cipher,
|
|
6975
|
-
/*isRawFrame*/ true, e.pending ? 0 : Number(e.handle),
|
|
6976
|
-
/*skipVerify*/ true);
|
|
7113
|
+
for (let i = 0; i < decodedAll.length; i++) {
|
|
7114
|
+
const decoded = decodedAll[i];
|
|
6977
7115
|
if (decoded) {
|
|
6978
|
-
decoded.pending =
|
|
7116
|
+
decoded.pending = entries[i].pending;
|
|
6979
7117
|
out.push(decoded);
|
|
6980
7118
|
}
|
|
6981
7119
|
}
|
|
@@ -7000,10 +7138,12 @@ class Space {
|
|
|
7000
7138
|
* indicators, presence, or live cursors are built on top of this. No-op if
|
|
7001
7139
|
* the space isn't connected (ephemeral signals aren't worth queuing).
|
|
7002
7140
|
*/
|
|
7003
|
-
sendEphemeral(subject, data) {
|
|
7141
|
+
sendEphemeral(subject, data, to) {
|
|
7004
7142
|
if (!this.isConnected())
|
|
7005
7143
|
return;
|
|
7006
|
-
|
|
7144
|
+
// `to` (a member id) lets the server unicast a *directed* ephemeral
|
|
7145
|
+
// instead of broadcasting; omitted ⇒ broadcast to the room (unchanged).
|
|
7146
|
+
this.sendRaw({ pub: to ? { subject, data, to } : { subject, data } });
|
|
7007
7147
|
}
|
|
7008
7148
|
/** Subscribe to inbound ephemeral signals. `from` is the authenticated sender. */
|
|
7009
7149
|
onEphemeral(handler) {
|
|
@@ -7030,25 +7170,45 @@ class Space {
|
|
|
7030
7170
|
params.set("limit", String(opts.limit));
|
|
7031
7171
|
const url = `${this.deps.httpBaseUrl}/api/spaces/${encodeURIComponent(this.name)}/history` +
|
|
7032
7172
|
(params.toString() ? `?${params}` : "");
|
|
7173
|
+
// Temporary staging diagnostic: split fetch / directory / decode time.
|
|
7174
|
+
const dbg = (() => { try {
|
|
7175
|
+
return globalThis.location?.hostname?.includes("staging");
|
|
7176
|
+
}
|
|
7177
|
+
catch {
|
|
7178
|
+
return false;
|
|
7179
|
+
} })();
|
|
7180
|
+
const mark = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
|
|
7181
|
+
const t0 = mark();
|
|
7033
7182
|
const res = await this.deps.fetch(url);
|
|
7034
7183
|
if (!res.ok)
|
|
7035
7184
|
throw new Error(`Space.history: ${res.status} ${res.statusText}`);
|
|
7036
7185
|
const body = (await res.json());
|
|
7186
|
+
const tFetch = mark();
|
|
7037
7187
|
// Warm the member directory once so historical senders' signatures verify.
|
|
7038
|
-
//
|
|
7039
|
-
await this.deps.keyring?.refreshDirectory();
|
|
7188
|
+
// Force past the cooldown — a history load is an explicit "get fresh".
|
|
7189
|
+
await this.deps.keyring?.refreshDirectory({ force: true });
|
|
7190
|
+
const tDir = mark();
|
|
7191
|
+
// Decode the whole page in PARALLEL — each frame is an AES-GCM decrypt +
|
|
7192
|
+
// ECDSA verify on the WebCrypto thread; doing them sequentially (await
|
|
7193
|
+
// per message) serializes ~100 main↔crypto round-trips and dominates
|
|
7194
|
+
// load time. Promise.all preserves input order.
|
|
7195
|
+
const raws = body.messages ?? [];
|
|
7196
|
+
const decodedAll = await Promise.all(raws.map((raw) => this.decodeFrame(raw, cipher)));
|
|
7197
|
+
if (dbg) {
|
|
7198
|
+
console.info(`[muhkoo:space] history n=${raws.length} | fetch ${Math.round(tFetch - t0)}ms | dir ${Math.round(tDir - tFetch)}ms | decode ${Math.round(mark() - tDir)}ms`);
|
|
7199
|
+
}
|
|
7040
7200
|
const messages = [];
|
|
7041
|
-
for (
|
|
7042
|
-
const decoded =
|
|
7201
|
+
for (let i = 0; i < decodedAll.length; i++) {
|
|
7202
|
+
const decoded = decodedAll[i];
|
|
7043
7203
|
if (!decoded)
|
|
7044
7204
|
continue;
|
|
7045
7205
|
messages.push(decoded);
|
|
7046
|
-
// Warm the offline cache
|
|
7206
|
+
// Warm the offline cache off the hot path (best-effort, non-blocking).
|
|
7047
7207
|
if (this.deps.offline?.enabled) {
|
|
7048
7208
|
try {
|
|
7049
|
-
const outer = JSON.parse(
|
|
7209
|
+
const outer = JSON.parse(raws[i]);
|
|
7050
7210
|
if (typeof outer.spaceMessage === "string") {
|
|
7051
|
-
|
|
7211
|
+
void this.cacheFrame(outer.spaceMessage, decoded, "msg");
|
|
7052
7212
|
}
|
|
7053
7213
|
}
|
|
7054
7214
|
catch {
|
|
@@ -7123,6 +7283,7 @@ class Space {
|
|
|
7123
7283
|
fetch: this.deps.fetch,
|
|
7124
7284
|
cache: this.deps.shardCache,
|
|
7125
7285
|
deferUpload: this.deps.deferShardUpload,
|
|
7286
|
+
peers: this.peerNet?.exchange,
|
|
7126
7287
|
});
|
|
7127
7288
|
return new FileStorage({ shards });
|
|
7128
7289
|
}
|
|
@@ -7144,8 +7305,8 @@ class Space {
|
|
|
7144
7305
|
const handle = Number(f.timestamp ?? 0);
|
|
7145
7306
|
const decoded = await this.decodeFrame(f.spaceMessage, this.cipher, /*raw*/ true, handle);
|
|
7146
7307
|
if (decoded) {
|
|
7147
|
-
this.emitEvent("message", decoded);
|
|
7148
|
-
|
|
7308
|
+
this.emitEvent("message", decoded); // UI first
|
|
7309
|
+
void this.cacheFrame(f.spaceMessage, decoded, "msg"); // cache off the hot path
|
|
7149
7310
|
}
|
|
7150
7311
|
return;
|
|
7151
7312
|
}
|
|
@@ -7157,7 +7318,7 @@ class Space {
|
|
|
7157
7318
|
const decoded = await this.decodeFrame(e.spaceMessage, this.cipher, /*raw*/ true, Number(e.ts ?? 0));
|
|
7158
7319
|
if (decoded) {
|
|
7159
7320
|
this.emitEvent("message-edited", decoded);
|
|
7160
|
-
|
|
7321
|
+
void this.cacheFrame(e.spaceMessage, decoded, "edit");
|
|
7161
7322
|
}
|
|
7162
7323
|
}
|
|
7163
7324
|
return;
|
|
@@ -7167,8 +7328,8 @@ class Space {
|
|
|
7167
7328
|
const handle = Number(d.ts ?? 0);
|
|
7168
7329
|
this.emitEvent("message-deleted", { handle });
|
|
7169
7330
|
if (this.deps.offline?.enabled) {
|
|
7170
|
-
|
|
7171
|
-
|
|
7331
|
+
// Cache the tombstone off the hot path.
|
|
7332
|
+
void this.deps.offline.putDeleted(this.id, handle).then(() => this.deps.offline?.observeHandle(this.id, handle));
|
|
7172
7333
|
}
|
|
7173
7334
|
return;
|
|
7174
7335
|
}
|
|
@@ -7189,7 +7350,7 @@ class Space {
|
|
|
7189
7350
|
this.emitEvent("join-request", req);
|
|
7190
7351
|
// A new member appeared — refresh the directory so their future
|
|
7191
7352
|
// messages verify, and (if we hold the key) auto-admit them.
|
|
7192
|
-
void this.deps.keyring?.refreshDirectory();
|
|
7353
|
+
void this.deps.keyring?.refreshDirectory({ force: true });
|
|
7193
7354
|
if (this.deps.autoAdmit !== false && this.deps.keyring?.hasAnyKey() && req.memberId !== this.deps.myId()) {
|
|
7194
7355
|
void this.deps.keyring.admit(req.memberId, req.identityEcdhPub).catch(() => { });
|
|
7195
7356
|
}
|
|
@@ -7339,7 +7500,7 @@ class Space {
|
|
|
7339
7500
|
const cursor = await offline.getCursor(this.id);
|
|
7340
7501
|
let since = String(cursor?.lastSeenHandle ?? 0);
|
|
7341
7502
|
// Warm the member directory once so historical senders' signatures verify.
|
|
7342
|
-
await this.deps.keyring?.refreshDirectory();
|
|
7503
|
+
await this.deps.keyring?.refreshDirectory({ force: true });
|
|
7343
7504
|
for (let page = 0; page < 50; page++) {
|
|
7344
7505
|
const url = `${this.deps.httpBaseUrl}/api/spaces/${encodeURIComponent(this.name)}/history` +
|
|
7345
7506
|
`?since=${encodeURIComponent(since)}&limit=100`;
|
|
@@ -7602,6 +7763,19 @@ function parseMaybeJson(text) {
|
|
|
7602
7763
|
* `KEEPER_MEMBER_ID`.
|
|
7603
7764
|
*/
|
|
7604
7765
|
const KEEPER_MEMBER_ID = "__keeper__";
|
|
7766
|
+
/** Staging-only keyring diagnostics (group-key cache hit/miss, persist, pulls). */
|
|
7767
|
+
const KEYRING_DEBUG = (() => {
|
|
7768
|
+
try {
|
|
7769
|
+
return typeof location !== "undefined" && /(^|\.)staging\./.test(location.hostname);
|
|
7770
|
+
}
|
|
7771
|
+
catch {
|
|
7772
|
+
return false;
|
|
7773
|
+
}
|
|
7774
|
+
})();
|
|
7775
|
+
function klog(msg) {
|
|
7776
|
+
if (KEYRING_DEBUG)
|
|
7777
|
+
console.info(`[muhkoo:keyring] ${msg}`);
|
|
7778
|
+
}
|
|
7605
7779
|
class SpaceKeyring {
|
|
7606
7780
|
deps;
|
|
7607
7781
|
keys = new Map();
|
|
@@ -7609,6 +7783,14 @@ class SpaceKeyring {
|
|
|
7609
7783
|
policy;
|
|
7610
7784
|
/** Cached member directory: memberId → imported ECDSA verify key. */
|
|
7611
7785
|
memberEcdsa = new Map();
|
|
7786
|
+
/** Coalesce concurrent roster refreshes (the parallel history/backlog decode
|
|
7787
|
+
* would otherwise fire one `roster` fetch PER message — a thundering herd). */
|
|
7788
|
+
dirInFlight = null;
|
|
7789
|
+
dirRefreshedAt = 0;
|
|
7790
|
+
/** Min gap between non-forced roster refreshes. A miss that persists within
|
|
7791
|
+
* this window won't re-fetch (the roster hasn't changed that fast); genuine
|
|
7792
|
+
* membership changes use `{ force: true }` (join requests / history loads). */
|
|
7793
|
+
static DIR_COOLDOWN_MS = 5000;
|
|
7612
7794
|
constructor(deps) {
|
|
7613
7795
|
this.deps = deps;
|
|
7614
7796
|
this.policy = deps.historyPolicy ?? "static";
|
|
@@ -7618,8 +7800,27 @@ class SpaceKeyring {
|
|
|
7618
7800
|
ecdsaKeyFor(memberId) {
|
|
7619
7801
|
return this.memberEcdsa.get(memberId);
|
|
7620
7802
|
}
|
|
7621
|
-
/**
|
|
7622
|
-
|
|
7803
|
+
/**
|
|
7804
|
+
* Refresh the member directory (memberId → ECDSA key) from the roster.
|
|
7805
|
+
* Coalesces concurrent callers into ONE fetch, and skips a fetch when one
|
|
7806
|
+
* completed within {@link DIR_COOLDOWN_MS} (unless `force`). Without this, a
|
|
7807
|
+
* page of N messages decoded in parallel — each missing the cold directory —
|
|
7808
|
+
* fires N simultaneous `roster` requests that queue behind the browser's
|
|
7809
|
+
* connection limit and take many seconds.
|
|
7810
|
+
*/
|
|
7811
|
+
async refreshDirectory(opts) {
|
|
7812
|
+
if (this.dirInFlight)
|
|
7813
|
+
return this.dirInFlight; // a refresh is already running
|
|
7814
|
+
if (!opts?.force && Date.now() - this.dirRefreshedAt < SpaceKeyring.DIR_COOLDOWN_MS) {
|
|
7815
|
+
return; // refreshed very recently — the roster won't have changed
|
|
7816
|
+
}
|
|
7817
|
+
this.dirInFlight = this.doRefreshDirectory().finally(() => {
|
|
7818
|
+
this.dirRefreshedAt = Date.now();
|
|
7819
|
+
this.dirInFlight = null;
|
|
7820
|
+
});
|
|
7821
|
+
return this.dirInFlight;
|
|
7822
|
+
}
|
|
7823
|
+
async doRefreshDirectory() {
|
|
7623
7824
|
let roster;
|
|
7624
7825
|
try {
|
|
7625
7826
|
roster = await this.deps.transport.fetchRoster();
|
|
@@ -7654,34 +7855,63 @@ class SpaceKeyring {
|
|
|
7654
7855
|
async loadFromCache() {
|
|
7655
7856
|
if (!this.deps.cache)
|
|
7656
7857
|
return false;
|
|
7858
|
+
// The cache entries are ECIES-wrapped to our identity key, so we unwrap
|
|
7859
|
+
// with the keyring's OWN private key — available whenever chat works,
|
|
7860
|
+
// unlike client.kv's StorageCipher which needs the full unlocked identity.
|
|
7861
|
+
const priv = this.deps.ownPrivateKey();
|
|
7862
|
+
if (!priv) {
|
|
7863
|
+
klog("loadFromCache: no private key available yet");
|
|
7864
|
+
return false;
|
|
7865
|
+
}
|
|
7657
7866
|
let cached;
|
|
7658
7867
|
try {
|
|
7659
7868
|
cached = await this.deps.cache.loadKeys(this.deps.spaceId);
|
|
7660
7869
|
}
|
|
7661
|
-
catch {
|
|
7870
|
+
catch (e) {
|
|
7871
|
+
klog(`loadFromCache: ERROR reading cache — ${e instanceof Error ? e.message : e}`);
|
|
7662
7872
|
return false;
|
|
7663
7873
|
}
|
|
7664
|
-
if (!cached)
|
|
7874
|
+
if (!cached) {
|
|
7875
|
+
klog("loadFromCache: cache empty");
|
|
7876
|
+
return false;
|
|
7877
|
+
}
|
|
7878
|
+
for (const [epochStr, serialized] of Object.entries(cached)) {
|
|
7879
|
+
try {
|
|
7880
|
+
const blob = JSON.parse(serialized);
|
|
7881
|
+
this.keys.set(Number(epochStr), await unwrapSpaceKey(blob, priv));
|
|
7882
|
+
}
|
|
7883
|
+
catch {
|
|
7884
|
+
/* stale / old-format entry — skip; we'll re-admit and re-persist */
|
|
7885
|
+
}
|
|
7886
|
+
}
|
|
7887
|
+
if (this.keys.size === 0) {
|
|
7888
|
+
klog("loadFromCache: no usable entries (stale format → will re-admit)");
|
|
7665
7889
|
return false;
|
|
7666
|
-
for (const [epochStr, b64] of Object.entries(cached)) {
|
|
7667
|
-
this.keys.set(Number(epochStr), fromBase64$1(b64));
|
|
7668
7890
|
}
|
|
7669
7891
|
this.epoch = Math.max(0, ...Array.from(this.keys.keys()));
|
|
7670
|
-
|
|
7892
|
+
klog(`loadFromCache: HIT ${this.keys.size} epoch(s), current=${this.epoch}`);
|
|
7893
|
+
return true;
|
|
7671
7894
|
}
|
|
7672
7895
|
async persist() {
|
|
7673
|
-
if (!this.deps.cache)
|
|
7896
|
+
if (!this.deps.cache || this.keys.size === 0)
|
|
7674
7897
|
return;
|
|
7675
|
-
|
|
7676
|
-
|
|
7677
|
-
out[String(epoch)] = toBase64$1(key);
|
|
7678
|
-
// Best-effort: caching is an optimization; a write failure (e.g. locked
|
|
7679
|
-
// storage) must not break key acquisition.
|
|
7898
|
+
// Best-effort: caching is an optimization; a write failure must not break
|
|
7899
|
+
// key acquisition.
|
|
7680
7900
|
try {
|
|
7901
|
+
// Wrap each epoch key to our OWN identity key (ECIES-to-self). The
|
|
7902
|
+
// cache stays server-blind (the kv layer stores it with encrypt:false
|
|
7903
|
+
// — it's already encrypted) AND readable with the keyring private key
|
|
7904
|
+
// alone, so it works while the full identity is locked (post-reload).
|
|
7905
|
+
const selfPub = await importEcdhPublicKey(this.deps.identityEcdhPub);
|
|
7906
|
+
const out = {};
|
|
7907
|
+
for (const [epoch, key] of this.keys) {
|
|
7908
|
+
out[String(epoch)] = JSON.stringify(await wrapSpaceKey(key, epoch, selfPub));
|
|
7909
|
+
}
|
|
7681
7910
|
await this.deps.cache.saveKeys(this.deps.spaceId, out);
|
|
7911
|
+
klog(`persist: saved ${Object.keys(out).length} epoch(s)`);
|
|
7682
7912
|
}
|
|
7683
|
-
catch {
|
|
7684
|
-
|
|
7913
|
+
catch (e) {
|
|
7914
|
+
klog(`persist: FAILED — ${e instanceof Error ? e.message : e}`);
|
|
7685
7915
|
}
|
|
7686
7916
|
}
|
|
7687
7917
|
/** Creator path: mint epoch-0 group key for a brand-new space. */
|
|
@@ -7730,8 +7960,24 @@ class SpaceKeyring {
|
|
|
7730
7960
|
this.epoch = Math.max(this.epoch, ...Array.from(this.keys.keys()));
|
|
7731
7961
|
await this.persist();
|
|
7732
7962
|
}
|
|
7963
|
+
klog(`pullKeys: +${added} epoch(s) (now hold ${this.keys.size}, current=${this.epoch})`);
|
|
7733
7964
|
return added;
|
|
7734
7965
|
}
|
|
7966
|
+
/**
|
|
7967
|
+
* Background freshness check for the cache-hit fast path: confirm we hold the
|
|
7968
|
+
* current epoch (picks up a rotation / our removal) WITHOUT the full newcomer
|
|
7969
|
+
* admit handshake. Cheap + idempotent; never blocks first paint. Returns the
|
|
7970
|
+
* number of newly-obtained epoch keys (0 = already current).
|
|
7971
|
+
*/
|
|
7972
|
+
async reconcile() {
|
|
7973
|
+
await this.refreshDirectory({ force: true });
|
|
7974
|
+
try {
|
|
7975
|
+
return await this.pullKeys();
|
|
7976
|
+
}
|
|
7977
|
+
catch {
|
|
7978
|
+
return 0; // best-effort — the live keyringReady frame is the backstop
|
|
7979
|
+
}
|
|
7980
|
+
}
|
|
7735
7981
|
// -- key-holder: admit + rotate -------------------------------------------
|
|
7736
7982
|
/**
|
|
7737
7983
|
* Wrap our group key(s) for a newcomer and post the blobs. In a `static`
|
|
@@ -8041,6 +8287,861 @@ class SpaceCache {
|
|
|
8041
8287
|
}
|
|
8042
8288
|
}
|
|
8043
8289
|
|
|
8290
|
+
/**
|
|
8291
|
+
* `ShardCache` — a Cache-API-backed store for file-shard bytes. Shards are
|
|
8292
|
+
* content-addressed (the key IS the SHA-256 of the ciphertext), so caching them
|
|
8293
|
+
* is uniquely safe: a cache hit is self-validating, immutable, and shareable
|
|
8294
|
+
* across files. The Cache API (rather than IndexedDB) is the right home for
|
|
8295
|
+
* large binary blobs — it streams, it's purpose-built for response bodies, and
|
|
8296
|
+
* it has its own eviction.
|
|
8297
|
+
*
|
|
8298
|
+
* Keys are SYNTHETIC absolute URLs (`https://shard.muhkoo.local/<hash>`) rather
|
|
8299
|
+
* than the real shard endpoint, so the cache is addressable by hash alone — the
|
|
8300
|
+
* sync engine can re-PUT a deferred shard knowing only its hash, without
|
|
8301
|
+
* reconstructing the accelerator URL. We never fetch these synthetic URLs; they
|
|
8302
|
+
* exist only as cache keys.
|
|
8303
|
+
*
|
|
8304
|
+
* Every method degrades to a no-op / null where the Cache API is unavailable
|
|
8305
|
+
* (Node, Workers, insecure contexts), so callers don't need to branch.
|
|
8306
|
+
*/
|
|
8307
|
+
const CACHE_NAME = "muhkoo-shards-v1";
|
|
8308
|
+
class ShardCache {
|
|
8309
|
+
static available() {
|
|
8310
|
+
try {
|
|
8311
|
+
return typeof caches !== "undefined";
|
|
8312
|
+
}
|
|
8313
|
+
catch {
|
|
8314
|
+
return false;
|
|
8315
|
+
}
|
|
8316
|
+
}
|
|
8317
|
+
key(hash) {
|
|
8318
|
+
return `https://shard.muhkoo.local/${encodeURIComponent(hash)}`;
|
|
8319
|
+
}
|
|
8320
|
+
open() {
|
|
8321
|
+
return caches.open(CACHE_NAME);
|
|
8322
|
+
}
|
|
8323
|
+
/** Cached shard bytes, or `null` on miss / when unavailable. */
|
|
8324
|
+
async get(hash) {
|
|
8325
|
+
if (!ShardCache.available())
|
|
8326
|
+
return null;
|
|
8327
|
+
try {
|
|
8328
|
+
const cache = await this.open();
|
|
8329
|
+
const res = await cache.match(this.key(hash));
|
|
8330
|
+
if (!res)
|
|
8331
|
+
return null;
|
|
8332
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
8333
|
+
}
|
|
8334
|
+
catch {
|
|
8335
|
+
return null;
|
|
8336
|
+
}
|
|
8337
|
+
}
|
|
8338
|
+
/** Store shard bytes under their hash. Best-effort. */
|
|
8339
|
+
async put(hash, bytes) {
|
|
8340
|
+
if (!ShardCache.available())
|
|
8341
|
+
return;
|
|
8342
|
+
try {
|
|
8343
|
+
const cache = await this.open();
|
|
8344
|
+
await cache.put(this.key(hash), new Response(bytes, {
|
|
8345
|
+
headers: { "content-type": "application/octet-stream" },
|
|
8346
|
+
}));
|
|
8347
|
+
}
|
|
8348
|
+
catch {
|
|
8349
|
+
/* quota / unavailable — degrade to online-only for this shard */
|
|
8350
|
+
}
|
|
8351
|
+
}
|
|
8352
|
+
/** Whether a shard is present in the cache. */
|
|
8353
|
+
async has(hash) {
|
|
8354
|
+
if (!ShardCache.available())
|
|
8355
|
+
return false;
|
|
8356
|
+
try {
|
|
8357
|
+
const cache = await this.open();
|
|
8358
|
+
return Boolean(await cache.match(this.key(hash)));
|
|
8359
|
+
}
|
|
8360
|
+
catch {
|
|
8361
|
+
return false;
|
|
8362
|
+
}
|
|
8363
|
+
}
|
|
8364
|
+
}
|
|
8365
|
+
|
|
8366
|
+
/**
|
|
8367
|
+
* `PeerExchange` — the thin façade the storage layer consults to try fetching a
|
|
8368
|
+
* block from peers before falling back to origin. It satisfies the structural
|
|
8369
|
+
* `PeerBlockSource` interface that {@link ../storage/transport/ShardClient}
|
|
8370
|
+
* declares (so storage never imports the p2p module — the dependency arrow stays
|
|
8371
|
+
* one-way, p2p → storage).
|
|
8372
|
+
*
|
|
8373
|
+
* It delegates to an {@link EngineHandle} — either an in-process {@link
|
|
8374
|
+
* ./worker/blockEngine.BlockEngine} or the Web Worker proxy — so the same façade
|
|
8375
|
+
* works whether the block engine runs on the main thread or off it.
|
|
8376
|
+
*/
|
|
8377
|
+
class PeerExchange {
|
|
8378
|
+
engine;
|
|
8379
|
+
timeoutMs;
|
|
8380
|
+
debug;
|
|
8381
|
+
constructor(engine, opts = {}) {
|
|
8382
|
+
this.engine = engine;
|
|
8383
|
+
this.timeoutMs = opts.timeoutMs ?? 2500;
|
|
8384
|
+
this.debug = opts.debug ?? false;
|
|
8385
|
+
}
|
|
8386
|
+
async getBlock(hash, opts) {
|
|
8387
|
+
const bytes = await this.engine.want(hash, opts?.timeoutMs ?? this.timeoutMs);
|
|
8388
|
+
if (this.debug) {
|
|
8389
|
+
console.info(`%c[muhkoo:p2p] ${bytes ? "✓ peer-served" : "✗ miss → origin"} shard ${hash.slice(0, 10)}…`, `color:${bytes ? "#58da7d" : "#888"}`);
|
|
8390
|
+
}
|
|
8391
|
+
return bytes;
|
|
8392
|
+
}
|
|
8393
|
+
announce(hash) {
|
|
8394
|
+
if (this.debug)
|
|
8395
|
+
console.info(`[muhkoo:p2p] announce shard ${hash.slice(0, 10)}…`);
|
|
8396
|
+
this.engine.announce(hash);
|
|
8397
|
+
}
|
|
8398
|
+
}
|
|
8399
|
+
|
|
8400
|
+
/**
|
|
8401
|
+
* `SpaceSignaler` — WebRTC signaling carried over the Space's existing ephemeral
|
|
8402
|
+
* relay. No new server channel: `Space.sendEphemeral`/`onEphemeral` already
|
|
8403
|
+
* blind-relay `pub` frames stamped with the authenticated sender, so SDP
|
|
8404
|
+
* offers/answers and ICE candidates ride a reserved subject (`__p2p__`) scoped
|
|
8405
|
+
* to Space membership. The sender (`from`) is server-stamped and can't be
|
|
8406
|
+
* spoofed; the payload is opaque to the server.
|
|
8407
|
+
*
|
|
8408
|
+
* Envelopes carry a `to` (a member id, or `"*"` for discovery) so the
|
|
8409
|
+
* accelerator can optionally unicast peer-pair signaling instead of broadcasting
|
|
8410
|
+
* (a small, backward-compatible `pub` enhancement); the client filters by `to`
|
|
8411
|
+
* regardless.
|
|
8412
|
+
*/
|
|
8413
|
+
const P2P_SUBJECT = "__p2p__";
|
|
8414
|
+
class SpaceSignaler {
|
|
8415
|
+
space;
|
|
8416
|
+
myId;
|
|
8417
|
+
constructor(space, myId) {
|
|
8418
|
+
this.space = space;
|
|
8419
|
+
this.myId = myId;
|
|
8420
|
+
}
|
|
8421
|
+
/** Send a signal to one peer (or `"*"`). */
|
|
8422
|
+
send(to, kind, data) {
|
|
8423
|
+
const env = { to, kind, data };
|
|
8424
|
+
// Ask the server to unicast for a directed target; broadcast for "*".
|
|
8425
|
+
// `to` also stays in the envelope so a broadcast-only server still works
|
|
8426
|
+
// (the client filters by it).
|
|
8427
|
+
this.space.sendEphemeral(P2P_SUBJECT, env, to !== "*" ? to : undefined);
|
|
8428
|
+
}
|
|
8429
|
+
/** Announce/withdraw P2P availability to all Space members. */
|
|
8430
|
+
broadcast(kind, data) {
|
|
8431
|
+
this.send("*", kind, data);
|
|
8432
|
+
}
|
|
8433
|
+
/**
|
|
8434
|
+
* Subscribe to inbound signals addressed to us (or broadcast). Skips our own
|
|
8435
|
+
* echoes. Returns an unsubscribe function.
|
|
8436
|
+
*/
|
|
8437
|
+
onSignal(cb) {
|
|
8438
|
+
return this.space.onEphemeral((e) => {
|
|
8439
|
+
if (e.subject !== P2P_SUBJECT || e.from === this.myId)
|
|
8440
|
+
return;
|
|
8441
|
+
const env = e.data;
|
|
8442
|
+
if (!env || typeof env.kind !== "string")
|
|
8443
|
+
return;
|
|
8444
|
+
if (env.to !== this.myId && env.to !== "*")
|
|
8445
|
+
return;
|
|
8446
|
+
cb(e.from, env.kind, env.data);
|
|
8447
|
+
});
|
|
8448
|
+
}
|
|
8449
|
+
}
|
|
8450
|
+
|
|
8451
|
+
/**
|
|
8452
|
+
* `WebRtcTransport` — a {@link ./PeerTransport.PeerTransport} over RTCDataChannels,
|
|
8453
|
+
* signaled by {@link ../signaling/SpaceSignaler}. Runs on the **main thread**
|
|
8454
|
+
* (`RTCPeerConnection` is Window-only — not available in Workers), so the block
|
|
8455
|
+
* engine lives in a Worker and this only shuttles bytes.
|
|
8456
|
+
*
|
|
8457
|
+
* Connection setup uses a deterministic initiator (the peer with the smaller id
|
|
8458
|
+
* dials) to avoid offer/answer glare. STUN handles NAT traversal; there is no
|
|
8459
|
+
* TURN — an unreachable peer simply never connects and the caller falls back to
|
|
8460
|
+
* origin (P2P is best-effort).
|
|
8461
|
+
*
|
|
8462
|
+
* Frames (up to a 4 MiB shard) exceed an RTCDataChannel's max message size, so
|
|
8463
|
+
* each frame is fragmented into `CHUNK`-sized pieces with a 12-byte header
|
|
8464
|
+
* `[msgId u32][index u32][total u32]` and reassembled per peer.
|
|
8465
|
+
*/
|
|
8466
|
+
const CHUNK = 16 * 1024;
|
|
8467
|
+
// [msgId u32][index u32][total u32][channel u32]
|
|
8468
|
+
const HEADER$1 = 16;
|
|
8469
|
+
/** Pause sends when a channel has this many bytes buffered (backpressure). */
|
|
8470
|
+
const MAX_BUFFERED = 4 * 1024 * 1024;
|
|
8471
|
+
const DEFAULT_ICE = [{ urls: "stun:stun.cloudflare.com:3478" }];
|
|
8472
|
+
class WebRtcTransport {
|
|
8473
|
+
signaler;
|
|
8474
|
+
myId;
|
|
8475
|
+
conns = new Map();
|
|
8476
|
+
msgCbs = new Set();
|
|
8477
|
+
peerCbs = new Set();
|
|
8478
|
+
offSignal;
|
|
8479
|
+
iceServers;
|
|
8480
|
+
maxPeers;
|
|
8481
|
+
nextMsgId = 1;
|
|
8482
|
+
constructor(signaler, myId, opts = {}) {
|
|
8483
|
+
this.signaler = signaler;
|
|
8484
|
+
this.myId = myId;
|
|
8485
|
+
this.iceServers = opts.iceServers ?? DEFAULT_ICE;
|
|
8486
|
+
this.maxPeers = opts.maxPeers ?? 8;
|
|
8487
|
+
this.offSignal = this.signaler.onSignal((from, kind, data) => this.onSignal(from, kind, data));
|
|
8488
|
+
}
|
|
8489
|
+
peers() {
|
|
8490
|
+
return [...this.conns].filter(([, c]) => c.dc?.readyState === "open").map(([id]) => id);
|
|
8491
|
+
}
|
|
8492
|
+
/** Dial a peer (no-op unless we're the deterministic initiator for the pair). */
|
|
8493
|
+
connectTo(peerId) {
|
|
8494
|
+
if (peerId === this.myId || this.conns.has(peerId))
|
|
8495
|
+
return;
|
|
8496
|
+
if (this.conns.size >= this.maxPeers)
|
|
8497
|
+
return; // mesh cap
|
|
8498
|
+
if (this.myId < peerId)
|
|
8499
|
+
void this.initiate(peerId); // smaller id dials
|
|
8500
|
+
}
|
|
8501
|
+
send(peer, data, channel = 0) {
|
|
8502
|
+
const dc = this.conns.get(peer)?.dc;
|
|
8503
|
+
if (dc?.readyState === "open")
|
|
8504
|
+
void this.sendFragmented(dc, data, channel);
|
|
8505
|
+
}
|
|
8506
|
+
broadcast(data, channel = 0) {
|
|
8507
|
+
for (const [, c] of this.conns) {
|
|
8508
|
+
if (c.dc?.readyState === "open")
|
|
8509
|
+
void this.sendFragmented(c.dc, data, channel);
|
|
8510
|
+
}
|
|
8511
|
+
}
|
|
8512
|
+
onMessage(cb) {
|
|
8513
|
+
this.msgCbs.add(cb);
|
|
8514
|
+
return () => this.msgCbs.delete(cb);
|
|
8515
|
+
}
|
|
8516
|
+
onPeerChange(cb) {
|
|
8517
|
+
this.peerCbs.add(cb);
|
|
8518
|
+
return () => this.peerCbs.delete(cb);
|
|
8519
|
+
}
|
|
8520
|
+
close() {
|
|
8521
|
+
this.offSignal();
|
|
8522
|
+
for (const [, c] of this.conns) {
|
|
8523
|
+
try {
|
|
8524
|
+
c.dc?.close();
|
|
8525
|
+
}
|
|
8526
|
+
catch { /* already closing */ }
|
|
8527
|
+
try {
|
|
8528
|
+
c.pc.close();
|
|
8529
|
+
}
|
|
8530
|
+
catch { /* already closing */ }
|
|
8531
|
+
}
|
|
8532
|
+
this.conns.clear();
|
|
8533
|
+
this.msgCbs.clear();
|
|
8534
|
+
this.peerCbs.clear();
|
|
8535
|
+
}
|
|
8536
|
+
// -- internals -----------------------------------------------------------
|
|
8537
|
+
newPc(peerId) {
|
|
8538
|
+
const pc = new RTCPeerConnection({ iceServers: this.iceServers });
|
|
8539
|
+
const conn = { pc, reasm: new Map() };
|
|
8540
|
+
this.conns.set(peerId, conn);
|
|
8541
|
+
pc.onicecandidate = (e) => {
|
|
8542
|
+
if (e.candidate)
|
|
8543
|
+
this.signaler.send(peerId, "ice", e.candidate.toJSON());
|
|
8544
|
+
};
|
|
8545
|
+
pc.onconnectionstatechange = () => {
|
|
8546
|
+
if (["failed", "closed", "disconnected"].includes(pc.connectionState))
|
|
8547
|
+
this.drop(peerId);
|
|
8548
|
+
};
|
|
8549
|
+
pc.ondatachannel = (e) => this.bindChannel(peerId, conn, e.channel);
|
|
8550
|
+
return conn;
|
|
8551
|
+
}
|
|
8552
|
+
async initiate(peerId) {
|
|
8553
|
+
const conn = this.newPc(peerId);
|
|
8554
|
+
const dc = conn.pc.createDataChannel("blocks");
|
|
8555
|
+
this.bindChannel(peerId, conn, dc);
|
|
8556
|
+
const offer = await conn.pc.createOffer();
|
|
8557
|
+
await conn.pc.setLocalDescription(offer);
|
|
8558
|
+
this.signaler.send(peerId, "offer", offer);
|
|
8559
|
+
}
|
|
8560
|
+
bindChannel(peerId, conn, dc) {
|
|
8561
|
+
dc.binaryType = "arraybuffer";
|
|
8562
|
+
conn.dc = dc;
|
|
8563
|
+
dc.onopen = () => this.firePeerChange();
|
|
8564
|
+
dc.onclose = () => this.drop(peerId);
|
|
8565
|
+
dc.onmessage = (e) => this.onChunk(peerId, conn, new Uint8Array(e.data));
|
|
8566
|
+
}
|
|
8567
|
+
async onSignal(from, kind, data) {
|
|
8568
|
+
if (kind === "offer") {
|
|
8569
|
+
if (!this.conns.has(from) && this.conns.size >= this.maxPeers)
|
|
8570
|
+
return; // mesh cap
|
|
8571
|
+
const conn = this.conns.get(from) ?? this.newPc(from);
|
|
8572
|
+
await conn.pc.setRemoteDescription(data);
|
|
8573
|
+
const answer = await conn.pc.createAnswer();
|
|
8574
|
+
await conn.pc.setLocalDescription(answer);
|
|
8575
|
+
this.signaler.send(from, "answer", answer);
|
|
8576
|
+
}
|
|
8577
|
+
else if (kind === "answer") {
|
|
8578
|
+
const conn = this.conns.get(from);
|
|
8579
|
+
if (conn)
|
|
8580
|
+
await conn.pc.setRemoteDescription(data);
|
|
8581
|
+
}
|
|
8582
|
+
else if (kind === "ice") {
|
|
8583
|
+
const conn = this.conns.get(from);
|
|
8584
|
+
if (conn && data) {
|
|
8585
|
+
try {
|
|
8586
|
+
await conn.pc.addIceCandidate(data);
|
|
8587
|
+
}
|
|
8588
|
+
catch { /* pre-SRD race */ }
|
|
8589
|
+
}
|
|
8590
|
+
}
|
|
8591
|
+
else if (kind === "bye") {
|
|
8592
|
+
this.drop(from);
|
|
8593
|
+
}
|
|
8594
|
+
}
|
|
8595
|
+
drop(peerId) {
|
|
8596
|
+
const conn = this.conns.get(peerId);
|
|
8597
|
+
if (!conn)
|
|
8598
|
+
return;
|
|
8599
|
+
this.conns.delete(peerId);
|
|
8600
|
+
try {
|
|
8601
|
+
conn.dc?.close();
|
|
8602
|
+
}
|
|
8603
|
+
catch { /* already closing */ }
|
|
8604
|
+
try {
|
|
8605
|
+
conn.pc.close();
|
|
8606
|
+
}
|
|
8607
|
+
catch { /* already closing */ }
|
|
8608
|
+
this.firePeerChange();
|
|
8609
|
+
}
|
|
8610
|
+
firePeerChange() {
|
|
8611
|
+
const peers = this.peers();
|
|
8612
|
+
for (const cb of this.peerCbs)
|
|
8613
|
+
cb(peers);
|
|
8614
|
+
}
|
|
8615
|
+
// -- fragmentation -------------------------------------------------------
|
|
8616
|
+
async sendFragmented(dc, data, channel) {
|
|
8617
|
+
const msgId = this.nextMsgId++ >>> 0;
|
|
8618
|
+
const total = Math.max(1, Math.ceil(data.length / CHUNK));
|
|
8619
|
+
for (let i = 0; i < total; i++) {
|
|
8620
|
+
const slice = data.subarray(i * CHUNK, (i + 1) * CHUNK);
|
|
8621
|
+
const frame = new Uint8Array(HEADER$1 + slice.length);
|
|
8622
|
+
const dv = new DataView(frame.buffer);
|
|
8623
|
+
dv.setUint32(0, msgId);
|
|
8624
|
+
dv.setUint32(4, i);
|
|
8625
|
+
dv.setUint32(8, total);
|
|
8626
|
+
dv.setUint32(12, channel);
|
|
8627
|
+
frame.set(slice, HEADER$1);
|
|
8628
|
+
if (dc.bufferedAmount > MAX_BUFFERED)
|
|
8629
|
+
await this.drain(dc);
|
|
8630
|
+
dc.send(frame);
|
|
8631
|
+
}
|
|
8632
|
+
}
|
|
8633
|
+
drain(dc) {
|
|
8634
|
+
return new Promise((resolve) => {
|
|
8635
|
+
dc.bufferedAmountLowThreshold = MAX_BUFFERED / 2;
|
|
8636
|
+
const onLow = () => { dc.removeEventListener("bufferedamountlow", onLow); resolve(); };
|
|
8637
|
+
dc.addEventListener("bufferedamountlow", onLow);
|
|
8638
|
+
});
|
|
8639
|
+
}
|
|
8640
|
+
onChunk(peerId, conn, frame) {
|
|
8641
|
+
if (frame.length < HEADER$1)
|
|
8642
|
+
return;
|
|
8643
|
+
const dv = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
|
|
8644
|
+
const msgId = dv.getUint32(0);
|
|
8645
|
+
const index = dv.getUint32(4);
|
|
8646
|
+
const total = dv.getUint32(8);
|
|
8647
|
+
const channel = dv.getUint32(12);
|
|
8648
|
+
const chunk = frame.slice(HEADER$1);
|
|
8649
|
+
let r = conn.reasm.get(msgId);
|
|
8650
|
+
if (!r) {
|
|
8651
|
+
r = { chunks: new Array(total), received: 0, total, size: 0, channel };
|
|
8652
|
+
conn.reasm.set(msgId, r);
|
|
8653
|
+
}
|
|
8654
|
+
if (r.chunks[index])
|
|
8655
|
+
return; // dup
|
|
8656
|
+
r.chunks[index] = chunk;
|
|
8657
|
+
r.received++;
|
|
8658
|
+
r.size += chunk.length;
|
|
8659
|
+
if (r.received < r.total)
|
|
8660
|
+
return;
|
|
8661
|
+
conn.reasm.delete(msgId);
|
|
8662
|
+
const out = new Uint8Array(r.size);
|
|
8663
|
+
let off = 0;
|
|
8664
|
+
for (const c of r.chunks) {
|
|
8665
|
+
if (!c)
|
|
8666
|
+
return; // gap — shouldn't happen on reliable ordered
|
|
8667
|
+
out.set(c, off);
|
|
8668
|
+
off += c.length;
|
|
8669
|
+
}
|
|
8670
|
+
for (const cb of this.msgCbs)
|
|
8671
|
+
cb(peerId, out, r.channel);
|
|
8672
|
+
}
|
|
8673
|
+
}
|
|
8674
|
+
|
|
8675
|
+
/**
|
|
8676
|
+
* `PeerTransport` — the pluggable wire under the block engine. It moves opaque
|
|
8677
|
+
* frames between Space peers; it knows nothing about blocks, hashes, or the
|
|
8678
|
+
* protocol. {@link ./WebRtcTransport} implements it over RTCDataChannels today;
|
|
8679
|
+
* a future libp2p/Helia transport can implement the same interface without the
|
|
8680
|
+
* block engine or `ShardClient` changing.
|
|
8681
|
+
*
|
|
8682
|
+
* Implementations are responsible for fragmenting/ reassembling frames larger
|
|
8683
|
+
* than the underlying channel's max message size (a 4 MiB shard won't fit in one
|
|
8684
|
+
* `RTCDataChannel.send`); the engine always deals in whole frames.
|
|
8685
|
+
*/
|
|
8686
|
+
/**
|
|
8687
|
+
* Logical channels multiplexed over the one data link. `0` is block exchange
|
|
8688
|
+
* (the engine), `1` is gossip (app/CRDT data). The tag rides the framing header
|
|
8689
|
+
* so payloads aren't copied to prepend it.
|
|
8690
|
+
*/
|
|
8691
|
+
const CHANNEL_BLOCK = 0;
|
|
8692
|
+
const CHANNEL_GOSSIP = 1;
|
|
8693
|
+
|
|
8694
|
+
/**
|
|
8695
|
+
* Bitswap-lite wire protocol for peer block exchange. Four frame types over a
|
|
8696
|
+
* {@link ../transport/PeerTransport} data channel:
|
|
8697
|
+
*
|
|
8698
|
+
* WANT(hash) — "does anyone have this block?" / "send it to me"
|
|
8699
|
+
* HAVE(hash) — "I hold this block" (announce / reply to a WANT)
|
|
8700
|
+
* BLOCK(hash, bytes) — the block itself (verified by hash on receipt)
|
|
8701
|
+
* CANCEL(hash) — "stop, I got it elsewhere"
|
|
8702
|
+
*
|
|
8703
|
+
* Frames are compact binary so a 4 MiB shard isn't base64-bloated through JSON:
|
|
8704
|
+
*
|
|
8705
|
+
* byte 0 : type (0 WANT | 1 HAVE | 2 BLOCK | 3 CANCEL)
|
|
8706
|
+
* bytes 1..33 : 32-byte raw SHA-256 of the block (our shard hash, hex-decoded)
|
|
8707
|
+
* bytes 33.. : payload (BLOCK only)
|
|
8708
|
+
*
|
|
8709
|
+
* Blocks are AES-GCM ciphertext, so a frame carries nothing readable; the hash
|
|
8710
|
+
* is the capability check (the receiver re-hashes the payload and drops a
|
|
8711
|
+
* mismatch). See {@link ./blockEngine}.
|
|
8712
|
+
*/
|
|
8713
|
+
var FrameType;
|
|
8714
|
+
(function (FrameType) {
|
|
8715
|
+
FrameType[FrameType["WANT"] = 0] = "WANT";
|
|
8716
|
+
FrameType[FrameType["HAVE"] = 1] = "HAVE";
|
|
8717
|
+
FrameType[FrameType["BLOCK"] = 2] = "BLOCK";
|
|
8718
|
+
FrameType[FrameType["CANCEL"] = 3] = "CANCEL";
|
|
8719
|
+
})(FrameType || (FrameType = {}));
|
|
8720
|
+
const HASH_BYTES = 32;
|
|
8721
|
+
const HEADER = 1 + HASH_BYTES; // type + hash
|
|
8722
|
+
function hexToBytes(hex) {
|
|
8723
|
+
if (hex.length !== HASH_BYTES * 2) {
|
|
8724
|
+
throw new Error(`protocol: expected a ${HASH_BYTES}-byte hex hash, got ${hex.length / 2} bytes`);
|
|
8725
|
+
}
|
|
8726
|
+
const out = new Uint8Array(HASH_BYTES);
|
|
8727
|
+
for (let i = 0; i < HASH_BYTES; i++)
|
|
8728
|
+
out[i] = parseInt(hex.substr(i * 2, 2), 16);
|
|
8729
|
+
return out;
|
|
8730
|
+
}
|
|
8731
|
+
const HEX = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
|
|
8732
|
+
function bytesToHex(bytes) {
|
|
8733
|
+
let s = "";
|
|
8734
|
+
for (let i = 0; i < bytes.length; i++)
|
|
8735
|
+
s += HEX[bytes[i]];
|
|
8736
|
+
return s;
|
|
8737
|
+
}
|
|
8738
|
+
function header(type, hash, extra = 0) {
|
|
8739
|
+
const buf = new Uint8Array(HEADER + extra);
|
|
8740
|
+
buf[0] = type;
|
|
8741
|
+
buf.set(hexToBytes(hash), 1);
|
|
8742
|
+
return { buf, offset: HEADER };
|
|
8743
|
+
}
|
|
8744
|
+
function encodeWant(hash) {
|
|
8745
|
+
return header(FrameType.WANT, hash).buf;
|
|
8746
|
+
}
|
|
8747
|
+
function encodeHave(hash) {
|
|
8748
|
+
return header(FrameType.HAVE, hash).buf;
|
|
8749
|
+
}
|
|
8750
|
+
function encodeCancel(hash) {
|
|
8751
|
+
return header(FrameType.CANCEL, hash).buf;
|
|
8752
|
+
}
|
|
8753
|
+
function encodeBlock(hash, payload) {
|
|
8754
|
+
const { buf, offset } = header(FrameType.BLOCK, hash, payload.length);
|
|
8755
|
+
buf.set(payload, offset);
|
|
8756
|
+
return buf;
|
|
8757
|
+
}
|
|
8758
|
+
function decode(frame) {
|
|
8759
|
+
if (frame.length < HEADER)
|
|
8760
|
+
throw new Error("protocol: frame too short");
|
|
8761
|
+
const type = frame[0];
|
|
8762
|
+
const hash = bytesToHex(frame.subarray(1, HEADER));
|
|
8763
|
+
if (type === FrameType.BLOCK) {
|
|
8764
|
+
// Copy the payload out so it doesn't alias a transferred/pooled buffer.
|
|
8765
|
+
return { type, hash, payload: frame.slice(HEADER) };
|
|
8766
|
+
}
|
|
8767
|
+
return { type, hash };
|
|
8768
|
+
}
|
|
8769
|
+
|
|
8770
|
+
var protocol = /*#__PURE__*/Object.freeze({
|
|
8771
|
+
__proto__: null,
|
|
8772
|
+
get FrameType () { return FrameType; },
|
|
8773
|
+
decode: decode,
|
|
8774
|
+
encodeBlock: encodeBlock,
|
|
8775
|
+
encodeCancel: encodeCancel,
|
|
8776
|
+
encodeHave: encodeHave,
|
|
8777
|
+
encodeWant: encodeWant
|
|
8778
|
+
});
|
|
8779
|
+
|
|
8780
|
+
/**
|
|
8781
|
+
* `BlockEngine` — the bitswap-lite want/have ledger. Host-agnostic: it speaks
|
|
8782
|
+
* only to a {@link BlockStore} (get/put/has) and an `out` sink that ships
|
|
8783
|
+
* encoded frames to a peer (or `"*"` to broadcast). The host wires `out` and
|
|
8784
|
+
* `handleFrame` to a real transport — on the main thread or inside the Web
|
|
8785
|
+
* Worker (see {@link ./engineClient}) — so this class is pure and unit-testable.
|
|
8786
|
+
*
|
|
8787
|
+
* It never sees keys: blocks are AES-GCM ciphertext, addressed by SHA-256. The
|
|
8788
|
+
* one trust check is integrity — an inbound BLOCK is re-hashed and dropped on
|
|
8789
|
+
* mismatch, so a malicious peer can't poison the store (the requester just
|
|
8790
|
+
* falls back to origin).
|
|
8791
|
+
*/
|
|
8792
|
+
class BlockEngine {
|
|
8793
|
+
store;
|
|
8794
|
+
out;
|
|
8795
|
+
hash;
|
|
8796
|
+
wants = new Map();
|
|
8797
|
+
constructor(store, out, hash) {
|
|
8798
|
+
this.store = store;
|
|
8799
|
+
this.out = out;
|
|
8800
|
+
this.hash = hash;
|
|
8801
|
+
}
|
|
8802
|
+
/**
|
|
8803
|
+
* Ask the swarm for a block. Resolves with the verified bytes, or `null` on
|
|
8804
|
+
* timeout (caller then falls back to origin). Returns immediately if we
|
|
8805
|
+
* already hold it; concurrent wants for the same hash share one request.
|
|
8806
|
+
*/
|
|
8807
|
+
want(hash, timeoutMs) {
|
|
8808
|
+
return new Promise((resolve) => {
|
|
8809
|
+
void this.store.get(hash).then((local) => {
|
|
8810
|
+
if (local)
|
|
8811
|
+
return resolve(local);
|
|
8812
|
+
const existing = this.wants.get(hash);
|
|
8813
|
+
if (existing) {
|
|
8814
|
+
existing.resolvers.push(resolve);
|
|
8815
|
+
return;
|
|
8816
|
+
}
|
|
8817
|
+
const timer = setTimeout(() => {
|
|
8818
|
+
const w = this.wants.get(hash);
|
|
8819
|
+
this.wants.delete(hash);
|
|
8820
|
+
this.out("*", encodeCancel(hash));
|
|
8821
|
+
w?.resolvers.forEach((r) => r(null));
|
|
8822
|
+
}, timeoutMs);
|
|
8823
|
+
this.wants.set(hash, { resolvers: [resolve], timer });
|
|
8824
|
+
this.out("*", encodeWant(hash));
|
|
8825
|
+
});
|
|
8826
|
+
});
|
|
8827
|
+
}
|
|
8828
|
+
/** Tell the swarm we now hold `hash` (so peers wanting it can pull from us). */
|
|
8829
|
+
announce(hash) {
|
|
8830
|
+
this.out("*", encodeHave(hash));
|
|
8831
|
+
}
|
|
8832
|
+
/** Handle one inbound frame from `peer`. */
|
|
8833
|
+
async handleFrame(peer, frame) {
|
|
8834
|
+
let f;
|
|
8835
|
+
try {
|
|
8836
|
+
f = decode(frame);
|
|
8837
|
+
}
|
|
8838
|
+
catch {
|
|
8839
|
+
return; // malformed — ignore
|
|
8840
|
+
}
|
|
8841
|
+
switch (f.type) {
|
|
8842
|
+
case FrameType.WANT: {
|
|
8843
|
+
const bytes = await this.store.get(f.hash);
|
|
8844
|
+
if (bytes)
|
|
8845
|
+
this.out(peer, encodeBlock(f.hash, bytes));
|
|
8846
|
+
return;
|
|
8847
|
+
}
|
|
8848
|
+
case FrameType.HAVE: {
|
|
8849
|
+
// A peer announced a block we're still waiting for → pull from them.
|
|
8850
|
+
if (this.wants.has(f.hash))
|
|
8851
|
+
this.out(peer, encodeWant(f.hash));
|
|
8852
|
+
return;
|
|
8853
|
+
}
|
|
8854
|
+
case FrameType.BLOCK: {
|
|
8855
|
+
const pending = this.wants.get(f.hash);
|
|
8856
|
+
if (!pending || !f.payload)
|
|
8857
|
+
return; // unsolicited / empty — ignore
|
|
8858
|
+
// Integrity: a peer can't poison the store. Drop a mismatch and
|
|
8859
|
+
// keep waiting (the timeout will fall back to origin).
|
|
8860
|
+
const got = await this.hash(f.payload);
|
|
8861
|
+
if (got !== f.hash)
|
|
8862
|
+
return;
|
|
8863
|
+
await this.store.put(f.hash, f.payload);
|
|
8864
|
+
this.wants.delete(f.hash);
|
|
8865
|
+
clearTimeout(pending.timer);
|
|
8866
|
+
this.out("*", encodeCancel(f.hash)); // others can stop sending
|
|
8867
|
+
pending.resolvers.forEach((r) => r(f.payload));
|
|
8868
|
+
return;
|
|
8869
|
+
}
|
|
8870
|
+
case FrameType.CANCEL:
|
|
8871
|
+
// We serve WANTs synchronously, so there's nothing in flight to
|
|
8872
|
+
// cancel yet. Reserved for chunked-send cancellation later.
|
|
8873
|
+
return;
|
|
8874
|
+
}
|
|
8875
|
+
}
|
|
8876
|
+
/** Drop all pending waits (resolve null) — on teardown. */
|
|
8877
|
+
close() {
|
|
8878
|
+
for (const [, w] of this.wants) {
|
|
8879
|
+
clearTimeout(w.timer);
|
|
8880
|
+
w.resolvers.forEach((r) => r(null));
|
|
8881
|
+
}
|
|
8882
|
+
this.wants.clear();
|
|
8883
|
+
}
|
|
8884
|
+
}
|
|
8885
|
+
|
|
8886
|
+
/**
|
|
8887
|
+
* `EngineHost` — the boundary {@link ../PeerNetwork} wires a transport to. It
|
|
8888
|
+
* abstracts *where* the {@link ./blockEngine.BlockEngine} runs:
|
|
8889
|
+
* - {@link LocalEngineHost} runs it on the calling thread (tests, or a
|
|
8890
|
+
* fallback when the Web Worker can't be built by a consumer's bundler);
|
|
8891
|
+
* - the Worker proxy ({@link ./engineClient}) runs it off-thread.
|
|
8892
|
+
*
|
|
8893
|
+
* Either way the host exposes the same surface: drive `want`/`announce`, feed it
|
|
8894
|
+
* inbound frames via `handleFrame`, and receive its outbound frames via
|
|
8895
|
+
* `onOutbound` (which the network forwards to the transport).
|
|
8896
|
+
*/
|
|
8897
|
+
/** Runs the engine in-process (main thread or test). */
|
|
8898
|
+
class LocalEngineHost {
|
|
8899
|
+
engine;
|
|
8900
|
+
cbs = new Set();
|
|
8901
|
+
constructor(store, hasher) {
|
|
8902
|
+
this.engine = new BlockEngine(store, (target, frame) => {
|
|
8903
|
+
for (const cb of this.cbs)
|
|
8904
|
+
cb(target, frame);
|
|
8905
|
+
}, hasher);
|
|
8906
|
+
}
|
|
8907
|
+
want(hash, timeoutMs) {
|
|
8908
|
+
return this.engine.want(hash, timeoutMs);
|
|
8909
|
+
}
|
|
8910
|
+
announce(hash) {
|
|
8911
|
+
this.engine.announce(hash);
|
|
8912
|
+
}
|
|
8913
|
+
handleFrame(peer, frame) {
|
|
8914
|
+
void this.engine.handleFrame(peer, frame);
|
|
8915
|
+
}
|
|
8916
|
+
onOutbound(cb) {
|
|
8917
|
+
this.cbs.add(cb);
|
|
8918
|
+
return () => this.cbs.delete(cb);
|
|
8919
|
+
}
|
|
8920
|
+
close() {
|
|
8921
|
+
this.engine.close();
|
|
8922
|
+
this.cbs.clear();
|
|
8923
|
+
}
|
|
8924
|
+
}
|
|
8925
|
+
|
|
8926
|
+
/**
|
|
8927
|
+
* `WorkerEngineHost` — the main-thread proxy to a {@link ./blockEngine.BlockEngine}
|
|
8928
|
+
* running inside a dedicated Web Worker (so hashing, blockstore I/O, and the
|
|
8929
|
+
* protocol codec stay off the main thread). It speaks the small message protocol
|
|
8930
|
+
* in {@link ./blockEngine.worker} and satisfies {@link ./engineHost.EngineHost},
|
|
8931
|
+
* so {@link ../PeerNetwork} treats it identically to the in-process host.
|
|
8932
|
+
*
|
|
8933
|
+
* Block bytes cross the worker boundary as **transferable** ArrayBuffers
|
|
8934
|
+
* (zero-copy). Only encrypted blocks ever cross — keys never enter the worker.
|
|
8935
|
+
*/
|
|
8936
|
+
class WorkerEngineHost {
|
|
8937
|
+
worker;
|
|
8938
|
+
wants = new Map();
|
|
8939
|
+
cbs = new Set();
|
|
8940
|
+
nextId = 1;
|
|
8941
|
+
constructor(worker) {
|
|
8942
|
+
this.worker = worker;
|
|
8943
|
+
this.worker.onmessage = (e) => {
|
|
8944
|
+
const m = e.data;
|
|
8945
|
+
if (m.t === "out") {
|
|
8946
|
+
const frame = new Uint8Array(m.data);
|
|
8947
|
+
for (const cb of this.cbs)
|
|
8948
|
+
cb(m.target, frame);
|
|
8949
|
+
}
|
|
8950
|
+
else if (m.t === "want-result") {
|
|
8951
|
+
const resolve = this.wants.get(m.id);
|
|
8952
|
+
if (resolve) {
|
|
8953
|
+
this.wants.delete(m.id);
|
|
8954
|
+
resolve(m.data ? new Uint8Array(m.data) : null);
|
|
8955
|
+
}
|
|
8956
|
+
}
|
|
8957
|
+
};
|
|
8958
|
+
}
|
|
8959
|
+
want(hash, timeoutMs) {
|
|
8960
|
+
return new Promise((resolve) => {
|
|
8961
|
+
const id = this.nextId++;
|
|
8962
|
+
this.wants.set(id, resolve);
|
|
8963
|
+
this.worker.postMessage({ t: "want", id, hash, timeout: timeoutMs });
|
|
8964
|
+
});
|
|
8965
|
+
}
|
|
8966
|
+
announce(hash) {
|
|
8967
|
+
this.worker.postMessage({ t: "announce", hash });
|
|
8968
|
+
}
|
|
8969
|
+
handleFrame(peer, frame) {
|
|
8970
|
+
// Transfer an exact-size buffer; copy if the view doesn't own the whole buffer.
|
|
8971
|
+
const buf = frame.byteOffset === 0 && frame.byteLength === frame.buffer.byteLength
|
|
8972
|
+
? frame.buffer
|
|
8973
|
+
: frame.slice().buffer;
|
|
8974
|
+
this.worker.postMessage({ t: "frame", peer, data: buf }, [buf]);
|
|
8975
|
+
}
|
|
8976
|
+
onOutbound(cb) {
|
|
8977
|
+
this.cbs.add(cb);
|
|
8978
|
+
return () => this.cbs.delete(cb);
|
|
8979
|
+
}
|
|
8980
|
+
close() {
|
|
8981
|
+
try {
|
|
8982
|
+
this.worker.postMessage({ t: "close" });
|
|
8983
|
+
}
|
|
8984
|
+
catch { /* already gone */ }
|
|
8985
|
+
this.worker.terminate();
|
|
8986
|
+
this.wants.forEach((r) => r(null));
|
|
8987
|
+
this.wants.clear();
|
|
8988
|
+
this.cbs.clear();
|
|
8989
|
+
}
|
|
8990
|
+
}
|
|
8991
|
+
|
|
8992
|
+
/**
|
|
8993
|
+
* `PeerNetwork` — the per-Space orchestrator that ties the pieces together on
|
|
8994
|
+
* the main thread:
|
|
8995
|
+
*
|
|
8996
|
+
* transport (WebRTC) ⇄ engine host (Worker or in-process) → PeerExchange
|
|
8997
|
+
* ▲ │
|
|
8998
|
+
* └────────────── signaling over the Space relay ─────────────┘
|
|
8999
|
+
*
|
|
9000
|
+
* It owns one {@link ./transport/WebRtcTransport} + one {@link
|
|
9001
|
+
* ./worker/engineHost.EngineHost}, wires inbound frames to the engine and the
|
|
9002
|
+
* engine's outbound frames back to the transport, runs peer discovery over the
|
|
9003
|
+
* Space's ephemeral channel, and exposes a {@link ./PeerExchange.PeerExchange}
|
|
9004
|
+
* for `ShardClient` to consult. Scope is one Space (private swarm).
|
|
9005
|
+
*
|
|
9006
|
+
* `engineHost` defaults to in-process; pass `workerFactory` to run the block
|
|
9007
|
+
* engine off the main thread (the consumer's bundler resolves the worker URL).
|
|
9008
|
+
*/
|
|
9009
|
+
/**
|
|
9010
|
+
* Build the engine host, preferring an off-thread Web Worker:
|
|
9011
|
+
* 1. an explicit `workerFactory` (most reliable in app bundlers), else
|
|
9012
|
+
* 2. the bundled worker resolved relative to this module (works where the
|
|
9013
|
+
* bundler supports `new Worker(new URL(...))`, e.g. Vite builds), else
|
|
9014
|
+
* 3. the in-process engine (always works; tests + non-worker runtimes).
|
|
9015
|
+
* Any worker construction failure degrades to in-process.
|
|
9016
|
+
*/
|
|
9017
|
+
function buildEngineHost(opts) {
|
|
9018
|
+
try {
|
|
9019
|
+
const worker = opts.workerFactory ? opts.workerFactory() : defaultWorker();
|
|
9020
|
+
if (worker)
|
|
9021
|
+
return new WorkerEngineHost(worker);
|
|
9022
|
+
}
|
|
9023
|
+
catch {
|
|
9024
|
+
/* no Worker global / bundler can't resolve it → in-process */
|
|
9025
|
+
}
|
|
9026
|
+
return new LocalEngineHost(opts.store ?? new ShardCache(), shardHash);
|
|
9027
|
+
}
|
|
9028
|
+
/** Construct the bundled worker if the runtime + bundler support it, else null. */
|
|
9029
|
+
function defaultWorker() {
|
|
9030
|
+
try {
|
|
9031
|
+
if (typeof Worker === "undefined")
|
|
9032
|
+
return null;
|
|
9033
|
+
// The string is resolved by the consumer's bundler relative to the
|
|
9034
|
+
// built connect module (sibling of dist/browser/index.js).
|
|
9035
|
+
return new Worker(new URL("./blockEngine.worker.js", import.meta.url), { type: "module" });
|
|
9036
|
+
}
|
|
9037
|
+
catch {
|
|
9038
|
+
return null;
|
|
9039
|
+
}
|
|
9040
|
+
}
|
|
9041
|
+
class PeerNetwork {
|
|
9042
|
+
/** Pass this to a `ShardClient` as `peers`. */
|
|
9043
|
+
exchange;
|
|
9044
|
+
transport;
|
|
9045
|
+
host;
|
|
9046
|
+
signaler;
|
|
9047
|
+
offs = [];
|
|
9048
|
+
gossipCbs = new Set();
|
|
9049
|
+
closed = false;
|
|
9050
|
+
constructor(opts) {
|
|
9051
|
+
this.signaler = new SpaceSignaler(opts.space, opts.myId);
|
|
9052
|
+
this.transport =
|
|
9053
|
+
opts.transport ??
|
|
9054
|
+
new WebRtcTransport(this.signaler, opts.myId, {
|
|
9055
|
+
iceServers: opts.iceServers,
|
|
9056
|
+
maxPeers: opts.maxPeers,
|
|
9057
|
+
});
|
|
9058
|
+
this.host = buildEngineHost(opts);
|
|
9059
|
+
// Wire transport ⇄ engine, demuxing block (engine) from gossip (app).
|
|
9060
|
+
this.offs.push(this.transport.onMessage((peer, data, channel) => {
|
|
9061
|
+
if (channel === CHANNEL_GOSSIP) {
|
|
9062
|
+
for (const cb of this.gossipCbs)
|
|
9063
|
+
cb(peer, data);
|
|
9064
|
+
}
|
|
9065
|
+
else {
|
|
9066
|
+
this.host.handleFrame(peer, data);
|
|
9067
|
+
}
|
|
9068
|
+
}));
|
|
9069
|
+
this.offs.push(this.host.onOutbound((target, frame) => target === "*"
|
|
9070
|
+
? this.transport.broadcast(frame, CHANNEL_BLOCK)
|
|
9071
|
+
: this.transport.send(target, frame, CHANNEL_BLOCK)));
|
|
9072
|
+
// Discovery: a broadcast hello triggers a unicast hello back (so a
|
|
9073
|
+
// newcomer learns existing members); both sides connectTo (the smaller
|
|
9074
|
+
// id actually dials). Deterministic + idempotent.
|
|
9075
|
+
this.offs.push(this.signaler.onSignal((from, kind, data) => {
|
|
9076
|
+
if (kind !== "hello")
|
|
9077
|
+
return;
|
|
9078
|
+
this.transport.connectTo?.(from);
|
|
9079
|
+
if (data?.broadcast) {
|
|
9080
|
+
this.signaler.send(from, "hello", { broadcast: false });
|
|
9081
|
+
}
|
|
9082
|
+
}));
|
|
9083
|
+
this.exchange = new PeerExchange(this.host, { timeoutMs: opts.timeoutMs, debug: opts.debug });
|
|
9084
|
+
if (opts.debug) {
|
|
9085
|
+
console.info("[muhkoo:p2p] mesh attached for space; awaiting peers…");
|
|
9086
|
+
this.offs.push(this.transport.onPeerChange((peers) => console.info(`%c[muhkoo:p2p] connected peers: ${peers.length}`, "color:#58da7d", peers)));
|
|
9087
|
+
}
|
|
9088
|
+
}
|
|
9089
|
+
/**
|
|
9090
|
+
* Begin peer discovery — broadcast a hello so existing members dial us and
|
|
9091
|
+
* we learn them. Call once the signaling channel is live (the Space fires
|
|
9092
|
+
* this on CONNECTED); safe to call again on reconnect.
|
|
9093
|
+
*/
|
|
9094
|
+
start() {
|
|
9095
|
+
if (this.closed)
|
|
9096
|
+
return;
|
|
9097
|
+
this.signaler.broadcast("hello", { broadcast: true });
|
|
9098
|
+
}
|
|
9099
|
+
/**
|
|
9100
|
+
* Broadcast an app payload to all connected peers over the gossip channel —
|
|
9101
|
+
* a direct, server-bypassing data path for CRDT ops / high-frequency state
|
|
9102
|
+
* (cursors, presence, collaborative edits). Single-hop (no re-forwarding).
|
|
9103
|
+
*/
|
|
9104
|
+
gossip(data) {
|
|
9105
|
+
this.transport.broadcast(data, CHANNEL_GOSSIP);
|
|
9106
|
+
}
|
|
9107
|
+
/** Subscribe to gossip from peers. Returns an unsubscribe function. */
|
|
9108
|
+
onGossip(cb) {
|
|
9109
|
+
this.gossipCbs.add(cb);
|
|
9110
|
+
return () => this.gossipCbs.delete(cb);
|
|
9111
|
+
}
|
|
9112
|
+
/** Connected peer count (open data channels). */
|
|
9113
|
+
peerCount() {
|
|
9114
|
+
return this.transport.peers().length;
|
|
9115
|
+
}
|
|
9116
|
+
close() {
|
|
9117
|
+
if (this.closed)
|
|
9118
|
+
return;
|
|
9119
|
+
this.closed = true;
|
|
9120
|
+
try {
|
|
9121
|
+
this.signaler.broadcast("bye");
|
|
9122
|
+
}
|
|
9123
|
+
catch { /* socket may be gone */ }
|
|
9124
|
+
for (const off of this.offs)
|
|
9125
|
+
off();
|
|
9126
|
+
this.transport.close();
|
|
9127
|
+
this.host.close();
|
|
9128
|
+
}
|
|
9129
|
+
}
|
|
9130
|
+
|
|
9131
|
+
/**
|
|
9132
|
+
* Whether this environment can do WebRTC peering. `RTCPeerConnection` is
|
|
9133
|
+
* Window-only (absent in Node/Workers/SSR), so this gates whether a
|
|
9134
|
+
* {@link ./PeerNetwork} can be built at all.
|
|
9135
|
+
*/
|
|
9136
|
+
function isP2pCapable() {
|
|
9137
|
+
try {
|
|
9138
|
+
return typeof RTCPeerConnection !== "undefined";
|
|
9139
|
+
}
|
|
9140
|
+
catch {
|
|
9141
|
+
return false;
|
|
9142
|
+
}
|
|
9143
|
+
}
|
|
9144
|
+
|
|
8044
9145
|
/**
|
|
8045
9146
|
* `client.space` — the fan-out group-encryption surface.
|
|
8046
9147
|
*
|
|
@@ -8272,13 +9373,52 @@ class SpaceNamespace {
|
|
|
8272
9373
|
: undefined,
|
|
8273
9374
|
});
|
|
8274
9375
|
this.openSpaces.set(spaceId, space);
|
|
9376
|
+
// Attach a private P2P swarm scoped to this Space (block exchange among
|
|
9377
|
+
// members; signaled over the Space relay). Discovery starts on connect.
|
|
9378
|
+
if (this.deps.p2p?.enabled && isP2pCapable()) {
|
|
9379
|
+
space.attachPeerNetwork(new PeerNetwork({
|
|
9380
|
+
space,
|
|
9381
|
+
myId: memberId,
|
|
9382
|
+
workerFactory: this.deps.p2p.workerFactory,
|
|
9383
|
+
iceServers: this.deps.p2p.iceServers,
|
|
9384
|
+
maxPeers: this.deps.p2p.maxPeers,
|
|
9385
|
+
debug: this.deps.p2p.debug,
|
|
9386
|
+
}));
|
|
9387
|
+
}
|
|
8275
9388
|
return space;
|
|
8276
9389
|
}
|
|
8277
9390
|
/** Ensure the member has an identity keypair; return its public ECDH + ECDSA keys. */
|
|
8278
9391
|
async ensureIdentity(memberId) {
|
|
8279
9392
|
const store = KeyStore.getInstance();
|
|
8280
9393
|
if (!store.getKeyPair(memberId)) {
|
|
8281
|
-
|
|
9394
|
+
// Unlocked (seed in hand): rehydrate the member's STABLE keypair from
|
|
9395
|
+
// the vault, or provision + persist one on first use. This keeps the
|
|
9396
|
+
// member the same identity across reloads — no re-admit each session,
|
|
9397
|
+
// and the group-key cache round-trips. Best-effort: a personal-space
|
|
9398
|
+
// hiccup falls through to an ephemeral keypair (today's behavior).
|
|
9399
|
+
const seed = this.deps.session.seed;
|
|
9400
|
+
const staging = (() => { try {
|
|
9401
|
+
return typeof location !== "undefined" && /(^|\.)staging\./.test(location.hostname);
|
|
9402
|
+
}
|
|
9403
|
+
catch {
|
|
9404
|
+
return false;
|
|
9405
|
+
} })();
|
|
9406
|
+
if (staging)
|
|
9407
|
+
console.info(`[muhkoo:vault] ensureIdentity: unlocked=${!!seed} vault=${!!this.deps.chatKeys}`);
|
|
9408
|
+
if (this.deps.chatKeys && seed) {
|
|
9409
|
+
try {
|
|
9410
|
+
await this.deps.chatKeys.ensure(memberId, toBase64$1(seed));
|
|
9411
|
+
}
|
|
9412
|
+
catch (e) {
|
|
9413
|
+
if (staging)
|
|
9414
|
+
console.info(`[muhkoo:vault] ensureIdentity: vault.ensure failed — ${e instanceof Error ? e.message : e}`);
|
|
9415
|
+
}
|
|
9416
|
+
}
|
|
9417
|
+
if (!store.getKeyPair(memberId)) {
|
|
9418
|
+
if (staging)
|
|
9419
|
+
console.info("[muhkoo:vault] ensureIdentity: EPHEMERAL keypair (locked / no vault) → will re-admit");
|
|
9420
|
+
await store.generateOwnKeyPair(memberId);
|
|
9421
|
+
}
|
|
8282
9422
|
}
|
|
8283
9423
|
const ecdhPub = await exportEcdhPublicKey(store.getKeyPair(memberId).publicKey);
|
|
8284
9424
|
const ecdsaPub = await exportEcdsaPublicKey(store.getAuthKeyPair(memberId).publicKey);
|
|
@@ -8519,6 +9659,112 @@ class FunctionsNamespace {
|
|
|
8519
9659
|
}
|
|
8520
9660
|
}
|
|
8521
9661
|
|
|
9662
|
+
/**
|
|
9663
|
+
* `ChatKeyVault` — SDK-owned lifecycle for a member's long-lived ratchet/space
|
|
9664
|
+
* keypair (the connect `KeyStore` entry keyed by the member id / username).
|
|
9665
|
+
*
|
|
9666
|
+
* Historically each app hand-rolled this (see the web app's old
|
|
9667
|
+
* `personal/spaceLoader.ts`): generate a keypair at register, seed-wrap it, and
|
|
9668
|
+
* PUT it to the personal space; on login GET + unwrap + hydrate the `KeyStore`.
|
|
9669
|
+
* That scaffolding now lives here so ANY app gets a **stable** member keypair
|
|
9670
|
+
* across reloads for free — without which the member re-admits to every space on
|
|
9671
|
+
* every load and the group-key cache can never round-trip.
|
|
9672
|
+
*
|
|
9673
|
+
* Security invariant: the wrapping secret is the **master seed** (not the
|
|
9674
|
+
* password), so the blob survives password changes and unlocks under a passkey.
|
|
9675
|
+
* The seed is only ever held in memory (never persisted), so this vault can only
|
|
9676
|
+
* provision/rehydrate while the client is unlocked.
|
|
9677
|
+
*/
|
|
9678
|
+
/** Staging-only diagnostics: is the member keypair stable across reloads? */
|
|
9679
|
+
const VAULT_DEBUG = (() => {
|
|
9680
|
+
try {
|
|
9681
|
+
return typeof location !== "undefined" && /(^|\.)staging\./.test(location.hostname);
|
|
9682
|
+
}
|
|
9683
|
+
catch {
|
|
9684
|
+
return false;
|
|
9685
|
+
}
|
|
9686
|
+
})();
|
|
9687
|
+
function vlog(msg) {
|
|
9688
|
+
if (VAULT_DEBUG)
|
|
9689
|
+
console.info(`[muhkoo:vault] ${msg}`);
|
|
9690
|
+
}
|
|
9691
|
+
/** A short, stable fingerprint of the member's current keypair (ecdsa pub). */
|
|
9692
|
+
async function fingerprint(memberId) {
|
|
9693
|
+
try {
|
|
9694
|
+
const raw = await KeyStore.getInstance().getRawEcdsaPublicKey(memberId);
|
|
9695
|
+
if (!raw)
|
|
9696
|
+
return "none";
|
|
9697
|
+
let s = 0;
|
|
9698
|
+
for (const b of raw)
|
|
9699
|
+
s = (s * 31 + b) >>> 0;
|
|
9700
|
+
return s.toString(16).padStart(8, "0");
|
|
9701
|
+
}
|
|
9702
|
+
catch {
|
|
9703
|
+
return "err";
|
|
9704
|
+
}
|
|
9705
|
+
}
|
|
9706
|
+
/** Personal-space key holding the seed-wrapped, dehydrated ratchet keypair. */
|
|
9707
|
+
const CHAT_KEYS_KEY = "chat-keys";
|
|
9708
|
+
class ChatKeyVault {
|
|
9709
|
+
store;
|
|
9710
|
+
constructor(store) {
|
|
9711
|
+
this.store = store;
|
|
9712
|
+
}
|
|
9713
|
+
/**
|
|
9714
|
+
* Register-time: ensure a keypair exists for `memberId`, seed-wrap the
|
|
9715
|
+
* dehydrated form, and persist it to the personal space. Idempotent — reuses
|
|
9716
|
+
* an existing in-tab keypair rather than minting a second one.
|
|
9717
|
+
*/
|
|
9718
|
+
async provision(memberId, seedSecret) {
|
|
9719
|
+
const ks = KeyStore.getInstance();
|
|
9720
|
+
if (!ks.getKeyPair(memberId)) {
|
|
9721
|
+
try {
|
|
9722
|
+
await ks.generateOwnKeyPair(memberId);
|
|
9723
|
+
}
|
|
9724
|
+
catch (e) {
|
|
9725
|
+
if (!String(e?.message ?? e).includes("already exists"))
|
|
9726
|
+
throw e;
|
|
9727
|
+
}
|
|
9728
|
+
}
|
|
9729
|
+
const dehydrated = await ks.dehydrateKeyPair(memberId);
|
|
9730
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(dehydrated));
|
|
9731
|
+
await this.store.put(CHAT_KEYS_KEY, await wrapWithPassphrase(seedSecret, plaintext));
|
|
9732
|
+
vlog(`provision: minted+persisted keypair fp=${await fingerprint(memberId)}`);
|
|
9733
|
+
}
|
|
9734
|
+
/**
|
|
9735
|
+
* Sign-in / unlock time: fetch the wrapped blob, unwrap with the seed, and
|
|
9736
|
+
* hydrate the `KeyStore` — restoring the SAME keypair the member had before.
|
|
9737
|
+
* Returns `true` when a keypair is now in hand (already present or hydrated),
|
|
9738
|
+
* `false` when there's no blob yet (a brand-new member → caller provisions).
|
|
9739
|
+
* Throws only on a genuine unwrap failure (wrong secret / tampered blob).
|
|
9740
|
+
*/
|
|
9741
|
+
async rehydrate(memberId, seedSecret) {
|
|
9742
|
+
// Already unlocked in this tab — nothing to do.
|
|
9743
|
+
if (KeyStore.getInstance().getKeyPair(memberId)?.privateKey)
|
|
9744
|
+
return true;
|
|
9745
|
+
const wrapped = await this.store.get(CHAT_KEYS_KEY);
|
|
9746
|
+
if (!wrapped) {
|
|
9747
|
+
vlog("rehydrate: no chat-keys blob (new member → will provision)");
|
|
9748
|
+
return false;
|
|
9749
|
+
}
|
|
9750
|
+
const bytes = await unwrapWithPassphrase(seedSecret, wrapped);
|
|
9751
|
+
const dehydrated = JSON.parse(new TextDecoder().decode(bytes));
|
|
9752
|
+
await KeyStore.getInstance().hydrateKeyPair(memberId, dehydrated);
|
|
9753
|
+
vlog(`rehydrate: HIT — restored stable keypair fp=${await fingerprint(memberId)}`);
|
|
9754
|
+
return true;
|
|
9755
|
+
}
|
|
9756
|
+
/**
|
|
9757
|
+
* Ensure a stable keypair is in hand: rehydrate from the vault if a blob
|
|
9758
|
+
* exists, otherwise mint one and persist it (so it's stable next session).
|
|
9759
|
+
* Used at register and lazily when a space is first opened.
|
|
9760
|
+
*/
|
|
9761
|
+
async ensure(memberId, seedSecret) {
|
|
9762
|
+
if (await this.rehydrate(memberId, seedSecret))
|
|
9763
|
+
return;
|
|
9764
|
+
await this.provision(memberId, seedSecret);
|
|
9765
|
+
}
|
|
9766
|
+
}
|
|
9767
|
+
|
|
8522
9768
|
/**
|
|
8523
9769
|
* `HlcTimestamp` — a Hybrid Logical Clock reading, packed into a single
|
|
8524
9770
|
* lexicographically-sortable string so the whole offline layer (and the
|
|
@@ -9035,82 +10281,6 @@ class SnapshotNamespace {
|
|
|
9035
10281
|
}
|
|
9036
10282
|
}
|
|
9037
10283
|
|
|
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
10284
|
/**
|
|
9115
10285
|
* IndexedDB schema for the offline layer — the single source of truth for the
|
|
9116
10286
|
* database name, version, and object stores. Bump {@link DB_VERSION} whenever
|
|
@@ -9822,17 +10992,46 @@ class Client {
|
|
|
9822
10992
|
this.message = new MessageNamespace({ http: this.http, session: this.session, wsBaseUrl });
|
|
9823
10993
|
// Group keys are cached in the user's PersonalSpace (encrypted at rest
|
|
9824
10994
|
// by client.kv), so a returning member hydrates without a fresh keyring
|
|
9825
|
-
// round-trip. The server only ever sees
|
|
10995
|
+
// round-trip. The server only ever sees ciphertext.
|
|
10996
|
+
//
|
|
10997
|
+
// `encrypt: false` is deliberate: the keyring already ECIES-wraps each
|
|
10998
|
+
// key to the member's identity (server-blind), and StorageCipher would
|
|
10999
|
+
// additionally require the FULL unlocked identity (`requireIdentity()`),
|
|
11000
|
+
// which is locked after a page reload — that coupling is exactly why the
|
|
11001
|
+
// cache previously never read or persisted. The wrapped blobs are opaque.
|
|
9826
11002
|
const spaceKeyCache = {
|
|
9827
11003
|
loadKeys: (spaceId) => this.kv.get("space-keys", spaceId),
|
|
9828
|
-
saveKeys: (spaceId, keys) => this.kv.set("space-keys", spaceId, keys),
|
|
11004
|
+
saveKeys: (spaceId, keys) => this.kv.set("space-keys", spaceId, keys, { encrypt: false }),
|
|
11005
|
+
};
|
|
11006
|
+
// SDK-owned vault for the member's long-lived ratchet/space keypair.
|
|
11007
|
+
// Stored at the personal-space `chat-keys` key (same key the app used to
|
|
11008
|
+
// manage by hand), seed-wrapped, session-token authed (no snarkjs). This
|
|
11009
|
+
// gives every app a STABLE keypair across reloads — no per-app scaffolding
|
|
11010
|
+
// — so members don't re-admit each load and the group-key cache works.
|
|
11011
|
+
const chatKeyStore = {
|
|
11012
|
+
get: async (key) => {
|
|
11013
|
+
const c = this.session.commitment;
|
|
11014
|
+
if (!c)
|
|
11015
|
+
return null;
|
|
11016
|
+
const res = await this.http.post(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}/get`, {});
|
|
11017
|
+
return (res?.value ?? null);
|
|
11018
|
+
},
|
|
11019
|
+
put: async (key, value) => {
|
|
11020
|
+
const c = this.session.commitment;
|
|
11021
|
+
if (!c)
|
|
11022
|
+
throw new Error("ChatKeyVault: no session — sign in first.");
|
|
11023
|
+
await this.http.post(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}`, { value });
|
|
11024
|
+
},
|
|
9829
11025
|
};
|
|
11026
|
+
const chatKeys = new ChatKeyVault(chatKeyStore);
|
|
9830
11027
|
this.space = new SpaceNamespace({
|
|
9831
11028
|
http: this.http,
|
|
9832
11029
|
session: this.session,
|
|
9833
11030
|
wsBaseUrl,
|
|
9834
11031
|
cache: spaceKeyCache,
|
|
9835
11032
|
offline: this.offline,
|
|
11033
|
+
p2p: options.p2p,
|
|
11034
|
+
chatKeys,
|
|
9836
11035
|
});
|
|
9837
11036
|
this.agents = new AgentsNamespace({ http: this.http });
|
|
9838
11037
|
this.functions = new FunctionsNamespace({ http: this.http, fnHostSuffix: options.functionsHostSuffix });
|
|
@@ -10724,5 +11923,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
|
|
|
10724
11923
|
default: loadRsWasm
|
|
10725
11924
|
});
|
|
10726
11925
|
|
|
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 };
|
|
11926
|
+
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
11927
|
//# sourceMappingURL=index.js.map
|