@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/browser/index.js
CHANGED
|
@@ -3796,6 +3796,8 @@ class ReedSolomonCodec {
|
|
|
3796
3796
|
* skip a PUT they'd otherwise dedup-rewrite. The
|
|
3797
3797
|
* server can ignore the optimization if it wants.
|
|
3798
3798
|
*/
|
|
3799
|
+
/** Peer head-start (ms) before origin joins the race in {@link ShardClient.getShard}. */
|
|
3800
|
+
const HEDGE_MS = 500;
|
|
3799
3801
|
/**
|
|
3800
3802
|
* Compute the lowercase hex SHA-256 of `bytes`. Available via WebCrypto in
|
|
3801
3803
|
* every runtime this SDK targets.
|
|
@@ -3814,6 +3816,7 @@ class ShardClient {
|
|
|
3814
3816
|
fetchFn;
|
|
3815
3817
|
cache;
|
|
3816
3818
|
deferUpload;
|
|
3819
|
+
peers;
|
|
3817
3820
|
constructor(opts) {
|
|
3818
3821
|
if (!opts?.baseUrl)
|
|
3819
3822
|
throw new Error("ShardClient: `baseUrl` is required");
|
|
@@ -3831,6 +3834,7 @@ class ShardClient {
|
|
|
3831
3834
|
this.fetchFn = f.bind(globalThis);
|
|
3832
3835
|
this.cache = opts.cache;
|
|
3833
3836
|
this.deferUpload = opts.deferUpload;
|
|
3837
|
+
this.peers = opts.peers;
|
|
3834
3838
|
}
|
|
3835
3839
|
/** Build the URL for a given shard hash — exposed for callers that want to log or pre-resolve. */
|
|
3836
3840
|
urlForHash(hash) {
|
|
@@ -3849,6 +3853,9 @@ class ShardClient {
|
|
|
3849
3853
|
// this is safe to keep regardless of upload outcome.
|
|
3850
3854
|
if (this.cache)
|
|
3851
3855
|
await this.cache.put(hash, bytes);
|
|
3856
|
+
// Tell the swarm we now hold this block, so peers can pull it from us.
|
|
3857
|
+
if (this.peers)
|
|
3858
|
+
this.peers.announce(hash);
|
|
3852
3859
|
let response;
|
|
3853
3860
|
try {
|
|
3854
3861
|
response = await this.fetchFn(url, {
|
|
@@ -3892,15 +3899,63 @@ class ShardClient {
|
|
|
3892
3899
|
if (cached)
|
|
3893
3900
|
return cached;
|
|
3894
3901
|
}
|
|
3902
|
+
// No peers → straight to origin.
|
|
3903
|
+
if (!this.peers)
|
|
3904
|
+
return this.fetchFromOrigin(hash);
|
|
3905
|
+
// HEDGED RACE: prefer a peer (saves origin egress) but never let a slow
|
|
3906
|
+
// or missing peer stall delivery — give the peer a short head start, then
|
|
3907
|
+
// start origin in parallel and take whichever returns the block first.
|
|
3908
|
+
const peers = this.peers;
|
|
3909
|
+
return new Promise((resolve, reject) => {
|
|
3910
|
+
let done = false;
|
|
3911
|
+
let originStarted = false;
|
|
3912
|
+
let timer = null;
|
|
3913
|
+
const win = (b) => {
|
|
3914
|
+
if (done)
|
|
3915
|
+
return;
|
|
3916
|
+
done = true;
|
|
3917
|
+
if (timer)
|
|
3918
|
+
clearTimeout(timer);
|
|
3919
|
+
resolve(b);
|
|
3920
|
+
};
|
|
3921
|
+
const fail = (e) => {
|
|
3922
|
+
if (!done) {
|
|
3923
|
+
done = true;
|
|
3924
|
+
reject(e);
|
|
3925
|
+
}
|
|
3926
|
+
};
|
|
3927
|
+
const startOrigin = () => {
|
|
3928
|
+
if (originStarted || done)
|
|
3929
|
+
return;
|
|
3930
|
+
originStarted = true;
|
|
3931
|
+
if (timer) {
|
|
3932
|
+
clearTimeout(timer);
|
|
3933
|
+
timer = null;
|
|
3934
|
+
}
|
|
3935
|
+
this.fetchFromOrigin(hash).then(win, fail);
|
|
3936
|
+
};
|
|
3937
|
+
timer = setTimeout(startOrigin, HEDGE_MS);
|
|
3938
|
+
peers.getBlock(hash).then((b) => {
|
|
3939
|
+
if (b) {
|
|
3940
|
+
if (this.cache)
|
|
3941
|
+
void this.cache.put(hash, b);
|
|
3942
|
+
win(b); // peer delivered first
|
|
3943
|
+
}
|
|
3944
|
+
else {
|
|
3945
|
+
startOrigin(); // peer miss → don't wait out the hedge
|
|
3946
|
+
}
|
|
3947
|
+
}, () => startOrigin());
|
|
3948
|
+
});
|
|
3949
|
+
}
|
|
3950
|
+
/** Fetch a shard from origin. Returns `null` for 404 (or offline when a
|
|
3951
|
+
* cache is present, so RS recovery can proceed); throws otherwise. */
|
|
3952
|
+
async fetchFromOrigin(hash) {
|
|
3895
3953
|
const url = this.urlForHash(hash);
|
|
3896
3954
|
let response;
|
|
3897
3955
|
try {
|
|
3898
3956
|
response = await this.fetchFn(url, { method: "GET" });
|
|
3899
3957
|
}
|
|
3900
3958
|
catch (err) {
|
|
3901
|
-
// Offline with no cache hit: report the shard as missing so
|
|
3902
|
-
// FileStorage's Reed–Solomon recovery can still reconstruct from the
|
|
3903
|
-
// shards that ARE cached. Rethrow when there's no cache to fall back on.
|
|
3904
3959
|
if (this.cache)
|
|
3905
3960
|
return null;
|
|
3906
3961
|
throw err;
|
|
@@ -3911,8 +3966,7 @@ class ShardClient {
|
|
|
3911
3966
|
const text = await safeText(response);
|
|
3912
3967
|
throw new Error(`ShardClient.getShard(${hash}) failed (${response.status}): ${text}`);
|
|
3913
3968
|
}
|
|
3914
|
-
const
|
|
3915
|
-
const bytes = new Uint8Array(buf);
|
|
3969
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
3916
3970
|
if (this.cache)
|
|
3917
3971
|
void this.cache.put(hash, bytes);
|
|
3918
3972
|
return bytes;
|
|
@@ -4940,7 +4994,7 @@ function base64UrlDecode(s) {
|
|
|
4940
4994
|
* expect (and isn't serving a stale cached bundle). Keep this in sync with
|
|
4941
4995
|
* `package.json` `version`.
|
|
4942
4996
|
*/
|
|
4943
|
-
const VERSION = "0.
|
|
4997
|
+
const VERSION = "0.10.0-alpha.0";
|
|
4944
4998
|
|
|
4945
4999
|
/**
|
|
4946
5000
|
* `HttpClient` — the one place the unified {@link Client} talks to the
|
|
@@ -4963,6 +5017,28 @@ const VERSION = "0.8.0-alpha.0";
|
|
|
4963
5017
|
* convenience JSON helpers (`get`/`post`/`del`) for code that talks to the
|
|
4964
5018
|
* accelerator directly.
|
|
4965
5019
|
*/
|
|
5020
|
+
/** Per-request timing logs, enabled on staging only (temporary diagnostic). */
|
|
5021
|
+
const HTTP_DEBUG = (() => {
|
|
5022
|
+
try {
|
|
5023
|
+
return typeof location !== "undefined" && /(^|\.)staging\./.test(location.hostname);
|
|
5024
|
+
}
|
|
5025
|
+
catch {
|
|
5026
|
+
return false;
|
|
5027
|
+
}
|
|
5028
|
+
})();
|
|
5029
|
+
/** Short, readable request path (pathname + search) for the timing log. */
|
|
5030
|
+
function reqPath(input) {
|
|
5031
|
+
try {
|
|
5032
|
+
const u = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
5033
|
+
if (!/^https?:\/\//i.test(u))
|
|
5034
|
+
return u;
|
|
5035
|
+
const parsed = new URL(u);
|
|
5036
|
+
return parsed.pathname + parsed.search;
|
|
5037
|
+
}
|
|
5038
|
+
catch {
|
|
5039
|
+
return "?";
|
|
5040
|
+
}
|
|
5041
|
+
}
|
|
4966
5042
|
/** Thrown for any non-2xx accelerator response. Carries the HTTP status. */
|
|
4967
5043
|
class HttpError extends Error {
|
|
4968
5044
|
status;
|
|
@@ -5019,7 +5095,14 @@ class HttpClient {
|
|
|
5019
5095
|
if (token)
|
|
5020
5096
|
headers.set("X-Muhkoo-Session", token);
|
|
5021
5097
|
}
|
|
5022
|
-
|
|
5098
|
+
const finalInit = { ...init, headers };
|
|
5099
|
+
if (!HTTP_DEBUG)
|
|
5100
|
+
return this.fetchFn(input, finalInit);
|
|
5101
|
+
// Staging diagnostic: time every accelerator request (this `fetch` is the
|
|
5102
|
+
// single chokepoint — JSON helpers + the roster/shard fetches ride it).
|
|
5103
|
+
const t = performance.now();
|
|
5104
|
+
const tag = `${init?.method ?? "GET"} ${reqPath(input)}`;
|
|
5105
|
+
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; });
|
|
5023
5106
|
};
|
|
5024
5107
|
/** True when `input` resolves to {@link baseOrigin} (relative URLs always do). */
|
|
5025
5108
|
isSameOrigin(input) {
|
|
@@ -5833,6 +5916,34 @@ class ZkAuth {
|
|
|
5833
5916
|
return null;
|
|
5834
5917
|
}
|
|
5835
5918
|
}
|
|
5919
|
+
/**
|
|
5920
|
+
* Boot helper — call this once on app start. Restores the session AND, when a
|
|
5921
|
+
* passkey is enrolled, silently unlocks the identity via WebAuthn (recovering
|
|
5922
|
+
* the master seed so the ratchet keypair rehydrates and `client.kv` works).
|
|
5923
|
+
* The seed is never persisted, so a passkey (or password) is the only way to
|
|
5924
|
+
* unlock after a reload — this keeps that off the app's plate.
|
|
5925
|
+
*
|
|
5926
|
+
* Returns `{ user, unlocked }`. When `unlocked` is false (no passkey, the
|
|
5927
|
+
* user cancelled, or the browser required a gesture), prompt for a password
|
|
5928
|
+
* and call {@link unlock}. Apps need no key-management scaffolding beyond this.
|
|
5929
|
+
*/
|
|
5930
|
+
async resume() {
|
|
5931
|
+
const user = await this.restore();
|
|
5932
|
+
if (!user)
|
|
5933
|
+
return { user: null, unlocked: false };
|
|
5934
|
+
if (this.deps.session.isUnlocked)
|
|
5935
|
+
return { user, unlocked: true };
|
|
5936
|
+
try {
|
|
5937
|
+
const factors = await this.listFactors();
|
|
5938
|
+
if (factors.some((f) => f.type === "passkey")) {
|
|
5939
|
+
await this.loginWithPasskey(user.username);
|
|
5940
|
+
}
|
|
5941
|
+
}
|
|
5942
|
+
catch {
|
|
5943
|
+
/* no passkey / cancelled / no user gesture — leave locked for a prompt */
|
|
5944
|
+
}
|
|
5945
|
+
return { user, unlocked: this.deps.session.isUnlocked };
|
|
5946
|
+
}
|
|
5836
5947
|
/**
|
|
5837
5948
|
* Re-derive identity material for an already-authenticated (restored)
|
|
5838
5949
|
* session, so the client can decrypt storage and run the ratchet. Verifies
|
|
@@ -7037,6 +7148,8 @@ class Space {
|
|
|
7037
7148
|
events = new EventTarget();
|
|
7038
7149
|
rawWired = false;
|
|
7039
7150
|
catchUpUnsub = null;
|
|
7151
|
+
/** Optional P2P swarm for this Space (block exchange among members). */
|
|
7152
|
+
peerNet = null;
|
|
7040
7153
|
constructor(deps) {
|
|
7041
7154
|
this.deps = deps;
|
|
7042
7155
|
this.name = deps.name;
|
|
@@ -7085,6 +7198,9 @@ class Space {
|
|
|
7085
7198
|
if (this.deps.keyring) {
|
|
7086
7199
|
this.channel.on(BroadcastChannelEvents.CONNECTED, () => {
|
|
7087
7200
|
this.channel?.sendRaw({ name: this.deps.myId() });
|
|
7201
|
+
// Kick P2P peer discovery now the signaling channel is live
|
|
7202
|
+
// (re-runs on each reconnect to re-form the mesh).
|
|
7203
|
+
this.peerNet?.start();
|
|
7088
7204
|
});
|
|
7089
7205
|
this.wireFanout();
|
|
7090
7206
|
// Register offline catch-up once: on each reconnect the sync
|
|
@@ -7100,6 +7216,27 @@ class Space {
|
|
|
7100
7216
|
this.channel?.disconnect();
|
|
7101
7217
|
this.catchUpUnsub?.();
|
|
7102
7218
|
this.catchUpUnsub = null;
|
|
7219
|
+
this.peerNet?.close();
|
|
7220
|
+
this.peerNet = null;
|
|
7221
|
+
}
|
|
7222
|
+
/** Attach a P2P swarm so this Space's file reads/writes can use peers. */
|
|
7223
|
+
attachPeerNetwork(net) {
|
|
7224
|
+
this.peerNet = net;
|
|
7225
|
+
}
|
|
7226
|
+
/**
|
|
7227
|
+
* Broadcast an app payload directly to connected Space peers over P2P,
|
|
7228
|
+
* **bypassing the server** — for CRDT ops / high-frequency state (live
|
|
7229
|
+
* cursors, presence, collaborative edits) you don't want to push through the
|
|
7230
|
+
* relay. No-op when no P2P mesh is active (requires `client` `p2p`).
|
|
7231
|
+
* Unlike {@link sendEphemeral} (server-relayed, metered, size-limited), this
|
|
7232
|
+
* is peer-direct and fragments large payloads.
|
|
7233
|
+
*/
|
|
7234
|
+
gossipToPeers(data) {
|
|
7235
|
+
this.peerNet?.gossip(data);
|
|
7236
|
+
}
|
|
7237
|
+
/** Subscribe to peer gossip ({@link gossipToPeers}). Returns an unsubscribe fn. */
|
|
7238
|
+
onPeerGossip(handler) {
|
|
7239
|
+
return this.peerNet?.onGossip(handler) ?? (() => { });
|
|
7103
7240
|
}
|
|
7104
7241
|
/** Subscribe to a raw channel event. Buffered until `connect()` if early. */
|
|
7105
7242
|
on(event, handler) {
|
|
@@ -7158,18 +7295,23 @@ class Space {
|
|
|
7158
7295
|
const hlc = await offline.nextHlc();
|
|
7159
7296
|
const packet = await this.sealMessage(payload, opts.channel, opts.contentType, clientId);
|
|
7160
7297
|
const frame = { spaceMessage: packet };
|
|
7161
|
-
|
|
7298
|
+
const entry = {
|
|
7162
7299
|
handle: provisionalSpaceKey(clientId),
|
|
7163
7300
|
packet,
|
|
7164
7301
|
op: "msg",
|
|
7165
7302
|
clientId,
|
|
7166
7303
|
hlc,
|
|
7167
7304
|
pending: true,
|
|
7168
|
-
}
|
|
7169
|
-
if (this.isConnected())
|
|
7170
|
-
this.sendRaw(frame);
|
|
7171
|
-
|
|
7305
|
+
};
|
|
7306
|
+
if (this.isConnected()) {
|
|
7307
|
+
this.sendRaw(frame); // send first — don't block the wire on IndexedDB
|
|
7308
|
+
void offline.putMessage(this.id, entry); // optimistic cache off the hot path
|
|
7309
|
+
}
|
|
7310
|
+
else {
|
|
7311
|
+
// Offline: persist the optimistic entry + durable replay queue.
|
|
7312
|
+
void offline.putMessage(this.id, entry);
|
|
7172
7313
|
await offline.enqueueFrame(this.id, frame, clientId, hlc);
|
|
7314
|
+
}
|
|
7173
7315
|
}
|
|
7174
7316
|
/** Subscribe to decrypted fan-out messages. Returns an unsubscribe fn. */
|
|
7175
7317
|
onMessage(handler) {
|
|
@@ -7233,18 +7375,14 @@ class Space {
|
|
|
7233
7375
|
const offline = this.deps.offline;
|
|
7234
7376
|
if (!offline?.enabled || !this.cipher)
|
|
7235
7377
|
return [];
|
|
7236
|
-
const entries = await offline.loadMessages(this.id);
|
|
7378
|
+
const entries = (await offline.loadMessages(this.id)).filter((e) => !e.deleted && e.packet);
|
|
7379
|
+
// Decode in parallel (already verified when cached → skipVerify).
|
|
7380
|
+
const decodedAll = await Promise.all(entries.map((e) => this.decodeFrame(e.packet, this.cipher, /*isRawFrame*/ true, e.pending ? 0 : Number(e.handle), /*skipVerify*/ true)));
|
|
7237
7381
|
const out = [];
|
|
7238
|
-
for (
|
|
7239
|
-
|
|
7240
|
-
continue;
|
|
7241
|
-
// Already verified when first cached → skip re-verification (which
|
|
7242
|
-
// would also fail offline without a warm member directory).
|
|
7243
|
-
const decoded = await this.decodeFrame(e.packet, this.cipher,
|
|
7244
|
-
/*isRawFrame*/ true, e.pending ? 0 : Number(e.handle),
|
|
7245
|
-
/*skipVerify*/ true);
|
|
7382
|
+
for (let i = 0; i < decodedAll.length; i++) {
|
|
7383
|
+
const decoded = decodedAll[i];
|
|
7246
7384
|
if (decoded) {
|
|
7247
|
-
decoded.pending =
|
|
7385
|
+
decoded.pending = entries[i].pending;
|
|
7248
7386
|
out.push(decoded);
|
|
7249
7387
|
}
|
|
7250
7388
|
}
|
|
@@ -7269,10 +7407,12 @@ class Space {
|
|
|
7269
7407
|
* indicators, presence, or live cursors are built on top of this. No-op if
|
|
7270
7408
|
* the space isn't connected (ephemeral signals aren't worth queuing).
|
|
7271
7409
|
*/
|
|
7272
|
-
sendEphemeral(subject, data) {
|
|
7410
|
+
sendEphemeral(subject, data, to) {
|
|
7273
7411
|
if (!this.isConnected())
|
|
7274
7412
|
return;
|
|
7275
|
-
|
|
7413
|
+
// `to` (a member id) lets the server unicast a *directed* ephemeral
|
|
7414
|
+
// instead of broadcasting; omitted ⇒ broadcast to the room (unchanged).
|
|
7415
|
+
this.sendRaw({ pub: to ? { subject, data, to } : { subject, data } });
|
|
7276
7416
|
}
|
|
7277
7417
|
/** Subscribe to inbound ephemeral signals. `from` is the authenticated sender. */
|
|
7278
7418
|
onEphemeral(handler) {
|
|
@@ -7299,25 +7439,45 @@ class Space {
|
|
|
7299
7439
|
params.set("limit", String(opts.limit));
|
|
7300
7440
|
const url = `${this.deps.httpBaseUrl}/api/spaces/${encodeURIComponent(this.name)}/history` +
|
|
7301
7441
|
(params.toString() ? `?${params}` : "");
|
|
7442
|
+
// Temporary staging diagnostic: split fetch / directory / decode time.
|
|
7443
|
+
const dbg = (() => { try {
|
|
7444
|
+
return globalThis.location?.hostname?.includes("staging");
|
|
7445
|
+
}
|
|
7446
|
+
catch {
|
|
7447
|
+
return false;
|
|
7448
|
+
} })();
|
|
7449
|
+
const mark = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
|
|
7450
|
+
const t0 = mark();
|
|
7302
7451
|
const res = await this.deps.fetch(url);
|
|
7303
7452
|
if (!res.ok)
|
|
7304
7453
|
throw new Error(`Space.history: ${res.status} ${res.statusText}`);
|
|
7305
7454
|
const body = (await res.json());
|
|
7455
|
+
const tFetch = mark();
|
|
7306
7456
|
// Warm the member directory once so historical senders' signatures verify.
|
|
7307
|
-
//
|
|
7308
|
-
await this.deps.keyring?.refreshDirectory();
|
|
7457
|
+
// Force past the cooldown — a history load is an explicit "get fresh".
|
|
7458
|
+
await this.deps.keyring?.refreshDirectory({ force: true });
|
|
7459
|
+
const tDir = mark();
|
|
7460
|
+
// Decode the whole page in PARALLEL — each frame is an AES-GCM decrypt +
|
|
7461
|
+
// ECDSA verify on the WebCrypto thread; doing them sequentially (await
|
|
7462
|
+
// per message) serializes ~100 main↔crypto round-trips and dominates
|
|
7463
|
+
// load time. Promise.all preserves input order.
|
|
7464
|
+
const raws = body.messages ?? [];
|
|
7465
|
+
const decodedAll = await Promise.all(raws.map((raw) => this.decodeFrame(raw, cipher)));
|
|
7466
|
+
if (dbg) {
|
|
7467
|
+
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`);
|
|
7468
|
+
}
|
|
7309
7469
|
const messages = [];
|
|
7310
|
-
for (
|
|
7311
|
-
const decoded =
|
|
7470
|
+
for (let i = 0; i < decodedAll.length; i++) {
|
|
7471
|
+
const decoded = decodedAll[i];
|
|
7312
7472
|
if (!decoded)
|
|
7313
7473
|
continue;
|
|
7314
7474
|
messages.push(decoded);
|
|
7315
|
-
// Warm the offline cache
|
|
7475
|
+
// Warm the offline cache off the hot path (best-effort, non-blocking).
|
|
7316
7476
|
if (this.deps.offline?.enabled) {
|
|
7317
7477
|
try {
|
|
7318
|
-
const outer = JSON.parse(
|
|
7478
|
+
const outer = JSON.parse(raws[i]);
|
|
7319
7479
|
if (typeof outer.spaceMessage === "string") {
|
|
7320
|
-
|
|
7480
|
+
void this.cacheFrame(outer.spaceMessage, decoded, "msg");
|
|
7321
7481
|
}
|
|
7322
7482
|
}
|
|
7323
7483
|
catch {
|
|
@@ -7392,6 +7552,7 @@ class Space {
|
|
|
7392
7552
|
fetch: this.deps.fetch,
|
|
7393
7553
|
cache: this.deps.shardCache,
|
|
7394
7554
|
deferUpload: this.deps.deferShardUpload,
|
|
7555
|
+
peers: this.peerNet?.exchange,
|
|
7395
7556
|
});
|
|
7396
7557
|
return new FileStorage({ shards });
|
|
7397
7558
|
}
|
|
@@ -7413,8 +7574,8 @@ class Space {
|
|
|
7413
7574
|
const handle = Number(f.timestamp ?? 0);
|
|
7414
7575
|
const decoded = await this.decodeFrame(f.spaceMessage, this.cipher, /*raw*/ true, handle);
|
|
7415
7576
|
if (decoded) {
|
|
7416
|
-
this.emitEvent("message", decoded);
|
|
7417
|
-
|
|
7577
|
+
this.emitEvent("message", decoded); // UI first
|
|
7578
|
+
void this.cacheFrame(f.spaceMessage, decoded, "msg"); // cache off the hot path
|
|
7418
7579
|
}
|
|
7419
7580
|
return;
|
|
7420
7581
|
}
|
|
@@ -7426,7 +7587,7 @@ class Space {
|
|
|
7426
7587
|
const decoded = await this.decodeFrame(e.spaceMessage, this.cipher, /*raw*/ true, Number(e.ts ?? 0));
|
|
7427
7588
|
if (decoded) {
|
|
7428
7589
|
this.emitEvent("message-edited", decoded);
|
|
7429
|
-
|
|
7590
|
+
void this.cacheFrame(e.spaceMessage, decoded, "edit");
|
|
7430
7591
|
}
|
|
7431
7592
|
}
|
|
7432
7593
|
return;
|
|
@@ -7436,8 +7597,8 @@ class Space {
|
|
|
7436
7597
|
const handle = Number(d.ts ?? 0);
|
|
7437
7598
|
this.emitEvent("message-deleted", { handle });
|
|
7438
7599
|
if (this.deps.offline?.enabled) {
|
|
7439
|
-
|
|
7440
|
-
|
|
7600
|
+
// Cache the tombstone off the hot path.
|
|
7601
|
+
void this.deps.offline.putDeleted(this.id, handle).then(() => this.deps.offline?.observeHandle(this.id, handle));
|
|
7441
7602
|
}
|
|
7442
7603
|
return;
|
|
7443
7604
|
}
|
|
@@ -7458,7 +7619,7 @@ class Space {
|
|
|
7458
7619
|
this.emitEvent("join-request", req);
|
|
7459
7620
|
// A new member appeared — refresh the directory so their future
|
|
7460
7621
|
// messages verify, and (if we hold the key) auto-admit them.
|
|
7461
|
-
void this.deps.keyring?.refreshDirectory();
|
|
7622
|
+
void this.deps.keyring?.refreshDirectory({ force: true });
|
|
7462
7623
|
if (this.deps.autoAdmit !== false && this.deps.keyring?.hasAnyKey() && req.memberId !== this.deps.myId()) {
|
|
7463
7624
|
void this.deps.keyring.admit(req.memberId, req.identityEcdhPub).catch(() => { });
|
|
7464
7625
|
}
|
|
@@ -7608,7 +7769,7 @@ class Space {
|
|
|
7608
7769
|
const cursor = await offline.getCursor(this.id);
|
|
7609
7770
|
let since = String(cursor?.lastSeenHandle ?? 0);
|
|
7610
7771
|
// Warm the member directory once so historical senders' signatures verify.
|
|
7611
|
-
await this.deps.keyring?.refreshDirectory();
|
|
7772
|
+
await this.deps.keyring?.refreshDirectory({ force: true });
|
|
7612
7773
|
for (let page = 0; page < 50; page++) {
|
|
7613
7774
|
const url = `${this.deps.httpBaseUrl}/api/spaces/${encodeURIComponent(this.name)}/history` +
|
|
7614
7775
|
`?since=${encodeURIComponent(since)}&limit=100`;
|
|
@@ -7871,6 +8032,19 @@ function parseMaybeJson(text) {
|
|
|
7871
8032
|
* `KEEPER_MEMBER_ID`.
|
|
7872
8033
|
*/
|
|
7873
8034
|
const KEEPER_MEMBER_ID = "__keeper__";
|
|
8035
|
+
/** Staging-only keyring diagnostics (group-key cache hit/miss, persist, pulls). */
|
|
8036
|
+
const KEYRING_DEBUG = (() => {
|
|
8037
|
+
try {
|
|
8038
|
+
return typeof location !== "undefined" && /(^|\.)staging\./.test(location.hostname);
|
|
8039
|
+
}
|
|
8040
|
+
catch {
|
|
8041
|
+
return false;
|
|
8042
|
+
}
|
|
8043
|
+
})();
|
|
8044
|
+
function klog(msg) {
|
|
8045
|
+
if (KEYRING_DEBUG)
|
|
8046
|
+
console.info(`[muhkoo:keyring] ${msg}`);
|
|
8047
|
+
}
|
|
7874
8048
|
class SpaceKeyring {
|
|
7875
8049
|
deps;
|
|
7876
8050
|
keys = new Map();
|
|
@@ -7878,6 +8052,14 @@ class SpaceKeyring {
|
|
|
7878
8052
|
policy;
|
|
7879
8053
|
/** Cached member directory: memberId → imported ECDSA verify key. */
|
|
7880
8054
|
memberEcdsa = new Map();
|
|
8055
|
+
/** Coalesce concurrent roster refreshes (the parallel history/backlog decode
|
|
8056
|
+
* would otherwise fire one `roster` fetch PER message — a thundering herd). */
|
|
8057
|
+
dirInFlight = null;
|
|
8058
|
+
dirRefreshedAt = 0;
|
|
8059
|
+
/** Min gap between non-forced roster refreshes. A miss that persists within
|
|
8060
|
+
* this window won't re-fetch (the roster hasn't changed that fast); genuine
|
|
8061
|
+
* membership changes use `{ force: true }` (join requests / history loads). */
|
|
8062
|
+
static DIR_COOLDOWN_MS = 5000;
|
|
7881
8063
|
constructor(deps) {
|
|
7882
8064
|
this.deps = deps;
|
|
7883
8065
|
this.policy = deps.historyPolicy ?? "static";
|
|
@@ -7887,8 +8069,27 @@ class SpaceKeyring {
|
|
|
7887
8069
|
ecdsaKeyFor(memberId) {
|
|
7888
8070
|
return this.memberEcdsa.get(memberId);
|
|
7889
8071
|
}
|
|
7890
|
-
/**
|
|
7891
|
-
|
|
8072
|
+
/**
|
|
8073
|
+
* Refresh the member directory (memberId → ECDSA key) from the roster.
|
|
8074
|
+
* Coalesces concurrent callers into ONE fetch, and skips a fetch when one
|
|
8075
|
+
* completed within {@link DIR_COOLDOWN_MS} (unless `force`). Without this, a
|
|
8076
|
+
* page of N messages decoded in parallel — each missing the cold directory —
|
|
8077
|
+
* fires N simultaneous `roster` requests that queue behind the browser's
|
|
8078
|
+
* connection limit and take many seconds.
|
|
8079
|
+
*/
|
|
8080
|
+
async refreshDirectory(opts) {
|
|
8081
|
+
if (this.dirInFlight)
|
|
8082
|
+
return this.dirInFlight; // a refresh is already running
|
|
8083
|
+
if (!opts?.force && Date.now() - this.dirRefreshedAt < SpaceKeyring.DIR_COOLDOWN_MS) {
|
|
8084
|
+
return; // refreshed very recently — the roster won't have changed
|
|
8085
|
+
}
|
|
8086
|
+
this.dirInFlight = this.doRefreshDirectory().finally(() => {
|
|
8087
|
+
this.dirRefreshedAt = Date.now();
|
|
8088
|
+
this.dirInFlight = null;
|
|
8089
|
+
});
|
|
8090
|
+
return this.dirInFlight;
|
|
8091
|
+
}
|
|
8092
|
+
async doRefreshDirectory() {
|
|
7892
8093
|
let roster;
|
|
7893
8094
|
try {
|
|
7894
8095
|
roster = await this.deps.transport.fetchRoster();
|
|
@@ -7923,34 +8124,63 @@ class SpaceKeyring {
|
|
|
7923
8124
|
async loadFromCache() {
|
|
7924
8125
|
if (!this.deps.cache)
|
|
7925
8126
|
return false;
|
|
8127
|
+
// The cache entries are ECIES-wrapped to our identity key, so we unwrap
|
|
8128
|
+
// with the keyring's OWN private key — available whenever chat works,
|
|
8129
|
+
// unlike client.kv's StorageCipher which needs the full unlocked identity.
|
|
8130
|
+
const priv = this.deps.ownPrivateKey();
|
|
8131
|
+
if (!priv) {
|
|
8132
|
+
klog("loadFromCache: no private key available yet");
|
|
8133
|
+
return false;
|
|
8134
|
+
}
|
|
7926
8135
|
let cached;
|
|
7927
8136
|
try {
|
|
7928
8137
|
cached = await this.deps.cache.loadKeys(this.deps.spaceId);
|
|
7929
8138
|
}
|
|
7930
|
-
catch {
|
|
8139
|
+
catch (e) {
|
|
8140
|
+
klog(`loadFromCache: ERROR reading cache — ${e instanceof Error ? e.message : e}`);
|
|
7931
8141
|
return false;
|
|
7932
8142
|
}
|
|
7933
|
-
if (!cached)
|
|
8143
|
+
if (!cached) {
|
|
8144
|
+
klog("loadFromCache: cache empty");
|
|
8145
|
+
return false;
|
|
8146
|
+
}
|
|
8147
|
+
for (const [epochStr, serialized] of Object.entries(cached)) {
|
|
8148
|
+
try {
|
|
8149
|
+
const blob = JSON.parse(serialized);
|
|
8150
|
+
this.keys.set(Number(epochStr), await unwrapSpaceKey(blob, priv));
|
|
8151
|
+
}
|
|
8152
|
+
catch {
|
|
8153
|
+
/* stale / old-format entry — skip; we'll re-admit and re-persist */
|
|
8154
|
+
}
|
|
8155
|
+
}
|
|
8156
|
+
if (this.keys.size === 0) {
|
|
8157
|
+
klog("loadFromCache: no usable entries (stale format → will re-admit)");
|
|
7934
8158
|
return false;
|
|
7935
|
-
for (const [epochStr, b64] of Object.entries(cached)) {
|
|
7936
|
-
this.keys.set(Number(epochStr), fromBase64$1(b64));
|
|
7937
8159
|
}
|
|
7938
8160
|
this.epoch = Math.max(0, ...Array.from(this.keys.keys()));
|
|
7939
|
-
|
|
8161
|
+
klog(`loadFromCache: HIT ${this.keys.size} epoch(s), current=${this.epoch}`);
|
|
8162
|
+
return true;
|
|
7940
8163
|
}
|
|
7941
8164
|
async persist() {
|
|
7942
|
-
if (!this.deps.cache)
|
|
8165
|
+
if (!this.deps.cache || this.keys.size === 0)
|
|
7943
8166
|
return;
|
|
7944
|
-
|
|
7945
|
-
|
|
7946
|
-
out[String(epoch)] = toBase64$1(key);
|
|
7947
|
-
// Best-effort: caching is an optimization; a write failure (e.g. locked
|
|
7948
|
-
// storage) must not break key acquisition.
|
|
8167
|
+
// Best-effort: caching is an optimization; a write failure must not break
|
|
8168
|
+
// key acquisition.
|
|
7949
8169
|
try {
|
|
8170
|
+
// Wrap each epoch key to our OWN identity key (ECIES-to-self). The
|
|
8171
|
+
// cache stays server-blind (the kv layer stores it with encrypt:false
|
|
8172
|
+
// — it's already encrypted) AND readable with the keyring private key
|
|
8173
|
+
// alone, so it works while the full identity is locked (post-reload).
|
|
8174
|
+
const selfPub = await importEcdhPublicKey(this.deps.identityEcdhPub);
|
|
8175
|
+
const out = {};
|
|
8176
|
+
for (const [epoch, key] of this.keys) {
|
|
8177
|
+
out[String(epoch)] = JSON.stringify(await wrapSpaceKey(key, epoch, selfPub));
|
|
8178
|
+
}
|
|
7950
8179
|
await this.deps.cache.saveKeys(this.deps.spaceId, out);
|
|
8180
|
+
klog(`persist: saved ${Object.keys(out).length} epoch(s)`);
|
|
7951
8181
|
}
|
|
7952
|
-
catch {
|
|
7953
|
-
|
|
8182
|
+
catch (e) {
|
|
8183
|
+
klog(`persist: FAILED — ${e instanceof Error ? e.message : e}`);
|
|
7954
8184
|
}
|
|
7955
8185
|
}
|
|
7956
8186
|
/** Creator path: mint epoch-0 group key for a brand-new space. */
|
|
@@ -7999,8 +8229,24 @@ class SpaceKeyring {
|
|
|
7999
8229
|
this.epoch = Math.max(this.epoch, ...Array.from(this.keys.keys()));
|
|
8000
8230
|
await this.persist();
|
|
8001
8231
|
}
|
|
8232
|
+
klog(`pullKeys: +${added} epoch(s) (now hold ${this.keys.size}, current=${this.epoch})`);
|
|
8002
8233
|
return added;
|
|
8003
8234
|
}
|
|
8235
|
+
/**
|
|
8236
|
+
* Background freshness check for the cache-hit fast path: confirm we hold the
|
|
8237
|
+
* current epoch (picks up a rotation / our removal) WITHOUT the full newcomer
|
|
8238
|
+
* admit handshake. Cheap + idempotent; never blocks first paint. Returns the
|
|
8239
|
+
* number of newly-obtained epoch keys (0 = already current).
|
|
8240
|
+
*/
|
|
8241
|
+
async reconcile() {
|
|
8242
|
+
await this.refreshDirectory({ force: true });
|
|
8243
|
+
try {
|
|
8244
|
+
return await this.pullKeys();
|
|
8245
|
+
}
|
|
8246
|
+
catch {
|
|
8247
|
+
return 0; // best-effort — the live keyringReady frame is the backstop
|
|
8248
|
+
}
|
|
8249
|
+
}
|
|
8004
8250
|
// -- key-holder: admit + rotate -------------------------------------------
|
|
8005
8251
|
/**
|
|
8006
8252
|
* Wrap our group key(s) for a newcomer and post the blobs. In a `static`
|
|
@@ -8310,6 +8556,861 @@ class SpaceCache {
|
|
|
8310
8556
|
}
|
|
8311
8557
|
}
|
|
8312
8558
|
|
|
8559
|
+
/**
|
|
8560
|
+
* `ShardCache` — a Cache-API-backed store for file-shard bytes. Shards are
|
|
8561
|
+
* content-addressed (the key IS the SHA-256 of the ciphertext), so caching them
|
|
8562
|
+
* is uniquely safe: a cache hit is self-validating, immutable, and shareable
|
|
8563
|
+
* across files. The Cache API (rather than IndexedDB) is the right home for
|
|
8564
|
+
* large binary blobs — it streams, it's purpose-built for response bodies, and
|
|
8565
|
+
* it has its own eviction.
|
|
8566
|
+
*
|
|
8567
|
+
* Keys are SYNTHETIC absolute URLs (`https://shard.muhkoo.local/<hash>`) rather
|
|
8568
|
+
* than the real shard endpoint, so the cache is addressable by hash alone — the
|
|
8569
|
+
* sync engine can re-PUT a deferred shard knowing only its hash, without
|
|
8570
|
+
* reconstructing the accelerator URL. We never fetch these synthetic URLs; they
|
|
8571
|
+
* exist only as cache keys.
|
|
8572
|
+
*
|
|
8573
|
+
* Every method degrades to a no-op / null where the Cache API is unavailable
|
|
8574
|
+
* (Node, Workers, insecure contexts), so callers don't need to branch.
|
|
8575
|
+
*/
|
|
8576
|
+
const CACHE_NAME = "muhkoo-shards-v1";
|
|
8577
|
+
class ShardCache {
|
|
8578
|
+
static available() {
|
|
8579
|
+
try {
|
|
8580
|
+
return typeof caches !== "undefined";
|
|
8581
|
+
}
|
|
8582
|
+
catch {
|
|
8583
|
+
return false;
|
|
8584
|
+
}
|
|
8585
|
+
}
|
|
8586
|
+
key(hash) {
|
|
8587
|
+
return `https://shard.muhkoo.local/${encodeURIComponent(hash)}`;
|
|
8588
|
+
}
|
|
8589
|
+
open() {
|
|
8590
|
+
return caches.open(CACHE_NAME);
|
|
8591
|
+
}
|
|
8592
|
+
/** Cached shard bytes, or `null` on miss / when unavailable. */
|
|
8593
|
+
async get(hash) {
|
|
8594
|
+
if (!ShardCache.available())
|
|
8595
|
+
return null;
|
|
8596
|
+
try {
|
|
8597
|
+
const cache = await this.open();
|
|
8598
|
+
const res = await cache.match(this.key(hash));
|
|
8599
|
+
if (!res)
|
|
8600
|
+
return null;
|
|
8601
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
8602
|
+
}
|
|
8603
|
+
catch {
|
|
8604
|
+
return null;
|
|
8605
|
+
}
|
|
8606
|
+
}
|
|
8607
|
+
/** Store shard bytes under their hash. Best-effort. */
|
|
8608
|
+
async put(hash, bytes) {
|
|
8609
|
+
if (!ShardCache.available())
|
|
8610
|
+
return;
|
|
8611
|
+
try {
|
|
8612
|
+
const cache = await this.open();
|
|
8613
|
+
await cache.put(this.key(hash), new Response(bytes, {
|
|
8614
|
+
headers: { "content-type": "application/octet-stream" },
|
|
8615
|
+
}));
|
|
8616
|
+
}
|
|
8617
|
+
catch {
|
|
8618
|
+
/* quota / unavailable — degrade to online-only for this shard */
|
|
8619
|
+
}
|
|
8620
|
+
}
|
|
8621
|
+
/** Whether a shard is present in the cache. */
|
|
8622
|
+
async has(hash) {
|
|
8623
|
+
if (!ShardCache.available())
|
|
8624
|
+
return false;
|
|
8625
|
+
try {
|
|
8626
|
+
const cache = await this.open();
|
|
8627
|
+
return Boolean(await cache.match(this.key(hash)));
|
|
8628
|
+
}
|
|
8629
|
+
catch {
|
|
8630
|
+
return false;
|
|
8631
|
+
}
|
|
8632
|
+
}
|
|
8633
|
+
}
|
|
8634
|
+
|
|
8635
|
+
/**
|
|
8636
|
+
* `PeerExchange` — the thin façade the storage layer consults to try fetching a
|
|
8637
|
+
* block from peers before falling back to origin. It satisfies the structural
|
|
8638
|
+
* `PeerBlockSource` interface that {@link ../storage/transport/ShardClient}
|
|
8639
|
+
* declares (so storage never imports the p2p module — the dependency arrow stays
|
|
8640
|
+
* one-way, p2p → storage).
|
|
8641
|
+
*
|
|
8642
|
+
* It delegates to an {@link EngineHandle} — either an in-process {@link
|
|
8643
|
+
* ./worker/blockEngine.BlockEngine} or the Web Worker proxy — so the same façade
|
|
8644
|
+
* works whether the block engine runs on the main thread or off it.
|
|
8645
|
+
*/
|
|
8646
|
+
class PeerExchange {
|
|
8647
|
+
engine;
|
|
8648
|
+
timeoutMs;
|
|
8649
|
+
debug;
|
|
8650
|
+
constructor(engine, opts = {}) {
|
|
8651
|
+
this.engine = engine;
|
|
8652
|
+
this.timeoutMs = opts.timeoutMs ?? 2500;
|
|
8653
|
+
this.debug = opts.debug ?? false;
|
|
8654
|
+
}
|
|
8655
|
+
async getBlock(hash, opts) {
|
|
8656
|
+
const bytes = await this.engine.want(hash, opts?.timeoutMs ?? this.timeoutMs);
|
|
8657
|
+
if (this.debug) {
|
|
8658
|
+
console.info(`%c[muhkoo:p2p] ${bytes ? "✓ peer-served" : "✗ miss → origin"} shard ${hash.slice(0, 10)}…`, `color:${bytes ? "#58da7d" : "#888"}`);
|
|
8659
|
+
}
|
|
8660
|
+
return bytes;
|
|
8661
|
+
}
|
|
8662
|
+
announce(hash) {
|
|
8663
|
+
if (this.debug)
|
|
8664
|
+
console.info(`[muhkoo:p2p] announce shard ${hash.slice(0, 10)}…`);
|
|
8665
|
+
this.engine.announce(hash);
|
|
8666
|
+
}
|
|
8667
|
+
}
|
|
8668
|
+
|
|
8669
|
+
/**
|
|
8670
|
+
* `SpaceSignaler` — WebRTC signaling carried over the Space's existing ephemeral
|
|
8671
|
+
* relay. No new server channel: `Space.sendEphemeral`/`onEphemeral` already
|
|
8672
|
+
* blind-relay `pub` frames stamped with the authenticated sender, so SDP
|
|
8673
|
+
* offers/answers and ICE candidates ride a reserved subject (`__p2p__`) scoped
|
|
8674
|
+
* to Space membership. The sender (`from`) is server-stamped and can't be
|
|
8675
|
+
* spoofed; the payload is opaque to the server.
|
|
8676
|
+
*
|
|
8677
|
+
* Envelopes carry a `to` (a member id, or `"*"` for discovery) so the
|
|
8678
|
+
* accelerator can optionally unicast peer-pair signaling instead of broadcasting
|
|
8679
|
+
* (a small, backward-compatible `pub` enhancement); the client filters by `to`
|
|
8680
|
+
* regardless.
|
|
8681
|
+
*/
|
|
8682
|
+
const P2P_SUBJECT = "__p2p__";
|
|
8683
|
+
class SpaceSignaler {
|
|
8684
|
+
space;
|
|
8685
|
+
myId;
|
|
8686
|
+
constructor(space, myId) {
|
|
8687
|
+
this.space = space;
|
|
8688
|
+
this.myId = myId;
|
|
8689
|
+
}
|
|
8690
|
+
/** Send a signal to one peer (or `"*"`). */
|
|
8691
|
+
send(to, kind, data) {
|
|
8692
|
+
const env = { to, kind, data };
|
|
8693
|
+
// Ask the server to unicast for a directed target; broadcast for "*".
|
|
8694
|
+
// `to` also stays in the envelope so a broadcast-only server still works
|
|
8695
|
+
// (the client filters by it).
|
|
8696
|
+
this.space.sendEphemeral(P2P_SUBJECT, env, to !== "*" ? to : undefined);
|
|
8697
|
+
}
|
|
8698
|
+
/** Announce/withdraw P2P availability to all Space members. */
|
|
8699
|
+
broadcast(kind, data) {
|
|
8700
|
+
this.send("*", kind, data);
|
|
8701
|
+
}
|
|
8702
|
+
/**
|
|
8703
|
+
* Subscribe to inbound signals addressed to us (or broadcast). Skips our own
|
|
8704
|
+
* echoes. Returns an unsubscribe function.
|
|
8705
|
+
*/
|
|
8706
|
+
onSignal(cb) {
|
|
8707
|
+
return this.space.onEphemeral((e) => {
|
|
8708
|
+
if (e.subject !== P2P_SUBJECT || e.from === this.myId)
|
|
8709
|
+
return;
|
|
8710
|
+
const env = e.data;
|
|
8711
|
+
if (!env || typeof env.kind !== "string")
|
|
8712
|
+
return;
|
|
8713
|
+
if (env.to !== this.myId && env.to !== "*")
|
|
8714
|
+
return;
|
|
8715
|
+
cb(e.from, env.kind, env.data);
|
|
8716
|
+
});
|
|
8717
|
+
}
|
|
8718
|
+
}
|
|
8719
|
+
|
|
8720
|
+
/**
|
|
8721
|
+
* `WebRtcTransport` — a {@link ./PeerTransport.PeerTransport} over RTCDataChannels,
|
|
8722
|
+
* signaled by {@link ../signaling/SpaceSignaler}. Runs on the **main thread**
|
|
8723
|
+
* (`RTCPeerConnection` is Window-only — not available in Workers), so the block
|
|
8724
|
+
* engine lives in a Worker and this only shuttles bytes.
|
|
8725
|
+
*
|
|
8726
|
+
* Connection setup uses a deterministic initiator (the peer with the smaller id
|
|
8727
|
+
* dials) to avoid offer/answer glare. STUN handles NAT traversal; there is no
|
|
8728
|
+
* TURN — an unreachable peer simply never connects and the caller falls back to
|
|
8729
|
+
* origin (P2P is best-effort).
|
|
8730
|
+
*
|
|
8731
|
+
* Frames (up to a 4 MiB shard) exceed an RTCDataChannel's max message size, so
|
|
8732
|
+
* each frame is fragmented into `CHUNK`-sized pieces with a 12-byte header
|
|
8733
|
+
* `[msgId u32][index u32][total u32]` and reassembled per peer.
|
|
8734
|
+
*/
|
|
8735
|
+
const CHUNK = 16 * 1024;
|
|
8736
|
+
// [msgId u32][index u32][total u32][channel u32]
|
|
8737
|
+
const HEADER$1 = 16;
|
|
8738
|
+
/** Pause sends when a channel has this many bytes buffered (backpressure). */
|
|
8739
|
+
const MAX_BUFFERED = 4 * 1024 * 1024;
|
|
8740
|
+
const DEFAULT_ICE = [{ urls: "stun:stun.cloudflare.com:3478" }];
|
|
8741
|
+
class WebRtcTransport {
|
|
8742
|
+
signaler;
|
|
8743
|
+
myId;
|
|
8744
|
+
conns = new Map();
|
|
8745
|
+
msgCbs = new Set();
|
|
8746
|
+
peerCbs = new Set();
|
|
8747
|
+
offSignal;
|
|
8748
|
+
iceServers;
|
|
8749
|
+
maxPeers;
|
|
8750
|
+
nextMsgId = 1;
|
|
8751
|
+
constructor(signaler, myId, opts = {}) {
|
|
8752
|
+
this.signaler = signaler;
|
|
8753
|
+
this.myId = myId;
|
|
8754
|
+
this.iceServers = opts.iceServers ?? DEFAULT_ICE;
|
|
8755
|
+
this.maxPeers = opts.maxPeers ?? 8;
|
|
8756
|
+
this.offSignal = this.signaler.onSignal((from, kind, data) => this.onSignal(from, kind, data));
|
|
8757
|
+
}
|
|
8758
|
+
peers() {
|
|
8759
|
+
return [...this.conns].filter(([, c]) => c.dc?.readyState === "open").map(([id]) => id);
|
|
8760
|
+
}
|
|
8761
|
+
/** Dial a peer (no-op unless we're the deterministic initiator for the pair). */
|
|
8762
|
+
connectTo(peerId) {
|
|
8763
|
+
if (peerId === this.myId || this.conns.has(peerId))
|
|
8764
|
+
return;
|
|
8765
|
+
if (this.conns.size >= this.maxPeers)
|
|
8766
|
+
return; // mesh cap
|
|
8767
|
+
if (this.myId < peerId)
|
|
8768
|
+
void this.initiate(peerId); // smaller id dials
|
|
8769
|
+
}
|
|
8770
|
+
send(peer, data, channel = 0) {
|
|
8771
|
+
const dc = this.conns.get(peer)?.dc;
|
|
8772
|
+
if (dc?.readyState === "open")
|
|
8773
|
+
void this.sendFragmented(dc, data, channel);
|
|
8774
|
+
}
|
|
8775
|
+
broadcast(data, channel = 0) {
|
|
8776
|
+
for (const [, c] of this.conns) {
|
|
8777
|
+
if (c.dc?.readyState === "open")
|
|
8778
|
+
void this.sendFragmented(c.dc, data, channel);
|
|
8779
|
+
}
|
|
8780
|
+
}
|
|
8781
|
+
onMessage(cb) {
|
|
8782
|
+
this.msgCbs.add(cb);
|
|
8783
|
+
return () => this.msgCbs.delete(cb);
|
|
8784
|
+
}
|
|
8785
|
+
onPeerChange(cb) {
|
|
8786
|
+
this.peerCbs.add(cb);
|
|
8787
|
+
return () => this.peerCbs.delete(cb);
|
|
8788
|
+
}
|
|
8789
|
+
close() {
|
|
8790
|
+
this.offSignal();
|
|
8791
|
+
for (const [, c] of this.conns) {
|
|
8792
|
+
try {
|
|
8793
|
+
c.dc?.close();
|
|
8794
|
+
}
|
|
8795
|
+
catch { /* already closing */ }
|
|
8796
|
+
try {
|
|
8797
|
+
c.pc.close();
|
|
8798
|
+
}
|
|
8799
|
+
catch { /* already closing */ }
|
|
8800
|
+
}
|
|
8801
|
+
this.conns.clear();
|
|
8802
|
+
this.msgCbs.clear();
|
|
8803
|
+
this.peerCbs.clear();
|
|
8804
|
+
}
|
|
8805
|
+
// -- internals -----------------------------------------------------------
|
|
8806
|
+
newPc(peerId) {
|
|
8807
|
+
const pc = new RTCPeerConnection({ iceServers: this.iceServers });
|
|
8808
|
+
const conn = { pc, reasm: new Map() };
|
|
8809
|
+
this.conns.set(peerId, conn);
|
|
8810
|
+
pc.onicecandidate = (e) => {
|
|
8811
|
+
if (e.candidate)
|
|
8812
|
+
this.signaler.send(peerId, "ice", e.candidate.toJSON());
|
|
8813
|
+
};
|
|
8814
|
+
pc.onconnectionstatechange = () => {
|
|
8815
|
+
if (["failed", "closed", "disconnected"].includes(pc.connectionState))
|
|
8816
|
+
this.drop(peerId);
|
|
8817
|
+
};
|
|
8818
|
+
pc.ondatachannel = (e) => this.bindChannel(peerId, conn, e.channel);
|
|
8819
|
+
return conn;
|
|
8820
|
+
}
|
|
8821
|
+
async initiate(peerId) {
|
|
8822
|
+
const conn = this.newPc(peerId);
|
|
8823
|
+
const dc = conn.pc.createDataChannel("blocks");
|
|
8824
|
+
this.bindChannel(peerId, conn, dc);
|
|
8825
|
+
const offer = await conn.pc.createOffer();
|
|
8826
|
+
await conn.pc.setLocalDescription(offer);
|
|
8827
|
+
this.signaler.send(peerId, "offer", offer);
|
|
8828
|
+
}
|
|
8829
|
+
bindChannel(peerId, conn, dc) {
|
|
8830
|
+
dc.binaryType = "arraybuffer";
|
|
8831
|
+
conn.dc = dc;
|
|
8832
|
+
dc.onopen = () => this.firePeerChange();
|
|
8833
|
+
dc.onclose = () => this.drop(peerId);
|
|
8834
|
+
dc.onmessage = (e) => this.onChunk(peerId, conn, new Uint8Array(e.data));
|
|
8835
|
+
}
|
|
8836
|
+
async onSignal(from, kind, data) {
|
|
8837
|
+
if (kind === "offer") {
|
|
8838
|
+
if (!this.conns.has(from) && this.conns.size >= this.maxPeers)
|
|
8839
|
+
return; // mesh cap
|
|
8840
|
+
const conn = this.conns.get(from) ?? this.newPc(from);
|
|
8841
|
+
await conn.pc.setRemoteDescription(data);
|
|
8842
|
+
const answer = await conn.pc.createAnswer();
|
|
8843
|
+
await conn.pc.setLocalDescription(answer);
|
|
8844
|
+
this.signaler.send(from, "answer", answer);
|
|
8845
|
+
}
|
|
8846
|
+
else if (kind === "answer") {
|
|
8847
|
+
const conn = this.conns.get(from);
|
|
8848
|
+
if (conn)
|
|
8849
|
+
await conn.pc.setRemoteDescription(data);
|
|
8850
|
+
}
|
|
8851
|
+
else if (kind === "ice") {
|
|
8852
|
+
const conn = this.conns.get(from);
|
|
8853
|
+
if (conn && data) {
|
|
8854
|
+
try {
|
|
8855
|
+
await conn.pc.addIceCandidate(data);
|
|
8856
|
+
}
|
|
8857
|
+
catch { /* pre-SRD race */ }
|
|
8858
|
+
}
|
|
8859
|
+
}
|
|
8860
|
+
else if (kind === "bye") {
|
|
8861
|
+
this.drop(from);
|
|
8862
|
+
}
|
|
8863
|
+
}
|
|
8864
|
+
drop(peerId) {
|
|
8865
|
+
const conn = this.conns.get(peerId);
|
|
8866
|
+
if (!conn)
|
|
8867
|
+
return;
|
|
8868
|
+
this.conns.delete(peerId);
|
|
8869
|
+
try {
|
|
8870
|
+
conn.dc?.close();
|
|
8871
|
+
}
|
|
8872
|
+
catch { /* already closing */ }
|
|
8873
|
+
try {
|
|
8874
|
+
conn.pc.close();
|
|
8875
|
+
}
|
|
8876
|
+
catch { /* already closing */ }
|
|
8877
|
+
this.firePeerChange();
|
|
8878
|
+
}
|
|
8879
|
+
firePeerChange() {
|
|
8880
|
+
const peers = this.peers();
|
|
8881
|
+
for (const cb of this.peerCbs)
|
|
8882
|
+
cb(peers);
|
|
8883
|
+
}
|
|
8884
|
+
// -- fragmentation -------------------------------------------------------
|
|
8885
|
+
async sendFragmented(dc, data, channel) {
|
|
8886
|
+
const msgId = this.nextMsgId++ >>> 0;
|
|
8887
|
+
const total = Math.max(1, Math.ceil(data.length / CHUNK));
|
|
8888
|
+
for (let i = 0; i < total; i++) {
|
|
8889
|
+
const slice = data.subarray(i * CHUNK, (i + 1) * CHUNK);
|
|
8890
|
+
const frame = new Uint8Array(HEADER$1 + slice.length);
|
|
8891
|
+
const dv = new DataView(frame.buffer);
|
|
8892
|
+
dv.setUint32(0, msgId);
|
|
8893
|
+
dv.setUint32(4, i);
|
|
8894
|
+
dv.setUint32(8, total);
|
|
8895
|
+
dv.setUint32(12, channel);
|
|
8896
|
+
frame.set(slice, HEADER$1);
|
|
8897
|
+
if (dc.bufferedAmount > MAX_BUFFERED)
|
|
8898
|
+
await this.drain(dc);
|
|
8899
|
+
dc.send(frame);
|
|
8900
|
+
}
|
|
8901
|
+
}
|
|
8902
|
+
drain(dc) {
|
|
8903
|
+
return new Promise((resolve) => {
|
|
8904
|
+
dc.bufferedAmountLowThreshold = MAX_BUFFERED / 2;
|
|
8905
|
+
const onLow = () => { dc.removeEventListener("bufferedamountlow", onLow); resolve(); };
|
|
8906
|
+
dc.addEventListener("bufferedamountlow", onLow);
|
|
8907
|
+
});
|
|
8908
|
+
}
|
|
8909
|
+
onChunk(peerId, conn, frame) {
|
|
8910
|
+
if (frame.length < HEADER$1)
|
|
8911
|
+
return;
|
|
8912
|
+
const dv = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
|
|
8913
|
+
const msgId = dv.getUint32(0);
|
|
8914
|
+
const index = dv.getUint32(4);
|
|
8915
|
+
const total = dv.getUint32(8);
|
|
8916
|
+
const channel = dv.getUint32(12);
|
|
8917
|
+
const chunk = frame.slice(HEADER$1);
|
|
8918
|
+
let r = conn.reasm.get(msgId);
|
|
8919
|
+
if (!r) {
|
|
8920
|
+
r = { chunks: new Array(total), received: 0, total, size: 0, channel };
|
|
8921
|
+
conn.reasm.set(msgId, r);
|
|
8922
|
+
}
|
|
8923
|
+
if (r.chunks[index])
|
|
8924
|
+
return; // dup
|
|
8925
|
+
r.chunks[index] = chunk;
|
|
8926
|
+
r.received++;
|
|
8927
|
+
r.size += chunk.length;
|
|
8928
|
+
if (r.received < r.total)
|
|
8929
|
+
return;
|
|
8930
|
+
conn.reasm.delete(msgId);
|
|
8931
|
+
const out = new Uint8Array(r.size);
|
|
8932
|
+
let off = 0;
|
|
8933
|
+
for (const c of r.chunks) {
|
|
8934
|
+
if (!c)
|
|
8935
|
+
return; // gap — shouldn't happen on reliable ordered
|
|
8936
|
+
out.set(c, off);
|
|
8937
|
+
off += c.length;
|
|
8938
|
+
}
|
|
8939
|
+
for (const cb of this.msgCbs)
|
|
8940
|
+
cb(peerId, out, r.channel);
|
|
8941
|
+
}
|
|
8942
|
+
}
|
|
8943
|
+
|
|
8944
|
+
/**
|
|
8945
|
+
* `PeerTransport` — the pluggable wire under the block engine. It moves opaque
|
|
8946
|
+
* frames between Space peers; it knows nothing about blocks, hashes, or the
|
|
8947
|
+
* protocol. {@link ./WebRtcTransport} implements it over RTCDataChannels today;
|
|
8948
|
+
* a future libp2p/Helia transport can implement the same interface without the
|
|
8949
|
+
* block engine or `ShardClient` changing.
|
|
8950
|
+
*
|
|
8951
|
+
* Implementations are responsible for fragmenting/ reassembling frames larger
|
|
8952
|
+
* than the underlying channel's max message size (a 4 MiB shard won't fit in one
|
|
8953
|
+
* `RTCDataChannel.send`); the engine always deals in whole frames.
|
|
8954
|
+
*/
|
|
8955
|
+
/**
|
|
8956
|
+
* Logical channels multiplexed over the one data link. `0` is block exchange
|
|
8957
|
+
* (the engine), `1` is gossip (app/CRDT data). The tag rides the framing header
|
|
8958
|
+
* so payloads aren't copied to prepend it.
|
|
8959
|
+
*/
|
|
8960
|
+
const CHANNEL_BLOCK = 0;
|
|
8961
|
+
const CHANNEL_GOSSIP = 1;
|
|
8962
|
+
|
|
8963
|
+
/**
|
|
8964
|
+
* Bitswap-lite wire protocol for peer block exchange. Four frame types over a
|
|
8965
|
+
* {@link ../transport/PeerTransport} data channel:
|
|
8966
|
+
*
|
|
8967
|
+
* WANT(hash) — "does anyone have this block?" / "send it to me"
|
|
8968
|
+
* HAVE(hash) — "I hold this block" (announce / reply to a WANT)
|
|
8969
|
+
* BLOCK(hash, bytes) — the block itself (verified by hash on receipt)
|
|
8970
|
+
* CANCEL(hash) — "stop, I got it elsewhere"
|
|
8971
|
+
*
|
|
8972
|
+
* Frames are compact binary so a 4 MiB shard isn't base64-bloated through JSON:
|
|
8973
|
+
*
|
|
8974
|
+
* byte 0 : type (0 WANT | 1 HAVE | 2 BLOCK | 3 CANCEL)
|
|
8975
|
+
* bytes 1..33 : 32-byte raw SHA-256 of the block (our shard hash, hex-decoded)
|
|
8976
|
+
* bytes 33.. : payload (BLOCK only)
|
|
8977
|
+
*
|
|
8978
|
+
* Blocks are AES-GCM ciphertext, so a frame carries nothing readable; the hash
|
|
8979
|
+
* is the capability check (the receiver re-hashes the payload and drops a
|
|
8980
|
+
* mismatch). See {@link ./blockEngine}.
|
|
8981
|
+
*/
|
|
8982
|
+
var FrameType;
|
|
8983
|
+
(function (FrameType) {
|
|
8984
|
+
FrameType[FrameType["WANT"] = 0] = "WANT";
|
|
8985
|
+
FrameType[FrameType["HAVE"] = 1] = "HAVE";
|
|
8986
|
+
FrameType[FrameType["BLOCK"] = 2] = "BLOCK";
|
|
8987
|
+
FrameType[FrameType["CANCEL"] = 3] = "CANCEL";
|
|
8988
|
+
})(FrameType || (FrameType = {}));
|
|
8989
|
+
const HASH_BYTES = 32;
|
|
8990
|
+
const HEADER = 1 + HASH_BYTES; // type + hash
|
|
8991
|
+
function hexToBytes(hex) {
|
|
8992
|
+
if (hex.length !== HASH_BYTES * 2) {
|
|
8993
|
+
throw new Error(`protocol: expected a ${HASH_BYTES}-byte hex hash, got ${hex.length / 2} bytes`);
|
|
8994
|
+
}
|
|
8995
|
+
const out = new Uint8Array(HASH_BYTES);
|
|
8996
|
+
for (let i = 0; i < HASH_BYTES; i++)
|
|
8997
|
+
out[i] = parseInt(hex.substr(i * 2, 2), 16);
|
|
8998
|
+
return out;
|
|
8999
|
+
}
|
|
9000
|
+
const HEX = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
|
|
9001
|
+
function bytesToHex(bytes) {
|
|
9002
|
+
let s = "";
|
|
9003
|
+
for (let i = 0; i < bytes.length; i++)
|
|
9004
|
+
s += HEX[bytes[i]];
|
|
9005
|
+
return s;
|
|
9006
|
+
}
|
|
9007
|
+
function header(type, hash, extra = 0) {
|
|
9008
|
+
const buf = new Uint8Array(HEADER + extra);
|
|
9009
|
+
buf[0] = type;
|
|
9010
|
+
buf.set(hexToBytes(hash), 1);
|
|
9011
|
+
return { buf, offset: HEADER };
|
|
9012
|
+
}
|
|
9013
|
+
function encodeWant(hash) {
|
|
9014
|
+
return header(FrameType.WANT, hash).buf;
|
|
9015
|
+
}
|
|
9016
|
+
function encodeHave(hash) {
|
|
9017
|
+
return header(FrameType.HAVE, hash).buf;
|
|
9018
|
+
}
|
|
9019
|
+
function encodeCancel(hash) {
|
|
9020
|
+
return header(FrameType.CANCEL, hash).buf;
|
|
9021
|
+
}
|
|
9022
|
+
function encodeBlock(hash, payload) {
|
|
9023
|
+
const { buf, offset } = header(FrameType.BLOCK, hash, payload.length);
|
|
9024
|
+
buf.set(payload, offset);
|
|
9025
|
+
return buf;
|
|
9026
|
+
}
|
|
9027
|
+
function decode(frame) {
|
|
9028
|
+
if (frame.length < HEADER)
|
|
9029
|
+
throw new Error("protocol: frame too short");
|
|
9030
|
+
const type = frame[0];
|
|
9031
|
+
const hash = bytesToHex(frame.subarray(1, HEADER));
|
|
9032
|
+
if (type === FrameType.BLOCK) {
|
|
9033
|
+
// Copy the payload out so it doesn't alias a transferred/pooled buffer.
|
|
9034
|
+
return { type, hash, payload: frame.slice(HEADER) };
|
|
9035
|
+
}
|
|
9036
|
+
return { type, hash };
|
|
9037
|
+
}
|
|
9038
|
+
|
|
9039
|
+
var protocol = /*#__PURE__*/Object.freeze({
|
|
9040
|
+
__proto__: null,
|
|
9041
|
+
get FrameType () { return FrameType; },
|
|
9042
|
+
decode: decode,
|
|
9043
|
+
encodeBlock: encodeBlock,
|
|
9044
|
+
encodeCancel: encodeCancel,
|
|
9045
|
+
encodeHave: encodeHave,
|
|
9046
|
+
encodeWant: encodeWant
|
|
9047
|
+
});
|
|
9048
|
+
|
|
9049
|
+
/**
|
|
9050
|
+
* `BlockEngine` — the bitswap-lite want/have ledger. Host-agnostic: it speaks
|
|
9051
|
+
* only to a {@link BlockStore} (get/put/has) and an `out` sink that ships
|
|
9052
|
+
* encoded frames to a peer (or `"*"` to broadcast). The host wires `out` and
|
|
9053
|
+
* `handleFrame` to a real transport — on the main thread or inside the Web
|
|
9054
|
+
* Worker (see {@link ./engineClient}) — so this class is pure and unit-testable.
|
|
9055
|
+
*
|
|
9056
|
+
* It never sees keys: blocks are AES-GCM ciphertext, addressed by SHA-256. The
|
|
9057
|
+
* one trust check is integrity — an inbound BLOCK is re-hashed and dropped on
|
|
9058
|
+
* mismatch, so a malicious peer can't poison the store (the requester just
|
|
9059
|
+
* falls back to origin).
|
|
9060
|
+
*/
|
|
9061
|
+
class BlockEngine {
|
|
9062
|
+
store;
|
|
9063
|
+
out;
|
|
9064
|
+
hash;
|
|
9065
|
+
wants = new Map();
|
|
9066
|
+
constructor(store, out, hash) {
|
|
9067
|
+
this.store = store;
|
|
9068
|
+
this.out = out;
|
|
9069
|
+
this.hash = hash;
|
|
9070
|
+
}
|
|
9071
|
+
/**
|
|
9072
|
+
* Ask the swarm for a block. Resolves with the verified bytes, or `null` on
|
|
9073
|
+
* timeout (caller then falls back to origin). Returns immediately if we
|
|
9074
|
+
* already hold it; concurrent wants for the same hash share one request.
|
|
9075
|
+
*/
|
|
9076
|
+
want(hash, timeoutMs) {
|
|
9077
|
+
return new Promise((resolve) => {
|
|
9078
|
+
void this.store.get(hash).then((local) => {
|
|
9079
|
+
if (local)
|
|
9080
|
+
return resolve(local);
|
|
9081
|
+
const existing = this.wants.get(hash);
|
|
9082
|
+
if (existing) {
|
|
9083
|
+
existing.resolvers.push(resolve);
|
|
9084
|
+
return;
|
|
9085
|
+
}
|
|
9086
|
+
const timer = setTimeout(() => {
|
|
9087
|
+
const w = this.wants.get(hash);
|
|
9088
|
+
this.wants.delete(hash);
|
|
9089
|
+
this.out("*", encodeCancel(hash));
|
|
9090
|
+
w?.resolvers.forEach((r) => r(null));
|
|
9091
|
+
}, timeoutMs);
|
|
9092
|
+
this.wants.set(hash, { resolvers: [resolve], timer });
|
|
9093
|
+
this.out("*", encodeWant(hash));
|
|
9094
|
+
});
|
|
9095
|
+
});
|
|
9096
|
+
}
|
|
9097
|
+
/** Tell the swarm we now hold `hash` (so peers wanting it can pull from us). */
|
|
9098
|
+
announce(hash) {
|
|
9099
|
+
this.out("*", encodeHave(hash));
|
|
9100
|
+
}
|
|
9101
|
+
/** Handle one inbound frame from `peer`. */
|
|
9102
|
+
async handleFrame(peer, frame) {
|
|
9103
|
+
let f;
|
|
9104
|
+
try {
|
|
9105
|
+
f = decode(frame);
|
|
9106
|
+
}
|
|
9107
|
+
catch {
|
|
9108
|
+
return; // malformed — ignore
|
|
9109
|
+
}
|
|
9110
|
+
switch (f.type) {
|
|
9111
|
+
case FrameType.WANT: {
|
|
9112
|
+
const bytes = await this.store.get(f.hash);
|
|
9113
|
+
if (bytes)
|
|
9114
|
+
this.out(peer, encodeBlock(f.hash, bytes));
|
|
9115
|
+
return;
|
|
9116
|
+
}
|
|
9117
|
+
case FrameType.HAVE: {
|
|
9118
|
+
// A peer announced a block we're still waiting for → pull from them.
|
|
9119
|
+
if (this.wants.has(f.hash))
|
|
9120
|
+
this.out(peer, encodeWant(f.hash));
|
|
9121
|
+
return;
|
|
9122
|
+
}
|
|
9123
|
+
case FrameType.BLOCK: {
|
|
9124
|
+
const pending = this.wants.get(f.hash);
|
|
9125
|
+
if (!pending || !f.payload)
|
|
9126
|
+
return; // unsolicited / empty — ignore
|
|
9127
|
+
// Integrity: a peer can't poison the store. Drop a mismatch and
|
|
9128
|
+
// keep waiting (the timeout will fall back to origin).
|
|
9129
|
+
const got = await this.hash(f.payload);
|
|
9130
|
+
if (got !== f.hash)
|
|
9131
|
+
return;
|
|
9132
|
+
await this.store.put(f.hash, f.payload);
|
|
9133
|
+
this.wants.delete(f.hash);
|
|
9134
|
+
clearTimeout(pending.timer);
|
|
9135
|
+
this.out("*", encodeCancel(f.hash)); // others can stop sending
|
|
9136
|
+
pending.resolvers.forEach((r) => r(f.payload));
|
|
9137
|
+
return;
|
|
9138
|
+
}
|
|
9139
|
+
case FrameType.CANCEL:
|
|
9140
|
+
// We serve WANTs synchronously, so there's nothing in flight to
|
|
9141
|
+
// cancel yet. Reserved for chunked-send cancellation later.
|
|
9142
|
+
return;
|
|
9143
|
+
}
|
|
9144
|
+
}
|
|
9145
|
+
/** Drop all pending waits (resolve null) — on teardown. */
|
|
9146
|
+
close() {
|
|
9147
|
+
for (const [, w] of this.wants) {
|
|
9148
|
+
clearTimeout(w.timer);
|
|
9149
|
+
w.resolvers.forEach((r) => r(null));
|
|
9150
|
+
}
|
|
9151
|
+
this.wants.clear();
|
|
9152
|
+
}
|
|
9153
|
+
}
|
|
9154
|
+
|
|
9155
|
+
/**
|
|
9156
|
+
* `EngineHost` — the boundary {@link ../PeerNetwork} wires a transport to. It
|
|
9157
|
+
* abstracts *where* the {@link ./blockEngine.BlockEngine} runs:
|
|
9158
|
+
* - {@link LocalEngineHost} runs it on the calling thread (tests, or a
|
|
9159
|
+
* fallback when the Web Worker can't be built by a consumer's bundler);
|
|
9160
|
+
* - the Worker proxy ({@link ./engineClient}) runs it off-thread.
|
|
9161
|
+
*
|
|
9162
|
+
* Either way the host exposes the same surface: drive `want`/`announce`, feed it
|
|
9163
|
+
* inbound frames via `handleFrame`, and receive its outbound frames via
|
|
9164
|
+
* `onOutbound` (which the network forwards to the transport).
|
|
9165
|
+
*/
|
|
9166
|
+
/** Runs the engine in-process (main thread or test). */
|
|
9167
|
+
class LocalEngineHost {
|
|
9168
|
+
engine;
|
|
9169
|
+
cbs = new Set();
|
|
9170
|
+
constructor(store, hasher) {
|
|
9171
|
+
this.engine = new BlockEngine(store, (target, frame) => {
|
|
9172
|
+
for (const cb of this.cbs)
|
|
9173
|
+
cb(target, frame);
|
|
9174
|
+
}, hasher);
|
|
9175
|
+
}
|
|
9176
|
+
want(hash, timeoutMs) {
|
|
9177
|
+
return this.engine.want(hash, timeoutMs);
|
|
9178
|
+
}
|
|
9179
|
+
announce(hash) {
|
|
9180
|
+
this.engine.announce(hash);
|
|
9181
|
+
}
|
|
9182
|
+
handleFrame(peer, frame) {
|
|
9183
|
+
void this.engine.handleFrame(peer, frame);
|
|
9184
|
+
}
|
|
9185
|
+
onOutbound(cb) {
|
|
9186
|
+
this.cbs.add(cb);
|
|
9187
|
+
return () => this.cbs.delete(cb);
|
|
9188
|
+
}
|
|
9189
|
+
close() {
|
|
9190
|
+
this.engine.close();
|
|
9191
|
+
this.cbs.clear();
|
|
9192
|
+
}
|
|
9193
|
+
}
|
|
9194
|
+
|
|
9195
|
+
/**
|
|
9196
|
+
* `WorkerEngineHost` — the main-thread proxy to a {@link ./blockEngine.BlockEngine}
|
|
9197
|
+
* running inside a dedicated Web Worker (so hashing, blockstore I/O, and the
|
|
9198
|
+
* protocol codec stay off the main thread). It speaks the small message protocol
|
|
9199
|
+
* in {@link ./blockEngine.worker} and satisfies {@link ./engineHost.EngineHost},
|
|
9200
|
+
* so {@link ../PeerNetwork} treats it identically to the in-process host.
|
|
9201
|
+
*
|
|
9202
|
+
* Block bytes cross the worker boundary as **transferable** ArrayBuffers
|
|
9203
|
+
* (zero-copy). Only encrypted blocks ever cross — keys never enter the worker.
|
|
9204
|
+
*/
|
|
9205
|
+
class WorkerEngineHost {
|
|
9206
|
+
worker;
|
|
9207
|
+
wants = new Map();
|
|
9208
|
+
cbs = new Set();
|
|
9209
|
+
nextId = 1;
|
|
9210
|
+
constructor(worker) {
|
|
9211
|
+
this.worker = worker;
|
|
9212
|
+
this.worker.onmessage = (e) => {
|
|
9213
|
+
const m = e.data;
|
|
9214
|
+
if (m.t === "out") {
|
|
9215
|
+
const frame = new Uint8Array(m.data);
|
|
9216
|
+
for (const cb of this.cbs)
|
|
9217
|
+
cb(m.target, frame);
|
|
9218
|
+
}
|
|
9219
|
+
else if (m.t === "want-result") {
|
|
9220
|
+
const resolve = this.wants.get(m.id);
|
|
9221
|
+
if (resolve) {
|
|
9222
|
+
this.wants.delete(m.id);
|
|
9223
|
+
resolve(m.data ? new Uint8Array(m.data) : null);
|
|
9224
|
+
}
|
|
9225
|
+
}
|
|
9226
|
+
};
|
|
9227
|
+
}
|
|
9228
|
+
want(hash, timeoutMs) {
|
|
9229
|
+
return new Promise((resolve) => {
|
|
9230
|
+
const id = this.nextId++;
|
|
9231
|
+
this.wants.set(id, resolve);
|
|
9232
|
+
this.worker.postMessage({ t: "want", id, hash, timeout: timeoutMs });
|
|
9233
|
+
});
|
|
9234
|
+
}
|
|
9235
|
+
announce(hash) {
|
|
9236
|
+
this.worker.postMessage({ t: "announce", hash });
|
|
9237
|
+
}
|
|
9238
|
+
handleFrame(peer, frame) {
|
|
9239
|
+
// Transfer an exact-size buffer; copy if the view doesn't own the whole buffer.
|
|
9240
|
+
const buf = frame.byteOffset === 0 && frame.byteLength === frame.buffer.byteLength
|
|
9241
|
+
? frame.buffer
|
|
9242
|
+
: frame.slice().buffer;
|
|
9243
|
+
this.worker.postMessage({ t: "frame", peer, data: buf }, [buf]);
|
|
9244
|
+
}
|
|
9245
|
+
onOutbound(cb) {
|
|
9246
|
+
this.cbs.add(cb);
|
|
9247
|
+
return () => this.cbs.delete(cb);
|
|
9248
|
+
}
|
|
9249
|
+
close() {
|
|
9250
|
+
try {
|
|
9251
|
+
this.worker.postMessage({ t: "close" });
|
|
9252
|
+
}
|
|
9253
|
+
catch { /* already gone */ }
|
|
9254
|
+
this.worker.terminate();
|
|
9255
|
+
this.wants.forEach((r) => r(null));
|
|
9256
|
+
this.wants.clear();
|
|
9257
|
+
this.cbs.clear();
|
|
9258
|
+
}
|
|
9259
|
+
}
|
|
9260
|
+
|
|
9261
|
+
/**
|
|
9262
|
+
* `PeerNetwork` — the per-Space orchestrator that ties the pieces together on
|
|
9263
|
+
* the main thread:
|
|
9264
|
+
*
|
|
9265
|
+
* transport (WebRTC) ⇄ engine host (Worker or in-process) → PeerExchange
|
|
9266
|
+
* ▲ │
|
|
9267
|
+
* └────────────── signaling over the Space relay ─────────────┘
|
|
9268
|
+
*
|
|
9269
|
+
* It owns one {@link ./transport/WebRtcTransport} + one {@link
|
|
9270
|
+
* ./worker/engineHost.EngineHost}, wires inbound frames to the engine and the
|
|
9271
|
+
* engine's outbound frames back to the transport, runs peer discovery over the
|
|
9272
|
+
* Space's ephemeral channel, and exposes a {@link ./PeerExchange.PeerExchange}
|
|
9273
|
+
* for `ShardClient` to consult. Scope is one Space (private swarm).
|
|
9274
|
+
*
|
|
9275
|
+
* `engineHost` defaults to in-process; pass `workerFactory` to run the block
|
|
9276
|
+
* engine off the main thread (the consumer's bundler resolves the worker URL).
|
|
9277
|
+
*/
|
|
9278
|
+
/**
|
|
9279
|
+
* Build the engine host, preferring an off-thread Web Worker:
|
|
9280
|
+
* 1. an explicit `workerFactory` (most reliable in app bundlers), else
|
|
9281
|
+
* 2. the bundled worker resolved relative to this module (works where the
|
|
9282
|
+
* bundler supports `new Worker(new URL(...))`, e.g. Vite builds), else
|
|
9283
|
+
* 3. the in-process engine (always works; tests + non-worker runtimes).
|
|
9284
|
+
* Any worker construction failure degrades to in-process.
|
|
9285
|
+
*/
|
|
9286
|
+
function buildEngineHost(opts) {
|
|
9287
|
+
try {
|
|
9288
|
+
const worker = opts.workerFactory ? opts.workerFactory() : defaultWorker();
|
|
9289
|
+
if (worker)
|
|
9290
|
+
return new WorkerEngineHost(worker);
|
|
9291
|
+
}
|
|
9292
|
+
catch {
|
|
9293
|
+
/* no Worker global / bundler can't resolve it → in-process */
|
|
9294
|
+
}
|
|
9295
|
+
return new LocalEngineHost(opts.store ?? new ShardCache(), shardHash);
|
|
9296
|
+
}
|
|
9297
|
+
/** Construct the bundled worker if the runtime + bundler support it, else null. */
|
|
9298
|
+
function defaultWorker() {
|
|
9299
|
+
try {
|
|
9300
|
+
if (typeof Worker === "undefined")
|
|
9301
|
+
return null;
|
|
9302
|
+
// The string is resolved by the consumer's bundler relative to the
|
|
9303
|
+
// built connect module (sibling of dist/browser/index.js).
|
|
9304
|
+
return new Worker(new URL("./blockEngine.worker.js", import.meta.url), { type: "module" });
|
|
9305
|
+
}
|
|
9306
|
+
catch {
|
|
9307
|
+
return null;
|
|
9308
|
+
}
|
|
9309
|
+
}
|
|
9310
|
+
class PeerNetwork {
|
|
9311
|
+
/** Pass this to a `ShardClient` as `peers`. */
|
|
9312
|
+
exchange;
|
|
9313
|
+
transport;
|
|
9314
|
+
host;
|
|
9315
|
+
signaler;
|
|
9316
|
+
offs = [];
|
|
9317
|
+
gossipCbs = new Set();
|
|
9318
|
+
closed = false;
|
|
9319
|
+
constructor(opts) {
|
|
9320
|
+
this.signaler = new SpaceSignaler(opts.space, opts.myId);
|
|
9321
|
+
this.transport =
|
|
9322
|
+
opts.transport ??
|
|
9323
|
+
new WebRtcTransport(this.signaler, opts.myId, {
|
|
9324
|
+
iceServers: opts.iceServers,
|
|
9325
|
+
maxPeers: opts.maxPeers,
|
|
9326
|
+
});
|
|
9327
|
+
this.host = buildEngineHost(opts);
|
|
9328
|
+
// Wire transport ⇄ engine, demuxing block (engine) from gossip (app).
|
|
9329
|
+
this.offs.push(this.transport.onMessage((peer, data, channel) => {
|
|
9330
|
+
if (channel === CHANNEL_GOSSIP) {
|
|
9331
|
+
for (const cb of this.gossipCbs)
|
|
9332
|
+
cb(peer, data);
|
|
9333
|
+
}
|
|
9334
|
+
else {
|
|
9335
|
+
this.host.handleFrame(peer, data);
|
|
9336
|
+
}
|
|
9337
|
+
}));
|
|
9338
|
+
this.offs.push(this.host.onOutbound((target, frame) => target === "*"
|
|
9339
|
+
? this.transport.broadcast(frame, CHANNEL_BLOCK)
|
|
9340
|
+
: this.transport.send(target, frame, CHANNEL_BLOCK)));
|
|
9341
|
+
// Discovery: a broadcast hello triggers a unicast hello back (so a
|
|
9342
|
+
// newcomer learns existing members); both sides connectTo (the smaller
|
|
9343
|
+
// id actually dials). Deterministic + idempotent.
|
|
9344
|
+
this.offs.push(this.signaler.onSignal((from, kind, data) => {
|
|
9345
|
+
if (kind !== "hello")
|
|
9346
|
+
return;
|
|
9347
|
+
this.transport.connectTo?.(from);
|
|
9348
|
+
if (data?.broadcast) {
|
|
9349
|
+
this.signaler.send(from, "hello", { broadcast: false });
|
|
9350
|
+
}
|
|
9351
|
+
}));
|
|
9352
|
+
this.exchange = new PeerExchange(this.host, { timeoutMs: opts.timeoutMs, debug: opts.debug });
|
|
9353
|
+
if (opts.debug) {
|
|
9354
|
+
console.info("[muhkoo:p2p] mesh attached for space; awaiting peers…");
|
|
9355
|
+
this.offs.push(this.transport.onPeerChange((peers) => console.info(`%c[muhkoo:p2p] connected peers: ${peers.length}`, "color:#58da7d", peers)));
|
|
9356
|
+
}
|
|
9357
|
+
}
|
|
9358
|
+
/**
|
|
9359
|
+
* Begin peer discovery — broadcast a hello so existing members dial us and
|
|
9360
|
+
* we learn them. Call once the signaling channel is live (the Space fires
|
|
9361
|
+
* this on CONNECTED); safe to call again on reconnect.
|
|
9362
|
+
*/
|
|
9363
|
+
start() {
|
|
9364
|
+
if (this.closed)
|
|
9365
|
+
return;
|
|
9366
|
+
this.signaler.broadcast("hello", { broadcast: true });
|
|
9367
|
+
}
|
|
9368
|
+
/**
|
|
9369
|
+
* Broadcast an app payload to all connected peers over the gossip channel —
|
|
9370
|
+
* a direct, server-bypassing data path for CRDT ops / high-frequency state
|
|
9371
|
+
* (cursors, presence, collaborative edits). Single-hop (no re-forwarding).
|
|
9372
|
+
*/
|
|
9373
|
+
gossip(data) {
|
|
9374
|
+
this.transport.broadcast(data, CHANNEL_GOSSIP);
|
|
9375
|
+
}
|
|
9376
|
+
/** Subscribe to gossip from peers. Returns an unsubscribe function. */
|
|
9377
|
+
onGossip(cb) {
|
|
9378
|
+
this.gossipCbs.add(cb);
|
|
9379
|
+
return () => this.gossipCbs.delete(cb);
|
|
9380
|
+
}
|
|
9381
|
+
/** Connected peer count (open data channels). */
|
|
9382
|
+
peerCount() {
|
|
9383
|
+
return this.transport.peers().length;
|
|
9384
|
+
}
|
|
9385
|
+
close() {
|
|
9386
|
+
if (this.closed)
|
|
9387
|
+
return;
|
|
9388
|
+
this.closed = true;
|
|
9389
|
+
try {
|
|
9390
|
+
this.signaler.broadcast("bye");
|
|
9391
|
+
}
|
|
9392
|
+
catch { /* socket may be gone */ }
|
|
9393
|
+
for (const off of this.offs)
|
|
9394
|
+
off();
|
|
9395
|
+
this.transport.close();
|
|
9396
|
+
this.host.close();
|
|
9397
|
+
}
|
|
9398
|
+
}
|
|
9399
|
+
|
|
9400
|
+
/**
|
|
9401
|
+
* Whether this environment can do WebRTC peering. `RTCPeerConnection` is
|
|
9402
|
+
* Window-only (absent in Node/Workers/SSR), so this gates whether a
|
|
9403
|
+
* {@link ./PeerNetwork} can be built at all.
|
|
9404
|
+
*/
|
|
9405
|
+
function isP2pCapable() {
|
|
9406
|
+
try {
|
|
9407
|
+
return typeof RTCPeerConnection !== "undefined";
|
|
9408
|
+
}
|
|
9409
|
+
catch {
|
|
9410
|
+
return false;
|
|
9411
|
+
}
|
|
9412
|
+
}
|
|
9413
|
+
|
|
8313
9414
|
/**
|
|
8314
9415
|
* `client.space` — the fan-out group-encryption surface.
|
|
8315
9416
|
*
|
|
@@ -8541,13 +9642,52 @@ class SpaceNamespace {
|
|
|
8541
9642
|
: undefined,
|
|
8542
9643
|
});
|
|
8543
9644
|
this.openSpaces.set(spaceId, space);
|
|
9645
|
+
// Attach a private P2P swarm scoped to this Space (block exchange among
|
|
9646
|
+
// members; signaled over the Space relay). Discovery starts on connect.
|
|
9647
|
+
if (this.deps.p2p?.enabled && isP2pCapable()) {
|
|
9648
|
+
space.attachPeerNetwork(new PeerNetwork({
|
|
9649
|
+
space,
|
|
9650
|
+
myId: memberId,
|
|
9651
|
+
workerFactory: this.deps.p2p.workerFactory,
|
|
9652
|
+
iceServers: this.deps.p2p.iceServers,
|
|
9653
|
+
maxPeers: this.deps.p2p.maxPeers,
|
|
9654
|
+
debug: this.deps.p2p.debug,
|
|
9655
|
+
}));
|
|
9656
|
+
}
|
|
8544
9657
|
return space;
|
|
8545
9658
|
}
|
|
8546
9659
|
/** Ensure the member has an identity keypair; return its public ECDH + ECDSA keys. */
|
|
8547
9660
|
async ensureIdentity(memberId) {
|
|
8548
9661
|
const store = KeyStore.getInstance();
|
|
8549
9662
|
if (!store.getKeyPair(memberId)) {
|
|
8550
|
-
|
|
9663
|
+
// Unlocked (seed in hand): rehydrate the member's STABLE keypair from
|
|
9664
|
+
// the vault, or provision + persist one on first use. This keeps the
|
|
9665
|
+
// member the same identity across reloads — no re-admit each session,
|
|
9666
|
+
// and the group-key cache round-trips. Best-effort: a personal-space
|
|
9667
|
+
// hiccup falls through to an ephemeral keypair (today's behavior).
|
|
9668
|
+
const seed = this.deps.session.seed;
|
|
9669
|
+
const staging = (() => { try {
|
|
9670
|
+
return typeof location !== "undefined" && /(^|\.)staging\./.test(location.hostname);
|
|
9671
|
+
}
|
|
9672
|
+
catch {
|
|
9673
|
+
return false;
|
|
9674
|
+
} })();
|
|
9675
|
+
if (staging)
|
|
9676
|
+
console.info(`[muhkoo:vault] ensureIdentity: unlocked=${!!seed} vault=${!!this.deps.chatKeys}`);
|
|
9677
|
+
if (this.deps.chatKeys && seed) {
|
|
9678
|
+
try {
|
|
9679
|
+
await this.deps.chatKeys.ensure(memberId, toBase64$1(seed));
|
|
9680
|
+
}
|
|
9681
|
+
catch (e) {
|
|
9682
|
+
if (staging)
|
|
9683
|
+
console.info(`[muhkoo:vault] ensureIdentity: vault.ensure failed — ${e instanceof Error ? e.message : e}`);
|
|
9684
|
+
}
|
|
9685
|
+
}
|
|
9686
|
+
if (!store.getKeyPair(memberId)) {
|
|
9687
|
+
if (staging)
|
|
9688
|
+
console.info("[muhkoo:vault] ensureIdentity: EPHEMERAL keypair (locked / no vault) → will re-admit");
|
|
9689
|
+
await store.generateOwnKeyPair(memberId);
|
|
9690
|
+
}
|
|
8551
9691
|
}
|
|
8552
9692
|
const ecdhPub = await exportEcdhPublicKey(store.getKeyPair(memberId).publicKey);
|
|
8553
9693
|
const ecdsaPub = await exportEcdsaPublicKey(store.getAuthKeyPair(memberId).publicKey);
|
|
@@ -8788,6 +9928,112 @@ class FunctionsNamespace {
|
|
|
8788
9928
|
}
|
|
8789
9929
|
}
|
|
8790
9930
|
|
|
9931
|
+
/**
|
|
9932
|
+
* `ChatKeyVault` — SDK-owned lifecycle for a member's long-lived ratchet/space
|
|
9933
|
+
* keypair (the connect `KeyStore` entry keyed by the member id / username).
|
|
9934
|
+
*
|
|
9935
|
+
* Historically each app hand-rolled this (see the web app's old
|
|
9936
|
+
* `personal/spaceLoader.ts`): generate a keypair at register, seed-wrap it, and
|
|
9937
|
+
* PUT it to the personal space; on login GET + unwrap + hydrate the `KeyStore`.
|
|
9938
|
+
* That scaffolding now lives here so ANY app gets a **stable** member keypair
|
|
9939
|
+
* across reloads for free — without which the member re-admits to every space on
|
|
9940
|
+
* every load and the group-key cache can never round-trip.
|
|
9941
|
+
*
|
|
9942
|
+
* Security invariant: the wrapping secret is the **master seed** (not the
|
|
9943
|
+
* password), so the blob survives password changes and unlocks under a passkey.
|
|
9944
|
+
* The seed is only ever held in memory (never persisted), so this vault can only
|
|
9945
|
+
* provision/rehydrate while the client is unlocked.
|
|
9946
|
+
*/
|
|
9947
|
+
/** Staging-only diagnostics: is the member keypair stable across reloads? */
|
|
9948
|
+
const VAULT_DEBUG = (() => {
|
|
9949
|
+
try {
|
|
9950
|
+
return typeof location !== "undefined" && /(^|\.)staging\./.test(location.hostname);
|
|
9951
|
+
}
|
|
9952
|
+
catch {
|
|
9953
|
+
return false;
|
|
9954
|
+
}
|
|
9955
|
+
})();
|
|
9956
|
+
function vlog(msg) {
|
|
9957
|
+
if (VAULT_DEBUG)
|
|
9958
|
+
console.info(`[muhkoo:vault] ${msg}`);
|
|
9959
|
+
}
|
|
9960
|
+
/** A short, stable fingerprint of the member's current keypair (ecdsa pub). */
|
|
9961
|
+
async function fingerprint(memberId) {
|
|
9962
|
+
try {
|
|
9963
|
+
const raw = await KeyStore.getInstance().getRawEcdsaPublicKey(memberId);
|
|
9964
|
+
if (!raw)
|
|
9965
|
+
return "none";
|
|
9966
|
+
let s = 0;
|
|
9967
|
+
for (const b of raw)
|
|
9968
|
+
s = (s * 31 + b) >>> 0;
|
|
9969
|
+
return s.toString(16).padStart(8, "0");
|
|
9970
|
+
}
|
|
9971
|
+
catch {
|
|
9972
|
+
return "err";
|
|
9973
|
+
}
|
|
9974
|
+
}
|
|
9975
|
+
/** Personal-space key holding the seed-wrapped, dehydrated ratchet keypair. */
|
|
9976
|
+
const CHAT_KEYS_KEY = "chat-keys";
|
|
9977
|
+
class ChatKeyVault {
|
|
9978
|
+
store;
|
|
9979
|
+
constructor(store) {
|
|
9980
|
+
this.store = store;
|
|
9981
|
+
}
|
|
9982
|
+
/**
|
|
9983
|
+
* Register-time: ensure a keypair exists for `memberId`, seed-wrap the
|
|
9984
|
+
* dehydrated form, and persist it to the personal space. Idempotent — reuses
|
|
9985
|
+
* an existing in-tab keypair rather than minting a second one.
|
|
9986
|
+
*/
|
|
9987
|
+
async provision(memberId, seedSecret) {
|
|
9988
|
+
const ks = KeyStore.getInstance();
|
|
9989
|
+
if (!ks.getKeyPair(memberId)) {
|
|
9990
|
+
try {
|
|
9991
|
+
await ks.generateOwnKeyPair(memberId);
|
|
9992
|
+
}
|
|
9993
|
+
catch (e) {
|
|
9994
|
+
if (!String(e?.message ?? e).includes("already exists"))
|
|
9995
|
+
throw e;
|
|
9996
|
+
}
|
|
9997
|
+
}
|
|
9998
|
+
const dehydrated = await ks.dehydrateKeyPair(memberId);
|
|
9999
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(dehydrated));
|
|
10000
|
+
await this.store.put(CHAT_KEYS_KEY, await wrapWithPassphrase(seedSecret, plaintext));
|
|
10001
|
+
vlog(`provision: minted+persisted keypair fp=${await fingerprint(memberId)}`);
|
|
10002
|
+
}
|
|
10003
|
+
/**
|
|
10004
|
+
* Sign-in / unlock time: fetch the wrapped blob, unwrap with the seed, and
|
|
10005
|
+
* hydrate the `KeyStore` — restoring the SAME keypair the member had before.
|
|
10006
|
+
* Returns `true` when a keypair is now in hand (already present or hydrated),
|
|
10007
|
+
* `false` when there's no blob yet (a brand-new member → caller provisions).
|
|
10008
|
+
* Throws only on a genuine unwrap failure (wrong secret / tampered blob).
|
|
10009
|
+
*/
|
|
10010
|
+
async rehydrate(memberId, seedSecret) {
|
|
10011
|
+
// Already unlocked in this tab — nothing to do.
|
|
10012
|
+
if (KeyStore.getInstance().getKeyPair(memberId)?.privateKey)
|
|
10013
|
+
return true;
|
|
10014
|
+
const wrapped = await this.store.get(CHAT_KEYS_KEY);
|
|
10015
|
+
if (!wrapped) {
|
|
10016
|
+
vlog("rehydrate: no chat-keys blob (new member → will provision)");
|
|
10017
|
+
return false;
|
|
10018
|
+
}
|
|
10019
|
+
const bytes = await unwrapWithPassphrase(seedSecret, wrapped);
|
|
10020
|
+
const dehydrated = JSON.parse(new TextDecoder().decode(bytes));
|
|
10021
|
+
await KeyStore.getInstance().hydrateKeyPair(memberId, dehydrated);
|
|
10022
|
+
vlog(`rehydrate: HIT — restored stable keypair fp=${await fingerprint(memberId)}`);
|
|
10023
|
+
return true;
|
|
10024
|
+
}
|
|
10025
|
+
/**
|
|
10026
|
+
* Ensure a stable keypair is in hand: rehydrate from the vault if a blob
|
|
10027
|
+
* exists, otherwise mint one and persist it (so it's stable next session).
|
|
10028
|
+
* Used at register and lazily when a space is first opened.
|
|
10029
|
+
*/
|
|
10030
|
+
async ensure(memberId, seedSecret) {
|
|
10031
|
+
if (await this.rehydrate(memberId, seedSecret))
|
|
10032
|
+
return;
|
|
10033
|
+
await this.provision(memberId, seedSecret);
|
|
10034
|
+
}
|
|
10035
|
+
}
|
|
10036
|
+
|
|
8791
10037
|
/**
|
|
8792
10038
|
* `HlcTimestamp` — a Hybrid Logical Clock reading, packed into a single
|
|
8793
10039
|
* lexicographically-sortable string so the whole offline layer (and the
|
|
@@ -9304,82 +10550,6 @@ class SnapshotNamespace {
|
|
|
9304
10550
|
}
|
|
9305
10551
|
}
|
|
9306
10552
|
|
|
9307
|
-
/**
|
|
9308
|
-
* `ShardCache` — a Cache-API-backed store for file-shard bytes. Shards are
|
|
9309
|
-
* content-addressed (the key IS the SHA-256 of the ciphertext), so caching them
|
|
9310
|
-
* is uniquely safe: a cache hit is self-validating, immutable, and shareable
|
|
9311
|
-
* across files. The Cache API (rather than IndexedDB) is the right home for
|
|
9312
|
-
* large binary blobs — it streams, it's purpose-built for response bodies, and
|
|
9313
|
-
* it has its own eviction.
|
|
9314
|
-
*
|
|
9315
|
-
* Keys are SYNTHETIC absolute URLs (`https://shard.muhkoo.local/<hash>`) rather
|
|
9316
|
-
* than the real shard endpoint, so the cache is addressable by hash alone — the
|
|
9317
|
-
* sync engine can re-PUT a deferred shard knowing only its hash, without
|
|
9318
|
-
* reconstructing the accelerator URL. We never fetch these synthetic URLs; they
|
|
9319
|
-
* exist only as cache keys.
|
|
9320
|
-
*
|
|
9321
|
-
* Every method degrades to a no-op / null where the Cache API is unavailable
|
|
9322
|
-
* (Node, Workers, insecure contexts), so callers don't need to branch.
|
|
9323
|
-
*/
|
|
9324
|
-
const CACHE_NAME = "muhkoo-shards-v1";
|
|
9325
|
-
class ShardCache {
|
|
9326
|
-
static available() {
|
|
9327
|
-
try {
|
|
9328
|
-
return typeof caches !== "undefined";
|
|
9329
|
-
}
|
|
9330
|
-
catch {
|
|
9331
|
-
return false;
|
|
9332
|
-
}
|
|
9333
|
-
}
|
|
9334
|
-
key(hash) {
|
|
9335
|
-
return `https://shard.muhkoo.local/${encodeURIComponent(hash)}`;
|
|
9336
|
-
}
|
|
9337
|
-
open() {
|
|
9338
|
-
return caches.open(CACHE_NAME);
|
|
9339
|
-
}
|
|
9340
|
-
/** Cached shard bytes, or `null` on miss / when unavailable. */
|
|
9341
|
-
async get(hash) {
|
|
9342
|
-
if (!ShardCache.available())
|
|
9343
|
-
return null;
|
|
9344
|
-
try {
|
|
9345
|
-
const cache = await this.open();
|
|
9346
|
-
const res = await cache.match(this.key(hash));
|
|
9347
|
-
if (!res)
|
|
9348
|
-
return null;
|
|
9349
|
-
return new Uint8Array(await res.arrayBuffer());
|
|
9350
|
-
}
|
|
9351
|
-
catch {
|
|
9352
|
-
return null;
|
|
9353
|
-
}
|
|
9354
|
-
}
|
|
9355
|
-
/** Store shard bytes under their hash. Best-effort. */
|
|
9356
|
-
async put(hash, bytes) {
|
|
9357
|
-
if (!ShardCache.available())
|
|
9358
|
-
return;
|
|
9359
|
-
try {
|
|
9360
|
-
const cache = await this.open();
|
|
9361
|
-
await cache.put(this.key(hash), new Response(bytes, {
|
|
9362
|
-
headers: { "content-type": "application/octet-stream" },
|
|
9363
|
-
}));
|
|
9364
|
-
}
|
|
9365
|
-
catch {
|
|
9366
|
-
/* quota / unavailable — degrade to online-only for this shard */
|
|
9367
|
-
}
|
|
9368
|
-
}
|
|
9369
|
-
/** Whether a shard is present in the cache. */
|
|
9370
|
-
async has(hash) {
|
|
9371
|
-
if (!ShardCache.available())
|
|
9372
|
-
return false;
|
|
9373
|
-
try {
|
|
9374
|
-
const cache = await this.open();
|
|
9375
|
-
return Boolean(await cache.match(this.key(hash)));
|
|
9376
|
-
}
|
|
9377
|
-
catch {
|
|
9378
|
-
return false;
|
|
9379
|
-
}
|
|
9380
|
-
}
|
|
9381
|
-
}
|
|
9382
|
-
|
|
9383
10553
|
/**
|
|
9384
10554
|
* IndexedDB schema for the offline layer — the single source of truth for the
|
|
9385
10555
|
* database name, version, and object stores. Bump {@link DB_VERSION} whenever
|
|
@@ -10091,17 +11261,46 @@ class Client {
|
|
|
10091
11261
|
this.message = new MessageNamespace({ http: this.http, session: this.session, wsBaseUrl });
|
|
10092
11262
|
// Group keys are cached in the user's PersonalSpace (encrypted at rest
|
|
10093
11263
|
// by client.kv), so a returning member hydrates without a fresh keyring
|
|
10094
|
-
// round-trip. The server only ever sees
|
|
11264
|
+
// round-trip. The server only ever sees ciphertext.
|
|
11265
|
+
//
|
|
11266
|
+
// `encrypt: false` is deliberate: the keyring already ECIES-wraps each
|
|
11267
|
+
// key to the member's identity (server-blind), and StorageCipher would
|
|
11268
|
+
// additionally require the FULL unlocked identity (`requireIdentity()`),
|
|
11269
|
+
// which is locked after a page reload — that coupling is exactly why the
|
|
11270
|
+
// cache previously never read or persisted. The wrapped blobs are opaque.
|
|
10095
11271
|
const spaceKeyCache = {
|
|
10096
11272
|
loadKeys: (spaceId) => this.kv.get("space-keys", spaceId),
|
|
10097
|
-
saveKeys: (spaceId, keys) => this.kv.set("space-keys", spaceId, keys),
|
|
11273
|
+
saveKeys: (spaceId, keys) => this.kv.set("space-keys", spaceId, keys, { encrypt: false }),
|
|
11274
|
+
};
|
|
11275
|
+
// SDK-owned vault for the member's long-lived ratchet/space keypair.
|
|
11276
|
+
// Stored at the personal-space `chat-keys` key (same key the app used to
|
|
11277
|
+
// manage by hand), seed-wrapped, session-token authed (no snarkjs). This
|
|
11278
|
+
// gives every app a STABLE keypair across reloads — no per-app scaffolding
|
|
11279
|
+
// — so members don't re-admit each load and the group-key cache works.
|
|
11280
|
+
const chatKeyStore = {
|
|
11281
|
+
get: async (key) => {
|
|
11282
|
+
const c = this.session.commitment;
|
|
11283
|
+
if (!c)
|
|
11284
|
+
return null;
|
|
11285
|
+
const res = await this.http.post(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}/get`, {});
|
|
11286
|
+
return (res?.value ?? null);
|
|
11287
|
+
},
|
|
11288
|
+
put: async (key, value) => {
|
|
11289
|
+
const c = this.session.commitment;
|
|
11290
|
+
if (!c)
|
|
11291
|
+
throw new Error("ChatKeyVault: no session — sign in first.");
|
|
11292
|
+
await this.http.post(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}`, { value });
|
|
11293
|
+
},
|
|
10098
11294
|
};
|
|
11295
|
+
const chatKeys = new ChatKeyVault(chatKeyStore);
|
|
10099
11296
|
this.space = new SpaceNamespace({
|
|
10100
11297
|
http: this.http,
|
|
10101
11298
|
session: this.session,
|
|
10102
11299
|
wsBaseUrl,
|
|
10103
11300
|
cache: spaceKeyCache,
|
|
10104
11301
|
offline: this.offline,
|
|
11302
|
+
p2p: options.p2p,
|
|
11303
|
+
chatKeys,
|
|
10105
11304
|
});
|
|
10106
11305
|
this.agents = new AgentsNamespace({ http: this.http });
|
|
10107
11306
|
this.functions = new FunctionsNamespace({ http: this.http, fnHostSuffix: options.functionsHostSuffix });
|
|
@@ -10523,5 +11722,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
|
|
|
10523
11722
|
default: loadRsWasm
|
|
10524
11723
|
});
|
|
10525
11724
|
|
|
10526
|
-
export { AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, ConnectivityManager, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DbCache, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HttpClient, HttpError, IndexedDbStore, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvCache, KvNamespace, LocalStorageSessionStore, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PersonalSpaceClient, Poseidon, PreimagePoK, ReedSolomonCodec, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, StorageNamespace, SyncEngine, VaultUnavailableError, WSTransport, ZERO_HLC, ZkAuth, buildCommitment, compareHlc, compilePrograms, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, generateAuthProof, generatePkce, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isOfflineCapable, isProvisional, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, poseidonHash, provisionalHandle, quickVerify, randomBytes, randomState, sealSeed, shardHash, signMessage, stableStringify, toField, tombstoneRow, unpack, unsealSeed, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
|
|
11725
|
+
export { AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, ConnectivityManager, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DbCache, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HttpClient, HttpError, IndexedDbStore, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, ReedSolomonCodec, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VaultUnavailableError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, buildCommitment, compareHlc, compilePrograms, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, generateAuthProof, generatePkce, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isOfflineCapable, isP2pCapable, isProvisional, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, sealSeed, shardHash, signMessage, stableStringify, toField, tombstoneRow, unpack, unsealSeed, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
|
|
10527
11726
|
//# sourceMappingURL=index.js.map
|