@muhkoo/connect 0.7.0-alpha.5 → 0.9.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser/blockEngine.worker.js +349 -0
- package/dist/browser/blockEngine.worker.js.map +1 -0
- package/dist/browser/index.js +3161 -422
- package/dist/browser/index.js.map +1 -1
- package/dist/connect.d.ts +1337 -5
- package/dist/server/index.js +3163 -424
- package/dist/server/index.js.map +1 -1
- package/dist/workers/index.js +8 -0
- package/dist/workers/index.js.map +1 -1
- package/package.json +6 -2
package/dist/server/index.js
CHANGED
|
@@ -2130,6 +2130,14 @@ var EventCoreEvents;
|
|
|
2130
2130
|
EventCoreEvents["MESSAGE"] = "message";
|
|
2131
2131
|
EventCoreEvents["GET_HISTORY"] = "get_history";
|
|
2132
2132
|
EventCoreEvents["RECEIVED_HISTORY"] = "received_history";
|
|
2133
|
+
// Offline layer — connectivity + background sync lifecycle. Emitted by the
|
|
2134
|
+
// ConnectivityManager / SyncEngine; apps subscribe to drive a status UI.
|
|
2135
|
+
EventCoreEvents["ONLINE"] = "online";
|
|
2136
|
+
EventCoreEvents["OFFLINE"] = "offline";
|
|
2137
|
+
EventCoreEvents["SYNCING"] = "syncing";
|
|
2138
|
+
EventCoreEvents["SYNC_PROGRESS"] = "sync_progress";
|
|
2139
|
+
EventCoreEvents["SYNC_COMPLETE"] = "sync_complete";
|
|
2140
|
+
EventCoreEvents["SYNC_ERROR"] = "sync_error";
|
|
2133
2141
|
})(EventCoreEvents || (EventCoreEvents = {}));
|
|
2134
2142
|
/**
|
|
2135
2143
|
* @public
|
|
@@ -2968,7 +2976,7 @@ function base64UrlDecode(s) {
|
|
|
2968
2976
|
* expect (and isn't serving a stale cached bundle). Keep this in sync with
|
|
2969
2977
|
* `package.json` `version`.
|
|
2970
2978
|
*/
|
|
2971
|
-
const VERSION = "0.
|
|
2979
|
+
const VERSION = "0.9.0-alpha.0";
|
|
2972
2980
|
|
|
2973
2981
|
/**
|
|
2974
2982
|
* `HttpClient` — the one place the unified {@link Client} talks to the
|
|
@@ -4630,30 +4638,104 @@ class KvNamespace {
|
|
|
4630
4638
|
cipherIdentity = null;
|
|
4631
4639
|
constructor(deps) {
|
|
4632
4640
|
this.deps = deps;
|
|
4641
|
+
// Replay queued kv writes on reconnect by re-issuing the original request.
|
|
4642
|
+
if (deps.offline?.enabled) ;
|
|
4643
|
+
}
|
|
4644
|
+
/** Replay one queued kv mutation (called by the sync engine). */
|
|
4645
|
+
async replay(entry) {
|
|
4646
|
+
const a = entry.args;
|
|
4647
|
+
if (entry.method === "set") {
|
|
4648
|
+
await this.deps.http.post(this.kvPath(a.collection, a.id), { value: a.stored, hlc: a.hlc });
|
|
4649
|
+
}
|
|
4650
|
+
else {
|
|
4651
|
+
await this.deps.http.del(this.kvPath(a.collection, a.id), { hlc: a.hlc });
|
|
4652
|
+
}
|
|
4633
4653
|
}
|
|
4634
4654
|
/** Store `value` under `collection`/`id` (encrypted at rest by default). */
|
|
4635
4655
|
async set(collection, id, value, opts) {
|
|
4636
4656
|
const encrypt = opts?.encrypt !== false;
|
|
4637
4657
|
const stored = encrypt ? await this.getCipher().encrypt(value) : value;
|
|
4638
|
-
|
|
4658
|
+
const offline = this.deps.offline;
|
|
4659
|
+
if (!offline?.enabled) {
|
|
4660
|
+
await this.deps.http.post(this.kvPath(collection, id), { value: stored });
|
|
4661
|
+
return;
|
|
4662
|
+
}
|
|
4663
|
+
const fullKey = `${collection}/${id}`;
|
|
4664
|
+
const hlc = await offline.nextHlc();
|
|
4665
|
+
await offline.writeLocal(this.commitment(), fullKey, stored, hlc);
|
|
4666
|
+
try {
|
|
4667
|
+
await this.deps.http.post(this.kvPath(collection, id), { value: stored, hlc });
|
|
4668
|
+
}
|
|
4669
|
+
catch (err) {
|
|
4670
|
+
if (err instanceof HttpError)
|
|
4671
|
+
throw err; // server reachable + refused
|
|
4672
|
+
await offline.enqueue("set", { collection, id, stored, hlc }, hlc, offline.newClientId());
|
|
4673
|
+
}
|
|
4639
4674
|
}
|
|
4640
4675
|
/** Fetch the value at `collection`/`id`, or `null` if absent. */
|
|
4641
4676
|
async get(collection, id) {
|
|
4642
|
-
const
|
|
4643
|
-
|
|
4677
|
+
const offline = this.deps.offline;
|
|
4678
|
+
if (!offline?.enabled) {
|
|
4679
|
+
const res = await this.deps.http.post(`${this.kvPath(collection, id)}/get`, {});
|
|
4680
|
+
return this.decode(res.value);
|
|
4681
|
+
}
|
|
4682
|
+
const fullKey = `${collection}/${id}`;
|
|
4683
|
+
try {
|
|
4684
|
+
const res = await this.deps.http.post(`${this.kvPath(collection, id)}/get`, {});
|
|
4685
|
+
// Refresh the cache from the authoritative read (merge by HLC).
|
|
4686
|
+
await offline.mergeRemote(this.commitment(), fullKey, res.value ?? null, res.value == null ? "delete" : "set", res.hlc);
|
|
4687
|
+
return this.decode(res.value);
|
|
4688
|
+
}
|
|
4689
|
+
catch (err) {
|
|
4690
|
+
if (err instanceof HttpError)
|
|
4691
|
+
throw err; // a real server error, not offline
|
|
4692
|
+
const entry = await offline.read(this.commitment(), fullKey);
|
|
4693
|
+
if (!entry || entry.deleted)
|
|
4694
|
+
return null;
|
|
4695
|
+
return this.decode(entry.value);
|
|
4696
|
+
}
|
|
4644
4697
|
}
|
|
4645
4698
|
/** Delete `collection`/`id`. Returns whether the key existed. */
|
|
4646
4699
|
async delete(collection, id) {
|
|
4647
|
-
const
|
|
4648
|
-
|
|
4700
|
+
const offline = this.deps.offline;
|
|
4701
|
+
if (!offline?.enabled) {
|
|
4702
|
+
const res = await this.deps.http.del(this.kvPath(collection, id), {});
|
|
4703
|
+
return Boolean(res.existed);
|
|
4704
|
+
}
|
|
4705
|
+
const fullKey = `${collection}/${id}`;
|
|
4706
|
+
const hlc = await offline.nextHlc();
|
|
4707
|
+
await offline.deleteLocal(this.commitment(), fullKey, hlc);
|
|
4708
|
+
try {
|
|
4709
|
+
const res = await this.deps.http.del(this.kvPath(collection, id), { hlc });
|
|
4710
|
+
return Boolean(res.existed);
|
|
4711
|
+
}
|
|
4712
|
+
catch (err) {
|
|
4713
|
+
if (err instanceof HttpError)
|
|
4714
|
+
throw err;
|
|
4715
|
+
await offline.enqueue("delete", { collection, id, hlc }, hlc, offline.newClientId());
|
|
4716
|
+
return true; // optimistic
|
|
4717
|
+
}
|
|
4649
4718
|
}
|
|
4650
4719
|
/** List the ids present in `collection`. */
|
|
4651
4720
|
async list(collection) {
|
|
4652
|
-
const res = await this.deps.http.post(this.spacePath("/list"), {});
|
|
4653
4721
|
const prefix = `${collection}/`;
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
|
|
4722
|
+
const offline = this.deps.offline;
|
|
4723
|
+
const fromKeys = (keys) => keys.filter((k) => k.startsWith(prefix)).map((k) => k.slice(prefix.length));
|
|
4724
|
+
if (!offline?.enabled) {
|
|
4725
|
+
const res = await this.deps.http.post(this.spacePath("/list"), {});
|
|
4726
|
+
return fromKeys(res.keys ?? []);
|
|
4727
|
+
}
|
|
4728
|
+
try {
|
|
4729
|
+
const res = await this.deps.http.post(this.spacePath("/list"), {});
|
|
4730
|
+
// Union with cached live keys so un-synced offline creates still show.
|
|
4731
|
+
const cached = await offline.liveKeys(this.commitment());
|
|
4732
|
+
return [...new Set([...(res.keys ?? []), ...cached].filter((k) => k.startsWith(prefix)))].map((k) => k.slice(prefix.length));
|
|
4733
|
+
}
|
|
4734
|
+
catch (err) {
|
|
4735
|
+
if (err instanceof HttpError)
|
|
4736
|
+
throw err;
|
|
4737
|
+
return fromKeys(await offline.liveKeys(this.commitment()));
|
|
4738
|
+
}
|
|
4657
4739
|
}
|
|
4658
4740
|
/** Reserved — server-side query is deferred (see the overhaul plan). */
|
|
4659
4741
|
async query(_collection, _filter) {
|
|
@@ -4712,6 +4794,10 @@ class KvNamespace {
|
|
|
4712
4794
|
return;
|
|
4713
4795
|
const collection = frame.key.slice(0, slash);
|
|
4714
4796
|
const id = frame.key.slice(slash + 1);
|
|
4797
|
+
// Keep the offline cache warm from the realtime feed (merge by HLC).
|
|
4798
|
+
if (this.deps.offline?.enabled) {
|
|
4799
|
+
await this.deps.offline.mergeRemote(this.commitment(), frame.key, frame.op === "set" ? frame.value ?? null : null, frame.op, frame.hlc);
|
|
4800
|
+
}
|
|
4715
4801
|
const data = frame.op === "set" ? await this.decode(frame.value) : null;
|
|
4716
4802
|
handler({ collection, id, type: frame.op, data });
|
|
4717
4803
|
}
|
|
@@ -4779,41 +4865,109 @@ class KvNamespace {
|
|
|
4779
4865
|
class DbTable {
|
|
4780
4866
|
http;
|
|
4781
4867
|
name;
|
|
4782
|
-
|
|
4868
|
+
offline;
|
|
4869
|
+
constructor(http, name, offline) {
|
|
4783
4870
|
this.http = http;
|
|
4784
4871
|
this.name = name;
|
|
4872
|
+
this.offline = offline;
|
|
4785
4873
|
}
|
|
4786
4874
|
base() {
|
|
4787
4875
|
return `/api/db/${encodeURIComponent(this.name)}`;
|
|
4788
4876
|
}
|
|
4877
|
+
rowUrl(id) {
|
|
4878
|
+
return `${this.base()}/${encodeURIComponent(String(id))}`;
|
|
4879
|
+
}
|
|
4789
4880
|
/** Insert a row. Returns the created row and its primary-key value. */
|
|
4790
4881
|
async insert(values) {
|
|
4791
|
-
|
|
4882
|
+
if (!this.offline?.enabled)
|
|
4883
|
+
return this.http.post(this.base(), { values });
|
|
4884
|
+
const hlc = await this.offline.nextHlc();
|
|
4885
|
+
const _hlc = this.offline.columnStamps(values, hlc);
|
|
4886
|
+
try {
|
|
4887
|
+
const res = await this.http.post(this.base(), { values, _hlc });
|
|
4888
|
+
await this.offline.writeRow(this.name, String(res.id), res.row);
|
|
4889
|
+
return res;
|
|
4890
|
+
}
|
|
4891
|
+
catch (err) {
|
|
4892
|
+
if (err instanceof HttpError)
|
|
4893
|
+
throw err;
|
|
4894
|
+
// Offline: queue for replay. No server pk yet — return a provisional
|
|
4895
|
+
// client id so the caller has a stable handle until sync.
|
|
4896
|
+
const clientId = this.offline.newClientId();
|
|
4897
|
+
await this.offline.enqueue("insert", { table: this.name, values, _hlc }, hlc, clientId);
|
|
4898
|
+
return { row: values, id: clientId };
|
|
4899
|
+
}
|
|
4792
4900
|
}
|
|
4793
4901
|
/** Fetch a row by primary key, or `null` if it doesn't exist. */
|
|
4794
4902
|
async get(id) {
|
|
4795
4903
|
try {
|
|
4796
|
-
const res = await this.http.get(
|
|
4904
|
+
const res = await this.http.get(this.rowUrl(id));
|
|
4905
|
+
if (this.offline?.enabled)
|
|
4906
|
+
await this.offline.writeRow(this.name, String(id), res.row);
|
|
4797
4907
|
return res.row;
|
|
4798
4908
|
}
|
|
4799
4909
|
catch (e) {
|
|
4800
4910
|
if (e?.status === 404)
|
|
4801
4911
|
return null;
|
|
4912
|
+
if (this.offline?.enabled && !(e instanceof HttpError)) {
|
|
4913
|
+
return (await this.offline.readRow(this.name, String(id)));
|
|
4914
|
+
}
|
|
4802
4915
|
throw e;
|
|
4803
4916
|
}
|
|
4804
4917
|
}
|
|
4805
4918
|
/** Query rows with filters, ordering, and keyset pagination. */
|
|
4806
4919
|
async query(query = {}) {
|
|
4807
|
-
|
|
4920
|
+
if (!this.offline?.enabled)
|
|
4921
|
+
return this.http.post(`${this.base()}/query`, query);
|
|
4922
|
+
try {
|
|
4923
|
+
const res = await this.http.post(`${this.base()}/query`, query);
|
|
4924
|
+
await this.offline.writeQuery(this.name, query, res);
|
|
4925
|
+
return res;
|
|
4926
|
+
}
|
|
4927
|
+
catch (e) {
|
|
4928
|
+
if (e instanceof HttpError)
|
|
4929
|
+
throw e;
|
|
4930
|
+
const cached = await this.offline.readQuery(this.name, query);
|
|
4931
|
+
return cached ?? { rows: [], nextCursor: null };
|
|
4932
|
+
}
|
|
4808
4933
|
}
|
|
4809
4934
|
/** Update a row by primary key. Returns the updated row. */
|
|
4810
4935
|
async update(id, values) {
|
|
4811
|
-
|
|
4936
|
+
if (!this.offline?.enabled)
|
|
4937
|
+
return this.http.patch(this.rowUrl(id), { values });
|
|
4938
|
+
const hlc = await this.offline.nextHlc();
|
|
4939
|
+
const _hlc = this.offline.columnStamps(values, hlc);
|
|
4940
|
+
await this.offline.patchRow(this.name, String(id), values); // optimistic
|
|
4941
|
+
try {
|
|
4942
|
+
const res = await this.http.patch(this.rowUrl(id), { values, _hlc });
|
|
4943
|
+
await this.offline.writeRow(this.name, String(id), res.row);
|
|
4944
|
+
return res;
|
|
4945
|
+
}
|
|
4946
|
+
catch (err) {
|
|
4947
|
+
if (err instanceof HttpError)
|
|
4948
|
+
throw err;
|
|
4949
|
+
await this.offline.enqueue("update", { table: this.name, id, values, _hlc }, hlc, this.offline.newClientId());
|
|
4950
|
+
return { row: (await this.offline.readRow(this.name, String(id))) };
|
|
4951
|
+
}
|
|
4812
4952
|
}
|
|
4813
4953
|
/** Delete a row by primary key. Returns the number of rows removed (0 or 1). */
|
|
4814
4954
|
async delete(id) {
|
|
4815
|
-
|
|
4816
|
-
|
|
4955
|
+
if (!this.offline?.enabled) {
|
|
4956
|
+
const res = await this.http.del(this.rowUrl(id));
|
|
4957
|
+
return res.deleted ?? 0;
|
|
4958
|
+
}
|
|
4959
|
+
const hlc = await this.offline.nextHlc();
|
|
4960
|
+
await this.offline.deleteRow(this.name, String(id)); // optimistic
|
|
4961
|
+
try {
|
|
4962
|
+
const res = await this.http.del(this.rowUrl(id), { _hlc: hlc });
|
|
4963
|
+
return res.deleted ?? 0;
|
|
4964
|
+
}
|
|
4965
|
+
catch (err) {
|
|
4966
|
+
if (err instanceof HttpError)
|
|
4967
|
+
throw err;
|
|
4968
|
+
await this.offline.enqueue("delete", { table: this.name, id, hlc }, hlc, this.offline.newClientId());
|
|
4969
|
+
return 1; // optimistic
|
|
4970
|
+
}
|
|
4817
4971
|
}
|
|
4818
4972
|
}
|
|
4819
4973
|
class DbNamespace {
|
|
@@ -4829,7 +4983,21 @@ class DbNamespace {
|
|
|
4829
4983
|
* const todos = client.db.table<Todo>('todos');
|
|
4830
4984
|
*/
|
|
4831
4985
|
table(name) {
|
|
4832
|
-
return new DbTable(this.deps.http, name);
|
|
4986
|
+
return new DbTable(this.deps.http, name, this.deps.offline);
|
|
4987
|
+
}
|
|
4988
|
+
/** Replay one queued db mutation (called by the sync engine). */
|
|
4989
|
+
async replay(entry) {
|
|
4990
|
+
const a = entry.args;
|
|
4991
|
+
const base = `/api/db/${encodeURIComponent(a.table)}`;
|
|
4992
|
+
if (entry.method === "insert") {
|
|
4993
|
+
await this.deps.http.post(base, { values: a.values, _hlc: a._hlc });
|
|
4994
|
+
}
|
|
4995
|
+
else if (entry.method === "update") {
|
|
4996
|
+
await this.deps.http.patch(`${base}/${encodeURIComponent(String(a.id))}`, { values: a.values, _hlc: a._hlc });
|
|
4997
|
+
}
|
|
4998
|
+
else if (entry.method === "delete") {
|
|
4999
|
+
await this.deps.http.del(`${base}/${encodeURIComponent(String(a.id))}`, { _hlc: a.hlc });
|
|
5000
|
+
}
|
|
4833
5001
|
}
|
|
4834
5002
|
}
|
|
4835
5003
|
|
|
@@ -5293,6 +5461,9 @@ class ShardClient {
|
|
|
5293
5461
|
baseUrl;
|
|
5294
5462
|
pathPrefix;
|
|
5295
5463
|
fetchFn;
|
|
5464
|
+
cache;
|
|
5465
|
+
deferUpload;
|
|
5466
|
+
peers;
|
|
5296
5467
|
constructor(opts) {
|
|
5297
5468
|
if (!opts?.baseUrl)
|
|
5298
5469
|
throw new Error("ShardClient: `baseUrl` is required");
|
|
@@ -5308,6 +5479,9 @@ class ShardClient {
|
|
|
5308
5479
|
throw new Error("ShardClient: `globalThis.fetch` is unavailable; pass an explicit fetch.");
|
|
5309
5480
|
}
|
|
5310
5481
|
this.fetchFn = f.bind(globalThis);
|
|
5482
|
+
this.cache = opts.cache;
|
|
5483
|
+
this.deferUpload = opts.deferUpload;
|
|
5484
|
+
this.peers = opts.peers;
|
|
5311
5485
|
}
|
|
5312
5486
|
/** Build the URL for a given shard hash — exposed for callers that want to log or pre-resolve. */
|
|
5313
5487
|
urlForHash(hash) {
|
|
@@ -5321,12 +5495,32 @@ class ShardClient {
|
|
|
5321
5495
|
*/
|
|
5322
5496
|
async putShard(hash, bytes) {
|
|
5323
5497
|
const url = this.urlForHash(hash);
|
|
5324
|
-
|
|
5325
|
-
|
|
5326
|
-
|
|
5327
|
-
|
|
5328
|
-
|
|
5329
|
-
|
|
5498
|
+
// Write the cache first so a just-uploaded file reads back even if the
|
|
5499
|
+
// network PUT then fails (offline). The bytes are content-addressed, so
|
|
5500
|
+
// this is safe to keep regardless of upload outcome.
|
|
5501
|
+
if (this.cache)
|
|
5502
|
+
await this.cache.put(hash, bytes);
|
|
5503
|
+
// Tell the swarm we now hold this block, so peers can pull it from us.
|
|
5504
|
+
if (this.peers)
|
|
5505
|
+
this.peers.announce(hash);
|
|
5506
|
+
let response;
|
|
5507
|
+
try {
|
|
5508
|
+
response = await this.fetchFn(url, {
|
|
5509
|
+
method: "PUT",
|
|
5510
|
+
headers: { "content-type": "application/octet-stream" },
|
|
5511
|
+
// BufferSource is what fetch's body expects; a Uint8Array satisfies it.
|
|
5512
|
+
body: bytes,
|
|
5513
|
+
});
|
|
5514
|
+
}
|
|
5515
|
+
catch (err) {
|
|
5516
|
+
// Network unreachable (offline). If a defer hook is wired, queue the
|
|
5517
|
+
// upload for replay and report success — the bytes are cached.
|
|
5518
|
+
if (this.deferUpload) {
|
|
5519
|
+
await this.deferUpload(hash);
|
|
5520
|
+
return { dedup: false };
|
|
5521
|
+
}
|
|
5522
|
+
throw err;
|
|
5523
|
+
}
|
|
5330
5524
|
if (!response.ok) {
|
|
5331
5525
|
const text = await safeText(response);
|
|
5332
5526
|
throw new Error(`ShardClient.putShard(${hash}) failed (${response.status}): ${text}`);
|
|
@@ -5346,8 +5540,41 @@ class ShardClient {
|
|
|
5346
5540
|
* `null` for 404 so callers can distinguish "missing" from "errored".
|
|
5347
5541
|
*/
|
|
5348
5542
|
async getShard(hash) {
|
|
5543
|
+
// Cache-first: a hit is content-verified by construction (key == hash).
|
|
5544
|
+
if (this.cache) {
|
|
5545
|
+
const cached = await this.cache.get(hash);
|
|
5546
|
+
if (cached)
|
|
5547
|
+
return cached;
|
|
5548
|
+
}
|
|
5549
|
+
// Peers next: ask the Space swarm before spending origin egress. Peer
|
|
5550
|
+
// blocks are hash-verified by the block engine; on miss/timeout we fall
|
|
5551
|
+
// through to origin. Best-effort — never let a peer error block a read.
|
|
5552
|
+
if (this.peers) {
|
|
5553
|
+
try {
|
|
5554
|
+
const fromPeer = await this.peers.getBlock(hash);
|
|
5555
|
+
if (fromPeer) {
|
|
5556
|
+
if (this.cache)
|
|
5557
|
+
void this.cache.put(hash, fromPeer);
|
|
5558
|
+
return fromPeer;
|
|
5559
|
+
}
|
|
5560
|
+
}
|
|
5561
|
+
catch {
|
|
5562
|
+
/* peer layer hiccup — fall through to origin */
|
|
5563
|
+
}
|
|
5564
|
+
}
|
|
5349
5565
|
const url = this.urlForHash(hash);
|
|
5350
|
-
|
|
5566
|
+
let response;
|
|
5567
|
+
try {
|
|
5568
|
+
response = await this.fetchFn(url, { method: "GET" });
|
|
5569
|
+
}
|
|
5570
|
+
catch (err) {
|
|
5571
|
+
// Offline with no cache hit: report the shard as missing so
|
|
5572
|
+
// FileStorage's Reed–Solomon recovery can still reconstruct from the
|
|
5573
|
+
// shards that ARE cached. Rethrow when there's no cache to fall back on.
|
|
5574
|
+
if (this.cache)
|
|
5575
|
+
return null;
|
|
5576
|
+
throw err;
|
|
5577
|
+
}
|
|
5351
5578
|
if (response.status === 404)
|
|
5352
5579
|
return null;
|
|
5353
5580
|
if (!response.ok) {
|
|
@@ -5355,7 +5582,10 @@ class ShardClient {
|
|
|
5355
5582
|
throw new Error(`ShardClient.getShard(${hash}) failed (${response.status}): ${text}`);
|
|
5356
5583
|
}
|
|
5357
5584
|
const buf = await response.arrayBuffer();
|
|
5358
|
-
|
|
5585
|
+
const bytes = new Uint8Array(buf);
|
|
5586
|
+
if (this.cache)
|
|
5587
|
+
void this.cache.put(hash, bytes);
|
|
5588
|
+
return bytes;
|
|
5359
5589
|
}
|
|
5360
5590
|
/**
|
|
5361
5591
|
* Check whether a shard exists. Lets the caller skip a PUT they'd otherwise
|
|
@@ -5860,6 +6090,8 @@ class StorageNamespace {
|
|
|
5860
6090
|
baseUrl: this.deps.baseUrl,
|
|
5861
6091
|
pathPrefix: "/api/shards",
|
|
5862
6092
|
fetch: this.deps.http.fetch,
|
|
6093
|
+
cache: this.deps.shardCache,
|
|
6094
|
+
deferUpload: this.deps.deferShardUpload,
|
|
5863
6095
|
});
|
|
5864
6096
|
}
|
|
5865
6097
|
return this._shards;
|
|
@@ -6556,6 +6788,9 @@ class Space {
|
|
|
6556
6788
|
/** Per-instance event bus for fan-out events (isolated across spaces). */
|
|
6557
6789
|
events = new EventTarget();
|
|
6558
6790
|
rawWired = false;
|
|
6791
|
+
catchUpUnsub = null;
|
|
6792
|
+
/** Optional P2P swarm for this Space (block exchange among members). */
|
|
6793
|
+
peerNet = null;
|
|
6559
6794
|
constructor(deps) {
|
|
6560
6795
|
this.deps = deps;
|
|
6561
6796
|
this.name = deps.name;
|
|
@@ -6604,14 +6839,45 @@ class Space {
|
|
|
6604
6839
|
if (this.deps.keyring) {
|
|
6605
6840
|
this.channel.on(BroadcastChannelEvents.CONNECTED, () => {
|
|
6606
6841
|
this.channel?.sendRaw({ name: this.deps.myId() });
|
|
6842
|
+
// Kick P2P peer discovery now the signaling channel is live
|
|
6843
|
+
// (re-runs on each reconnect to re-form the mesh).
|
|
6844
|
+
this.peerNet?.start();
|
|
6607
6845
|
});
|
|
6608
6846
|
this.wireFanout();
|
|
6847
|
+
// Register offline catch-up once: on each reconnect the sync
|
|
6848
|
+
// engine pages history forward to fill any gap we missed.
|
|
6849
|
+
if (this.deps.offline?.enabled && !this.catchUpUnsub) {
|
|
6850
|
+
this.catchUpUnsub = this.deps.offline.registerCatchUp(() => this.catchUp());
|
|
6851
|
+
}
|
|
6609
6852
|
}
|
|
6610
6853
|
}
|
|
6611
6854
|
await this.channel.connect();
|
|
6612
6855
|
}
|
|
6613
6856
|
disconnect() {
|
|
6614
6857
|
this.channel?.disconnect();
|
|
6858
|
+
this.catchUpUnsub?.();
|
|
6859
|
+
this.catchUpUnsub = null;
|
|
6860
|
+
this.peerNet?.close();
|
|
6861
|
+
this.peerNet = null;
|
|
6862
|
+
}
|
|
6863
|
+
/** Attach a P2P swarm so this Space's file reads/writes can use peers. */
|
|
6864
|
+
attachPeerNetwork(net) {
|
|
6865
|
+
this.peerNet = net;
|
|
6866
|
+
}
|
|
6867
|
+
/**
|
|
6868
|
+
* Broadcast an app payload directly to connected Space peers over P2P,
|
|
6869
|
+
* **bypassing the server** — for CRDT ops / high-frequency state (live
|
|
6870
|
+
* cursors, presence, collaborative edits) you don't want to push through the
|
|
6871
|
+
* relay. No-op when no P2P mesh is active (requires `client` `p2p`).
|
|
6872
|
+
* Unlike {@link sendEphemeral} (server-relayed, metered, size-limited), this
|
|
6873
|
+
* is peer-direct and fragments large payloads.
|
|
6874
|
+
*/
|
|
6875
|
+
gossipToPeers(data) {
|
|
6876
|
+
this.peerNet?.gossip(data);
|
|
6877
|
+
}
|
|
6878
|
+
/** Subscribe to peer gossip ({@link gossipToPeers}). Returns an unsubscribe fn. */
|
|
6879
|
+
onPeerGossip(handler) {
|
|
6880
|
+
return this.peerNet?.onGossip(handler) ?? (() => { });
|
|
6615
6881
|
}
|
|
6616
6882
|
/** Subscribe to a raw channel event. Buffered until `connect()` if early. */
|
|
6617
6883
|
on(event, handler) {
|
|
@@ -6658,7 +6924,30 @@ class Space {
|
|
|
6658
6924
|
* persisted `spaceMessage`. Requires a keyring.
|
|
6659
6925
|
*/
|
|
6660
6926
|
async sendMessage(payload, opts = {}) {
|
|
6661
|
-
|
|
6927
|
+
const offline = this.deps.offline;
|
|
6928
|
+
if (!offline?.enabled) {
|
|
6929
|
+
this.sendRaw({ spaceMessage: await this.sealMessage(payload, opts.channel, opts.contentType) });
|
|
6930
|
+
return;
|
|
6931
|
+
}
|
|
6932
|
+
// Offline-aware path: stamp the message, cache it optimistically (so the
|
|
6933
|
+
// UI can show it instantly via cachedMessages), then send if connected
|
|
6934
|
+
// or durably queue it for replay on reconnect.
|
|
6935
|
+
const clientId = offline.newClientId();
|
|
6936
|
+
const hlc = await offline.nextHlc();
|
|
6937
|
+
const packet = await this.sealMessage(payload, opts.channel, opts.contentType, clientId);
|
|
6938
|
+
const frame = { spaceMessage: packet };
|
|
6939
|
+
await offline.putMessage(this.id, {
|
|
6940
|
+
handle: provisionalSpaceKey(clientId),
|
|
6941
|
+
packet,
|
|
6942
|
+
op: "msg",
|
|
6943
|
+
clientId,
|
|
6944
|
+
hlc,
|
|
6945
|
+
pending: true,
|
|
6946
|
+
});
|
|
6947
|
+
if (this.isConnected())
|
|
6948
|
+
this.sendRaw(frame);
|
|
6949
|
+
else
|
|
6950
|
+
await offline.enqueueFrame(this.id, frame, clientId, hlc);
|
|
6662
6951
|
}
|
|
6663
6952
|
/** Subscribe to decrypted fan-out messages. Returns an unsubscribe fn. */
|
|
6664
6953
|
onMessage(handler) {
|
|
@@ -6676,7 +6965,22 @@ class Space {
|
|
|
6676
6965
|
*/
|
|
6677
6966
|
async editMessage(handle, payload, opts = {}) {
|
|
6678
6967
|
const spaceMessage = await this.sealMessage(payload, opts.channel, opts.contentType);
|
|
6679
|
-
|
|
6968
|
+
const frame = { editSpaceMessage: { ts: handle, spaceMessage } };
|
|
6969
|
+
const offline = this.deps.offline;
|
|
6970
|
+
if (offline?.enabled) {
|
|
6971
|
+
await offline.putMessage(this.id, {
|
|
6972
|
+
handle: padSpaceHandle(handle),
|
|
6973
|
+
packet: spaceMessage,
|
|
6974
|
+
op: "edit",
|
|
6975
|
+
hlc: await offline.nextHlc(),
|
|
6976
|
+
});
|
|
6977
|
+
if (this.isConnected())
|
|
6978
|
+
this.sendRaw(frame);
|
|
6979
|
+
else
|
|
6980
|
+
await offline.enqueueFrame(this.id, frame, offline.newClientId(), await offline.nextHlc());
|
|
6981
|
+
return;
|
|
6982
|
+
}
|
|
6983
|
+
this.sendRaw(frame);
|
|
6680
6984
|
}
|
|
6681
6985
|
/**
|
|
6682
6986
|
* Hard-delete a persisted message by its server {@link SpaceMessageEvent.handle}
|
|
@@ -6684,7 +6988,45 @@ class Space {
|
|
|
6684
6988
|
* original author. Receivers get an {@link onMessageDeleted} event.
|
|
6685
6989
|
*/
|
|
6686
6990
|
async deleteMessage(handle) {
|
|
6687
|
-
|
|
6991
|
+
const frame = { deleteSpaceMessage: { ts: handle } };
|
|
6992
|
+
const offline = this.deps.offline;
|
|
6993
|
+
if (offline?.enabled) {
|
|
6994
|
+
await offline.putDeleted(this.id, handle);
|
|
6995
|
+
if (this.isConnected())
|
|
6996
|
+
this.sendRaw(frame);
|
|
6997
|
+
else
|
|
6998
|
+
await offline.enqueueFrame(this.id, frame, offline.newClientId(), await offline.nextHlc());
|
|
6999
|
+
return;
|
|
7000
|
+
}
|
|
7001
|
+
this.sendRaw(frame);
|
|
7002
|
+
}
|
|
7003
|
+
/**
|
|
7004
|
+
* Decrypt and return the locally-cached message log for this space, in
|
|
7005
|
+
* handle order (pending sends last). Works offline — the source is the
|
|
7006
|
+
* IndexedDB cache populated by live frames + {@link history}. Apps call this
|
|
7007
|
+
* on boot to paint instantly, then subscribe to {@link onMessage} for live
|
|
7008
|
+
* updates. Returns `[]` when offline support is off.
|
|
7009
|
+
*/
|
|
7010
|
+
async cachedMessages() {
|
|
7011
|
+
const offline = this.deps.offline;
|
|
7012
|
+
if (!offline?.enabled || !this.cipher)
|
|
7013
|
+
return [];
|
|
7014
|
+
const entries = await offline.loadMessages(this.id);
|
|
7015
|
+
const out = [];
|
|
7016
|
+
for (const e of entries) {
|
|
7017
|
+
if (e.deleted || !e.packet)
|
|
7018
|
+
continue;
|
|
7019
|
+
// Already verified when first cached → skip re-verification (which
|
|
7020
|
+
// would also fail offline without a warm member directory).
|
|
7021
|
+
const decoded = await this.decodeFrame(e.packet, this.cipher,
|
|
7022
|
+
/*isRawFrame*/ true, e.pending ? 0 : Number(e.handle),
|
|
7023
|
+
/*skipVerify*/ true);
|
|
7024
|
+
if (decoded) {
|
|
7025
|
+
decoded.pending = e.pending;
|
|
7026
|
+
out.push(decoded);
|
|
7027
|
+
}
|
|
7028
|
+
}
|
|
7029
|
+
return out;
|
|
6688
7030
|
}
|
|
6689
7031
|
/** Subscribe to in-place edits of persisted messages (new content at the same handle). */
|
|
6690
7032
|
onMessageEdited(handler) {
|
|
@@ -6705,10 +7047,12 @@ class Space {
|
|
|
6705
7047
|
* indicators, presence, or live cursors are built on top of this. No-op if
|
|
6706
7048
|
* the space isn't connected (ephemeral signals aren't worth queuing).
|
|
6707
7049
|
*/
|
|
6708
|
-
sendEphemeral(subject, data) {
|
|
7050
|
+
sendEphemeral(subject, data, to) {
|
|
6709
7051
|
if (!this.isConnected())
|
|
6710
7052
|
return;
|
|
6711
|
-
|
|
7053
|
+
// `to` (a member id) lets the server unicast a *directed* ephemeral
|
|
7054
|
+
// instead of broadcasting; omitted ⇒ broadcast to the room (unchanged).
|
|
7055
|
+
this.sendRaw({ pub: to ? { subject, data, to } : { subject, data } });
|
|
6712
7056
|
}
|
|
6713
7057
|
/** Subscribe to inbound ephemeral signals. `from` is the authenticated sender. */
|
|
6714
7058
|
onEphemeral(handler) {
|
|
@@ -6745,8 +7089,21 @@ class Space {
|
|
|
6745
7089
|
const messages = [];
|
|
6746
7090
|
for (const raw of body.messages ?? []) {
|
|
6747
7091
|
const decoded = await this.decodeFrame(raw, cipher);
|
|
6748
|
-
if (decoded)
|
|
6749
|
-
|
|
7092
|
+
if (!decoded)
|
|
7093
|
+
continue;
|
|
7094
|
+
messages.push(decoded);
|
|
7095
|
+
// Warm the offline cache with the inner sealed packet at its handle.
|
|
7096
|
+
if (this.deps.offline?.enabled) {
|
|
7097
|
+
try {
|
|
7098
|
+
const outer = JSON.parse(raw);
|
|
7099
|
+
if (typeof outer.spaceMessage === "string") {
|
|
7100
|
+
await this.cacheFrame(outer.spaceMessage, decoded, "msg");
|
|
7101
|
+
}
|
|
7102
|
+
}
|
|
7103
|
+
catch {
|
|
7104
|
+
/* unparseable history row — skip caching, still surfaced above */
|
|
7105
|
+
}
|
|
7106
|
+
}
|
|
6750
7107
|
}
|
|
6751
7108
|
return { messages, nextCursor: body.nextCursor ?? null };
|
|
6752
7109
|
}
|
|
@@ -6813,6 +7170,9 @@ class Space {
|
|
|
6813
7170
|
baseUrl: this.deps.httpBaseUrl,
|
|
6814
7171
|
pathPrefix: "/api/shards",
|
|
6815
7172
|
fetch: this.deps.fetch,
|
|
7173
|
+
cache: this.deps.shardCache,
|
|
7174
|
+
deferUpload: this.deps.deferShardUpload,
|
|
7175
|
+
peers: this.peerNet?.exchange,
|
|
6816
7176
|
});
|
|
6817
7177
|
return new FileStorage({ shards });
|
|
6818
7178
|
}
|
|
@@ -6831,9 +7191,12 @@ class Space {
|
|
|
6831
7191
|
if (!f || typeof f !== "object")
|
|
6832
7192
|
return;
|
|
6833
7193
|
if (typeof f.spaceMessage === "string" && this.cipher) {
|
|
6834
|
-
const
|
|
6835
|
-
|
|
7194
|
+
const handle = Number(f.timestamp ?? 0);
|
|
7195
|
+
const decoded = await this.decodeFrame(f.spaceMessage, this.cipher, /*raw*/ true, handle);
|
|
7196
|
+
if (decoded) {
|
|
6836
7197
|
this.emitEvent("message", decoded);
|
|
7198
|
+
await this.cacheFrame(f.spaceMessage, decoded, "msg");
|
|
7199
|
+
}
|
|
6837
7200
|
return;
|
|
6838
7201
|
}
|
|
6839
7202
|
if (f.editSpaceMessage && typeof f.editSpaceMessage === "object" && this.cipher) {
|
|
@@ -6842,14 +7205,21 @@ class Space {
|
|
|
6842
7205
|
const e = f.editSpaceMessage;
|
|
6843
7206
|
if (typeof e.spaceMessage === "string") {
|
|
6844
7207
|
const decoded = await this.decodeFrame(e.spaceMessage, this.cipher, /*raw*/ true, Number(e.ts ?? 0));
|
|
6845
|
-
if (decoded)
|
|
7208
|
+
if (decoded) {
|
|
6846
7209
|
this.emitEvent("message-edited", decoded);
|
|
7210
|
+
await this.cacheFrame(e.spaceMessage, decoded, "edit");
|
|
7211
|
+
}
|
|
6847
7212
|
}
|
|
6848
7213
|
return;
|
|
6849
7214
|
}
|
|
6850
7215
|
if (f.deleteSpaceMessage && typeof f.deleteSpaceMessage === "object") {
|
|
6851
7216
|
const d = f.deleteSpaceMessage;
|
|
6852
|
-
|
|
7217
|
+
const handle = Number(d.ts ?? 0);
|
|
7218
|
+
this.emitEvent("message-deleted", { handle });
|
|
7219
|
+
if (this.deps.offline?.enabled) {
|
|
7220
|
+
await this.deps.offline.putDeleted(this.id, handle);
|
|
7221
|
+
await this.deps.offline.observeHandle(this.id, handle);
|
|
7222
|
+
}
|
|
6853
7223
|
return;
|
|
6854
7224
|
}
|
|
6855
7225
|
if (f.pub && typeof f.pub === "object") {
|
|
@@ -6885,12 +7255,17 @@ class Space {
|
|
|
6885
7255
|
* serialized `Packet` string. Shared by send (a new message) and edit
|
|
6886
7256
|
* (replacement content for an existing handle). Requires a keyring.
|
|
6887
7257
|
*/
|
|
6888
|
-
async sealMessage(payload, channel, contentType) {
|
|
7258
|
+
async sealMessage(payload, channel, contentType, clientId) {
|
|
6889
7259
|
const cipher = this.requireCipher(contentType);
|
|
6890
7260
|
const message = new Message(payload);
|
|
6891
7261
|
const headers = await cipher.seal(message.serialize());
|
|
6892
7262
|
const source = this.deps.myId();
|
|
6893
7263
|
const subject = channel ?? "default";
|
|
7264
|
+
// Cleartext dedupe hint — lets the sender match its optimistic entry to
|
|
7265
|
+
// the server echo. Not part of the signed canonical form (it carries no
|
|
7266
|
+
// authority); worst case a stale hint just shows a transient duplicate.
|
|
7267
|
+
if (clientId)
|
|
7268
|
+
headers.cid = clientId;
|
|
6894
7269
|
// Sign with our identity ECDSA key so receivers can verify authorship
|
|
6895
7270
|
// end-to-end (independent of the relay's `source` stamp).
|
|
6896
7271
|
const authPriv = KeyStore.getInstance().getAuthKeyPair(source)?.privateKey;
|
|
@@ -6910,7 +7285,7 @@ class Space {
|
|
|
6910
7285
|
* the enclosing frame); false means `input` is the full
|
|
6911
7286
|
* `{spaceMessage, name, timestamp}` frame and the handle is read from it.
|
|
6912
7287
|
*/
|
|
6913
|
-
async decodeFrame(input, cipher, isRawFrame = false, serverTs = 0) {
|
|
7288
|
+
async decodeFrame(input, cipher, isRawFrame = false, serverTs = 0, skipVerify = false) {
|
|
6914
7289
|
try {
|
|
6915
7290
|
let packetJson = input;
|
|
6916
7291
|
let handle = serverTs;
|
|
@@ -6928,8 +7303,9 @@ class Space {
|
|
|
6928
7303
|
// by `source`'s identity ECDSA key (looked up in the member
|
|
6929
7304
|
// directory). Drop anything unsigned, signed by an unknown member,
|
|
6930
7305
|
// or whose signature doesn't match — this defeats both member
|
|
6931
|
-
// impersonation and a relay rewriting `source`.
|
|
6932
|
-
|
|
7306
|
+
// impersonation and a relay rewriting `source`. Skipped only when
|
|
7307
|
+
// re-reading from the local cache (already verified before caching).
|
|
7308
|
+
if (!skipVerify && !(await this.verifySender(packet)))
|
|
6933
7309
|
return null;
|
|
6934
7310
|
const serialized = await cipher.open(packet.headers);
|
|
6935
7311
|
if (serialized === null)
|
|
@@ -6941,6 +7317,7 @@ class Space {
|
|
|
6941
7317
|
epoch: Number(packet.headers?.epoch ?? 0),
|
|
6942
7318
|
contentType: packet.headers?.contentType,
|
|
6943
7319
|
handle,
|
|
7320
|
+
clientId: packet.headers?.cid,
|
|
6944
7321
|
message,
|
|
6945
7322
|
};
|
|
6946
7323
|
}
|
|
@@ -6983,6 +7360,62 @@ class Space {
|
|
|
6983
7360
|
});
|
|
6984
7361
|
return verifySpaceMessage(canonical, sig, key);
|
|
6985
7362
|
}
|
|
7363
|
+
/** Persist a decoded live/history/edit frame to the offline cache. */
|
|
7364
|
+
async cacheFrame(packet, decoded, op) {
|
|
7365
|
+
const offline = this.deps.offline;
|
|
7366
|
+
if (!offline?.enabled)
|
|
7367
|
+
return;
|
|
7368
|
+
// An incoming real message supersedes any optimistic pending entry.
|
|
7369
|
+
if (decoded.clientId)
|
|
7370
|
+
await offline.dropPending(this.id, decoded.clientId);
|
|
7371
|
+
await offline.putMessage(this.id, {
|
|
7372
|
+
handle: padSpaceHandle(decoded.handle),
|
|
7373
|
+
packet,
|
|
7374
|
+
op,
|
|
7375
|
+
clientId: decoded.clientId,
|
|
7376
|
+
});
|
|
7377
|
+
await offline.observeHandle(this.id, decoded.handle);
|
|
7378
|
+
}
|
|
7379
|
+
/**
|
|
7380
|
+
* Inbound reconciliation run on reconnect: page the server's forward delta
|
|
7381
|
+
* (`?since=<lastSeenHandle>`) to the head, pulling exactly the messages that
|
|
7382
|
+
* landed while we were offline into the cache. Bounded so a long absence
|
|
7383
|
+
* can't page forever. Falls back gracefully if the endpoint is unavailable.
|
|
7384
|
+
*/
|
|
7385
|
+
async catchUp() {
|
|
7386
|
+
const offline = this.deps.offline;
|
|
7387
|
+
if (!offline?.enabled || !this.cipher)
|
|
7388
|
+
return;
|
|
7389
|
+
const cursor = await offline.getCursor(this.id);
|
|
7390
|
+
let since = String(cursor?.lastSeenHandle ?? 0);
|
|
7391
|
+
// Warm the member directory once so historical senders' signatures verify.
|
|
7392
|
+
await this.deps.keyring?.refreshDirectory();
|
|
7393
|
+
for (let page = 0; page < 50; page++) {
|
|
7394
|
+
const url = `${this.deps.httpBaseUrl}/api/spaces/${encodeURIComponent(this.name)}/history` +
|
|
7395
|
+
`?since=${encodeURIComponent(since)}&limit=100`;
|
|
7396
|
+
const res = await this.deps.fetch(url);
|
|
7397
|
+
if (!res.ok)
|
|
7398
|
+
break;
|
|
7399
|
+
const body = (await res.json());
|
|
7400
|
+
for (const raw of body.messages ?? []) {
|
|
7401
|
+
const decoded = await this.decodeFrame(raw, this.cipher);
|
|
7402
|
+
if (!decoded)
|
|
7403
|
+
continue;
|
|
7404
|
+
try {
|
|
7405
|
+
const outer = JSON.parse(raw);
|
|
7406
|
+
if (typeof outer.spaceMessage === "string") {
|
|
7407
|
+
await this.cacheFrame(outer.spaceMessage, decoded, "msg");
|
|
7408
|
+
}
|
|
7409
|
+
}
|
|
7410
|
+
catch {
|
|
7411
|
+
/* unparseable row — skip */
|
|
7412
|
+
}
|
|
7413
|
+
}
|
|
7414
|
+
if (!body.nextCursor)
|
|
7415
|
+
break;
|
|
7416
|
+
since = body.nextCursor;
|
|
7417
|
+
}
|
|
7418
|
+
}
|
|
6986
7419
|
emitEvent(type, detail) {
|
|
6987
7420
|
this.events.dispatchEvent(new CustomEvent(type, { detail }));
|
|
6988
7421
|
}
|
|
@@ -7002,6 +7435,18 @@ class Space {
|
|
|
7002
7435
|
function delay(ms) {
|
|
7003
7436
|
return new Promise((r) => setTimeout(r, ms));
|
|
7004
7437
|
}
|
|
7438
|
+
/**
|
|
7439
|
+
* Zero-pad a numeric server handle for use as a cache key, so string-ordered
|
|
7440
|
+
* keys sort in handle order. Must match the offline layer's `padHandle`.
|
|
7441
|
+
*/
|
|
7442
|
+
function padSpaceHandle(handle) {
|
|
7443
|
+
return String(Math.max(0, Math.floor(handle))).padStart(16, "0");
|
|
7444
|
+
}
|
|
7445
|
+
/** Cache key for an un-acked optimistic send. Must match the offline layer's
|
|
7446
|
+
* `provisionalHandle` (sorts after every real, zero-padded handle). */
|
|
7447
|
+
function provisionalSpaceKey(clientId) {
|
|
7448
|
+
return `~local:${clientId}`;
|
|
7449
|
+
}
|
|
7005
7450
|
|
|
7006
7451
|
/**
|
|
7007
7452
|
* `client.message` — realtime messaging over the shared-space primitive.
|
|
@@ -7497,455 +7942,2573 @@ class KeyringClient {
|
|
|
7497
7942
|
}
|
|
7498
7943
|
|
|
7499
7944
|
/**
|
|
7500
|
-
*
|
|
7501
|
-
*
|
|
7502
|
-
*
|
|
7503
|
-
*
|
|
7504
|
-
*
|
|
7505
|
-
*
|
|
7506
|
-
*
|
|
7507
|
-
*
|
|
7508
|
-
*
|
|
7509
|
-
*
|
|
7510
|
-
*
|
|
7511
|
-
*
|
|
7512
|
-
*
|
|
7945
|
+
* Message-log helpers — the conflict model for a Space's message history.
|
|
7946
|
+
*
|
|
7947
|
+
* Messages are the easy CRDT: the accelerator assigns every persisted message a
|
|
7948
|
+
* monotonic, zero-padded `handle` that already totally-orders the log, and the
|
|
7949
|
+
* sealed body is immutable. So the "log" is just a grow-only set keyed by
|
|
7950
|
+
* `handle`, and convergence is structural — no server change needed.
|
|
7951
|
+
*
|
|
7952
|
+
* The only subtleties this module encodes:
|
|
7953
|
+
* - **Optimistic sends** have no server handle yet. We key them by a
|
|
7954
|
+
* provisional handle that sorts *after* every real one, and tag them with
|
|
7955
|
+
* the sender's `clientId` so the real frame (carrying the same `clientId`)
|
|
7956
|
+
* can replace the placeholder instead of duplicating it.
|
|
7957
|
+
* - **Edits** are a per-message LWW register on the body (greater HLC wins).
|
|
7958
|
+
* - **Deletes** are a tombstone at the message's handle.
|
|
7513
7959
|
*/
|
|
7514
|
-
/**
|
|
7515
|
-
|
|
7516
|
-
|
|
7517
|
-
constructor(channelName) {
|
|
7518
|
-
super(`Channel "${channelName}" does not exist. Create it first.`);
|
|
7519
|
-
this.channelName = channelName;
|
|
7520
|
-
this.name = "ChannelNotFoundError";
|
|
7521
|
-
}
|
|
7960
|
+
/** IndexedDB key for a message within a space. */
|
|
7961
|
+
function messageKey(spaceId, handle) {
|
|
7962
|
+
return `${spaceId}|${handle}`;
|
|
7522
7963
|
}
|
|
7523
|
-
/**
|
|
7524
|
-
|
|
7525
|
-
|
|
7526
|
-
|
|
7527
|
-
|
|
7528
|
-
|
|
7529
|
-
|
|
7964
|
+
/**
|
|
7965
|
+
* A handle for an un-acked optimistic message. Prefixed with `"~"` (0x7E),
|
|
7966
|
+
* which sorts after every digit, so pending sends always appear at the tail of
|
|
7967
|
+
* the handle-ordered log until the real handle arrives.
|
|
7968
|
+
*/
|
|
7969
|
+
function provisionalHandle(clientId) {
|
|
7970
|
+
return `~local:${clientId}`;
|
|
7971
|
+
}
|
|
7972
|
+
function isProvisional(handle) {
|
|
7973
|
+
return handle.startsWith("~local:");
|
|
7974
|
+
}
|
|
7975
|
+
/**
|
|
7976
|
+
* Merge two entries for the SAME handle. A delete always wins (tombstone); an
|
|
7977
|
+
* edit wins over the entry it supersedes when its HLC is greater; otherwise the
|
|
7978
|
+
* existing entry stands (a re-delivered `msg` is idempotent).
|
|
7979
|
+
*/
|
|
7980
|
+
function mergeMessage(local, incoming) {
|
|
7981
|
+
if (!local)
|
|
7982
|
+
return incoming;
|
|
7983
|
+
if (incoming.op === "delete" || local.deleted) {
|
|
7984
|
+
return { ...local, op: "delete", packet: null, deleted: true };
|
|
7530
7985
|
}
|
|
7986
|
+
if (incoming.op === "edit") {
|
|
7987
|
+
if (!local.hlc || (incoming.hlc && incoming.hlc > local.hlc)) {
|
|
7988
|
+
return { ...local, packet: incoming.packet, hlc: incoming.hlc, op: "edit" };
|
|
7989
|
+
}
|
|
7990
|
+
return local;
|
|
7991
|
+
}
|
|
7992
|
+
// incoming.op === "msg": keep the already-stored (de-pending) entry.
|
|
7993
|
+
return { ...local, pending: false };
|
|
7531
7994
|
}
|
|
7532
|
-
|
|
7533
|
-
|
|
7534
|
-
|
|
7535
|
-
|
|
7995
|
+
|
|
7996
|
+
/**
|
|
7997
|
+
* `SpaceCache` — the offline adapter a {@link ../spaces/Space} talks to. It
|
|
7998
|
+
* persists sealed message frames (ciphertext at rest), tracks per-space sync
|
|
7999
|
+
* cursors, and records offline sends in the durable queue. Kept as its own
|
|
8000
|
+
* object (rather than wiring the Space straight to the store + clock) so the
|
|
8001
|
+
* Space stays decoupled from IndexedDB and so the no-op path is a single
|
|
8002
|
+
* `enabled` check.
|
|
8003
|
+
*
|
|
8004
|
+
* Messages are the structural CRDT (see {@link ./crdt/MessageLog}): the server
|
|
8005
|
+
* assigns a monotonic `handle` that totally-orders the log, so the cache just
|
|
8006
|
+
* stores frames keyed by a zero-padded handle (real) or a provisional key
|
|
8007
|
+
* (pending sends, which sort to the tail until their echo arrives).
|
|
8008
|
+
*/
|
|
8009
|
+
/** Zero-pad a numeric server handle so string keys sort in handle order. */
|
|
8010
|
+
function padHandle(handle) {
|
|
8011
|
+
return String(Math.max(0, Math.floor(handle))).padStart(16, "0");
|
|
8012
|
+
}
|
|
8013
|
+
/**
|
|
8014
|
+
* The surface a {@link ../spaces/Space} uses. Declared structurally on the
|
|
8015
|
+
* Space side too (as `SpaceOfflineAdapter`) so the spaces module never imports
|
|
8016
|
+
* the offline module.
|
|
8017
|
+
*/
|
|
8018
|
+
class SpaceCache {
|
|
8019
|
+
manager;
|
|
8020
|
+
store;
|
|
8021
|
+
constructor(manager) {
|
|
8022
|
+
this.manager = manager;
|
|
8023
|
+
this.store = manager.store;
|
|
8024
|
+
}
|
|
8025
|
+
get enabled() {
|
|
8026
|
+
return this.manager.enabled;
|
|
8027
|
+
}
|
|
8028
|
+
newClientId() {
|
|
8029
|
+
return this.manager.newClientId();
|
|
8030
|
+
}
|
|
8031
|
+
nextHlc() {
|
|
8032
|
+
return this.manager.nextHlc();
|
|
8033
|
+
}
|
|
8034
|
+
/** Persist a sealed message frame keyed by its (real or provisional) handle. */
|
|
8035
|
+
async putMessage(spaceId, entry) {
|
|
8036
|
+
await this.store.put("space-messages", messageKey(spaceId, entry.handle), entry);
|
|
8037
|
+
}
|
|
8038
|
+
/** Tombstone the message at `handle`. */
|
|
8039
|
+
async putDeleted(spaceId, handle) {
|
|
8040
|
+
const key = messageKey(spaceId, padHandle(handle));
|
|
8041
|
+
const existing = await this.store.get("space-messages", key);
|
|
8042
|
+
await this.store.put("space-messages", key, {
|
|
8043
|
+
handle: padHandle(handle),
|
|
8044
|
+
packet: null,
|
|
8045
|
+
op: "delete",
|
|
8046
|
+
deleted: true,
|
|
8047
|
+
clientId: existing?.clientId,
|
|
8048
|
+
});
|
|
7536
8049
|
}
|
|
7537
|
-
/**
|
|
7538
|
-
|
|
7539
|
-
|
|
7540
|
-
*/
|
|
7541
|
-
async createSpace(opts = {}) {
|
|
7542
|
-
const historyPolicy = opts.historyPolicy ?? "static";
|
|
7543
|
-
const identity = await generateSpaceIdentity();
|
|
7544
|
-
// Register the space (server records pubkey + policy; the DO is lazy).
|
|
7545
|
-
// For a private space the server also records the creator (this user) as
|
|
7546
|
-
// its first member — so the metadata POST must carry the user session.
|
|
7547
|
-
await this.deps.http.post(`/api/spaces/${encodeURIComponent(identity.id)}/metadata`, { spacePubKey: identity.id, historyPolicy, visibility: opts.private ? "private" : "public" });
|
|
7548
|
-
const space = await this.build(identity.id, historyPolicy);
|
|
7549
|
-
await space.create();
|
|
7550
|
-
return space;
|
|
8050
|
+
/** Drop a pending (optimistic) entry once its real echo has been stored. */
|
|
8051
|
+
async dropPending(spaceId, clientId) {
|
|
8052
|
+
await this.store.delete("space-messages", messageKey(spaceId, provisionalHandle(clientId)));
|
|
7551
8053
|
}
|
|
7552
|
-
/**
|
|
7553
|
-
async
|
|
7554
|
-
const
|
|
7555
|
-
|
|
7556
|
-
await space.join({ timeoutMs: opts.timeoutMs });
|
|
7557
|
-
return space;
|
|
8054
|
+
/** All cached frames for a space, in handle order (pending ones last). */
|
|
8055
|
+
async loadMessages(spaceId) {
|
|
8056
|
+
const rows = await this.store.range("space-messages", `${spaceId}|`, `${spaceId}|`);
|
|
8057
|
+
return rows.map((r) => r.value);
|
|
7558
8058
|
}
|
|
7559
|
-
|
|
7560
|
-
|
|
7561
|
-
return this.build(spaceId, historyPolicy);
|
|
8059
|
+
async getCursor(spaceId) {
|
|
8060
|
+
return this.store.get("space-cursors", spaceId);
|
|
7562
8061
|
}
|
|
7563
|
-
|
|
7564
|
-
|
|
7565
|
-
|
|
7566
|
-
|
|
7567
|
-
|
|
7568
|
-
|
|
7569
|
-
|
|
7570
|
-
|
|
7571
|
-
|
|
7572
|
-
|
|
8062
|
+
/** Advance the last-seen handle (monotonic) and track the oldest fetched. */
|
|
8063
|
+
async observeHandle(spaceId, handle) {
|
|
8064
|
+
if (!handle)
|
|
8065
|
+
return;
|
|
8066
|
+
const cur = (await this.getCursor(spaceId)) ?? {
|
|
8067
|
+
lastSeenHandle: 0,
|
|
8068
|
+
oldestHandle: handle,
|
|
8069
|
+
fullyBackfilled: false,
|
|
8070
|
+
};
|
|
8071
|
+
const next = {
|
|
8072
|
+
lastSeenHandle: Math.max(cur.lastSeenHandle, handle),
|
|
8073
|
+
oldestHandle: cur.oldestHandle ? Math.min(cur.oldestHandle, handle) : handle,
|
|
8074
|
+
fullyBackfilled: cur.fullyBackfilled,
|
|
8075
|
+
};
|
|
8076
|
+
await this.store.put("space-cursors", spaceId, next);
|
|
8077
|
+
}
|
|
8078
|
+
/** Queue a raw space frame for replay when the connection returns. */
|
|
8079
|
+
async enqueueFrame(spaceId, frame, clientId, hlc) {
|
|
8080
|
+
await this.manager.enqueue({
|
|
8081
|
+
hlc,
|
|
8082
|
+
clientId,
|
|
8083
|
+
domain: "space",
|
|
8084
|
+
method: "sendRaw",
|
|
8085
|
+
args: { spaceId, frame },
|
|
8086
|
+
});
|
|
7573
8087
|
}
|
|
7574
|
-
/**
|
|
7575
|
-
|
|
8088
|
+
/** Register an inbound catch-up task (forward/back history paging). */
|
|
8089
|
+
registerCatchUp(task) {
|
|
8090
|
+
return this.manager.registerCatchUp(task);
|
|
8091
|
+
}
|
|
8092
|
+
}
|
|
8093
|
+
|
|
8094
|
+
/**
|
|
8095
|
+
* `ShardCache` — a Cache-API-backed store for file-shard bytes. Shards are
|
|
8096
|
+
* content-addressed (the key IS the SHA-256 of the ciphertext), so caching them
|
|
8097
|
+
* is uniquely safe: a cache hit is self-validating, immutable, and shareable
|
|
8098
|
+
* across files. The Cache API (rather than IndexedDB) is the right home for
|
|
8099
|
+
* large binary blobs — it streams, it's purpose-built for response bodies, and
|
|
8100
|
+
* it has its own eviction.
|
|
8101
|
+
*
|
|
8102
|
+
* Keys are SYNTHETIC absolute URLs (`https://shard.muhkoo.local/<hash>`) rather
|
|
8103
|
+
* than the real shard endpoint, so the cache is addressable by hash alone — the
|
|
8104
|
+
* sync engine can re-PUT a deferred shard knowing only its hash, without
|
|
8105
|
+
* reconstructing the accelerator URL. We never fetch these synthetic URLs; they
|
|
8106
|
+
* exist only as cache keys.
|
|
8107
|
+
*
|
|
8108
|
+
* Every method degrades to a no-op / null where the Cache API is unavailable
|
|
8109
|
+
* (Node, Workers, insecure contexts), so callers don't need to branch.
|
|
8110
|
+
*/
|
|
8111
|
+
const CACHE_NAME = "muhkoo-shards-v1";
|
|
8112
|
+
class ShardCache {
|
|
8113
|
+
static available() {
|
|
7576
8114
|
try {
|
|
7577
|
-
|
|
7578
|
-
return body.spaceId ?? null;
|
|
8115
|
+
return typeof caches !== "undefined";
|
|
7579
8116
|
}
|
|
7580
|
-
catch
|
|
7581
|
-
|
|
7582
|
-
return null;
|
|
7583
|
-
throw err;
|
|
8117
|
+
catch {
|
|
8118
|
+
return false;
|
|
7584
8119
|
}
|
|
7585
8120
|
}
|
|
7586
|
-
|
|
7587
|
-
|
|
7588
|
-
|
|
7589
|
-
|
|
7590
|
-
|
|
7591
|
-
|
|
7592
|
-
|
|
7593
|
-
|
|
8121
|
+
key(hash) {
|
|
8122
|
+
return `https://shard.muhkoo.local/${encodeURIComponent(hash)}`;
|
|
8123
|
+
}
|
|
8124
|
+
open() {
|
|
8125
|
+
return caches.open(CACHE_NAME);
|
|
8126
|
+
}
|
|
8127
|
+
/** Cached shard bytes, or `null` on miss / when unavailable. */
|
|
8128
|
+
async get(hash) {
|
|
8129
|
+
if (!ShardCache.available())
|
|
8130
|
+
return null;
|
|
7594
8131
|
try {
|
|
7595
|
-
await this.
|
|
8132
|
+
const cache = await this.open();
|
|
8133
|
+
const res = await cache.match(this.key(hash));
|
|
8134
|
+
if (!res)
|
|
8135
|
+
return null;
|
|
8136
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
7596
8137
|
}
|
|
7597
|
-
catch
|
|
7598
|
-
|
|
7599
|
-
throw new ChannelExistsError(name);
|
|
7600
|
-
}
|
|
7601
|
-
throw err;
|
|
8138
|
+
catch {
|
|
8139
|
+
return null;
|
|
7602
8140
|
}
|
|
7603
|
-
|
|
7604
|
-
|
|
8141
|
+
}
|
|
8142
|
+
/** Store shard bytes under their hash. Best-effort. */
|
|
8143
|
+
async put(hash, bytes) {
|
|
8144
|
+
if (!ShardCache.available())
|
|
8145
|
+
return;
|
|
7605
8146
|
try {
|
|
7606
|
-
const
|
|
7607
|
-
|
|
7608
|
-
|
|
8147
|
+
const cache = await this.open();
|
|
8148
|
+
await cache.put(this.key(hash), new Response(bytes, {
|
|
8149
|
+
headers: { "content-type": "application/octet-stream" },
|
|
8150
|
+
}));
|
|
7609
8151
|
}
|
|
7610
|
-
catch
|
|
7611
|
-
|
|
8152
|
+
catch {
|
|
8153
|
+
/* quota / unavailable — degrade to online-only for this shard */
|
|
8154
|
+
}
|
|
8155
|
+
}
|
|
8156
|
+
/** Whether a shard is present in the cache. */
|
|
8157
|
+
async has(hash) {
|
|
8158
|
+
if (!ShardCache.available())
|
|
8159
|
+
return false;
|
|
8160
|
+
try {
|
|
8161
|
+
const cache = await this.open();
|
|
8162
|
+
return Boolean(await cache.match(this.key(hash)));
|
|
8163
|
+
}
|
|
8164
|
+
catch {
|
|
8165
|
+
return false;
|
|
8166
|
+
}
|
|
8167
|
+
}
|
|
8168
|
+
}
|
|
8169
|
+
|
|
8170
|
+
/**
|
|
8171
|
+
* `PeerExchange` — the thin façade the storage layer consults to try fetching a
|
|
8172
|
+
* block from peers before falling back to origin. It satisfies the structural
|
|
8173
|
+
* `PeerBlockSource` interface that {@link ../storage/transport/ShardClient}
|
|
8174
|
+
* declares (so storage never imports the p2p module — the dependency arrow stays
|
|
8175
|
+
* one-way, p2p → storage).
|
|
8176
|
+
*
|
|
8177
|
+
* It delegates to an {@link EngineHandle} — either an in-process {@link
|
|
8178
|
+
* ./worker/blockEngine.BlockEngine} or the Web Worker proxy — so the same façade
|
|
8179
|
+
* works whether the block engine runs on the main thread or off it.
|
|
8180
|
+
*/
|
|
8181
|
+
class PeerExchange {
|
|
8182
|
+
engine;
|
|
8183
|
+
timeoutMs;
|
|
8184
|
+
constructor(engine, opts = {}) {
|
|
8185
|
+
this.engine = engine;
|
|
8186
|
+
this.timeoutMs = opts.timeoutMs ?? 2500;
|
|
8187
|
+
}
|
|
8188
|
+
getBlock(hash, opts) {
|
|
8189
|
+
return this.engine.want(hash, opts?.timeoutMs ?? this.timeoutMs);
|
|
8190
|
+
}
|
|
8191
|
+
announce(hash) {
|
|
8192
|
+
this.engine.announce(hash);
|
|
8193
|
+
}
|
|
8194
|
+
}
|
|
8195
|
+
|
|
8196
|
+
/**
|
|
8197
|
+
* `SpaceSignaler` — WebRTC signaling carried over the Space's existing ephemeral
|
|
8198
|
+
* relay. No new server channel: `Space.sendEphemeral`/`onEphemeral` already
|
|
8199
|
+
* blind-relay `pub` frames stamped with the authenticated sender, so SDP
|
|
8200
|
+
* offers/answers and ICE candidates ride a reserved subject (`__p2p__`) scoped
|
|
8201
|
+
* to Space membership. The sender (`from`) is server-stamped and can't be
|
|
8202
|
+
* spoofed; the payload is opaque to the server.
|
|
8203
|
+
*
|
|
8204
|
+
* Envelopes carry a `to` (a member id, or `"*"` for discovery) so the
|
|
8205
|
+
* accelerator can optionally unicast peer-pair signaling instead of broadcasting
|
|
8206
|
+
* (a small, backward-compatible `pub` enhancement); the client filters by `to`
|
|
8207
|
+
* regardless.
|
|
8208
|
+
*/
|
|
8209
|
+
const P2P_SUBJECT = "__p2p__";
|
|
8210
|
+
class SpaceSignaler {
|
|
8211
|
+
space;
|
|
8212
|
+
myId;
|
|
8213
|
+
constructor(space, myId) {
|
|
8214
|
+
this.space = space;
|
|
8215
|
+
this.myId = myId;
|
|
8216
|
+
}
|
|
8217
|
+
/** Send a signal to one peer (or `"*"`). */
|
|
8218
|
+
send(to, kind, data) {
|
|
8219
|
+
const env = { to, kind, data };
|
|
8220
|
+
// Ask the server to unicast for a directed target; broadcast for "*".
|
|
8221
|
+
// `to` also stays in the envelope so a broadcast-only server still works
|
|
8222
|
+
// (the client filters by it).
|
|
8223
|
+
this.space.sendEphemeral(P2P_SUBJECT, env, to !== "*" ? to : undefined);
|
|
8224
|
+
}
|
|
8225
|
+
/** Announce/withdraw P2P availability to all Space members. */
|
|
8226
|
+
broadcast(kind, data) {
|
|
8227
|
+
this.send("*", kind, data);
|
|
8228
|
+
}
|
|
8229
|
+
/**
|
|
8230
|
+
* Subscribe to inbound signals addressed to us (or broadcast). Skips our own
|
|
8231
|
+
* echoes. Returns an unsubscribe function.
|
|
8232
|
+
*/
|
|
8233
|
+
onSignal(cb) {
|
|
8234
|
+
return this.space.onEphemeral((e) => {
|
|
8235
|
+
if (e.subject !== P2P_SUBJECT || e.from === this.myId)
|
|
8236
|
+
return;
|
|
8237
|
+
const env = e.data;
|
|
8238
|
+
if (!env || typeof env.kind !== "string")
|
|
8239
|
+
return;
|
|
8240
|
+
if (env.to !== this.myId && env.to !== "*")
|
|
8241
|
+
return;
|
|
8242
|
+
cb(e.from, env.kind, env.data);
|
|
8243
|
+
});
|
|
8244
|
+
}
|
|
8245
|
+
}
|
|
8246
|
+
|
|
8247
|
+
/**
|
|
8248
|
+
* `WebRtcTransport` — a {@link ./PeerTransport.PeerTransport} over RTCDataChannels,
|
|
8249
|
+
* signaled by {@link ../signaling/SpaceSignaler}. Runs on the **main thread**
|
|
8250
|
+
* (`RTCPeerConnection` is Window-only — not available in Workers), so the block
|
|
8251
|
+
* engine lives in a Worker and this only shuttles bytes.
|
|
8252
|
+
*
|
|
8253
|
+
* Connection setup uses a deterministic initiator (the peer with the smaller id
|
|
8254
|
+
* dials) to avoid offer/answer glare. STUN handles NAT traversal; there is no
|
|
8255
|
+
* TURN — an unreachable peer simply never connects and the caller falls back to
|
|
8256
|
+
* origin (P2P is best-effort).
|
|
8257
|
+
*
|
|
8258
|
+
* Frames (up to a 4 MiB shard) exceed an RTCDataChannel's max message size, so
|
|
8259
|
+
* each frame is fragmented into `CHUNK`-sized pieces with a 12-byte header
|
|
8260
|
+
* `[msgId u32][index u32][total u32]` and reassembled per peer.
|
|
8261
|
+
*/
|
|
8262
|
+
const CHUNK = 16 * 1024;
|
|
8263
|
+
// [msgId u32][index u32][total u32][channel u32]
|
|
8264
|
+
const HEADER$1 = 16;
|
|
8265
|
+
/** Pause sends when a channel has this many bytes buffered (backpressure). */
|
|
8266
|
+
const MAX_BUFFERED = 4 * 1024 * 1024;
|
|
8267
|
+
const DEFAULT_ICE = [{ urls: "stun:stun.cloudflare.com:3478" }];
|
|
8268
|
+
class WebRtcTransport {
|
|
8269
|
+
signaler;
|
|
8270
|
+
myId;
|
|
8271
|
+
conns = new Map();
|
|
8272
|
+
msgCbs = new Set();
|
|
8273
|
+
peerCbs = new Set();
|
|
8274
|
+
offSignal;
|
|
8275
|
+
iceServers;
|
|
8276
|
+
maxPeers;
|
|
8277
|
+
nextMsgId = 1;
|
|
8278
|
+
constructor(signaler, myId, opts = {}) {
|
|
8279
|
+
this.signaler = signaler;
|
|
8280
|
+
this.myId = myId;
|
|
8281
|
+
this.iceServers = opts.iceServers ?? DEFAULT_ICE;
|
|
8282
|
+
this.maxPeers = opts.maxPeers ?? 8;
|
|
8283
|
+
this.offSignal = this.signaler.onSignal((from, kind, data) => this.onSignal(from, kind, data));
|
|
8284
|
+
}
|
|
8285
|
+
peers() {
|
|
8286
|
+
return [...this.conns].filter(([, c]) => c.dc?.readyState === "open").map(([id]) => id);
|
|
8287
|
+
}
|
|
8288
|
+
/** Dial a peer (no-op unless we're the deterministic initiator for the pair). */
|
|
8289
|
+
connectTo(peerId) {
|
|
8290
|
+
if (peerId === this.myId || this.conns.has(peerId))
|
|
8291
|
+
return;
|
|
8292
|
+
if (this.conns.size >= this.maxPeers)
|
|
8293
|
+
return; // mesh cap
|
|
8294
|
+
if (this.myId < peerId)
|
|
8295
|
+
void this.initiate(peerId); // smaller id dials
|
|
8296
|
+
}
|
|
8297
|
+
send(peer, data, channel = 0) {
|
|
8298
|
+
const dc = this.conns.get(peer)?.dc;
|
|
8299
|
+
if (dc?.readyState === "open")
|
|
8300
|
+
void this.sendFragmented(dc, data, channel);
|
|
8301
|
+
}
|
|
8302
|
+
broadcast(data, channel = 0) {
|
|
8303
|
+
for (const [, c] of this.conns) {
|
|
8304
|
+
if (c.dc?.readyState === "open")
|
|
8305
|
+
void this.sendFragmented(c.dc, data, channel);
|
|
8306
|
+
}
|
|
8307
|
+
}
|
|
8308
|
+
onMessage(cb) {
|
|
8309
|
+
this.msgCbs.add(cb);
|
|
8310
|
+
return () => this.msgCbs.delete(cb);
|
|
8311
|
+
}
|
|
8312
|
+
onPeerChange(cb) {
|
|
8313
|
+
this.peerCbs.add(cb);
|
|
8314
|
+
return () => this.peerCbs.delete(cb);
|
|
8315
|
+
}
|
|
8316
|
+
close() {
|
|
8317
|
+
this.offSignal();
|
|
8318
|
+
for (const [, c] of this.conns) {
|
|
8319
|
+
try {
|
|
8320
|
+
c.dc?.close();
|
|
8321
|
+
}
|
|
8322
|
+
catch { /* already closing */ }
|
|
8323
|
+
try {
|
|
8324
|
+
c.pc.close();
|
|
8325
|
+
}
|
|
8326
|
+
catch { /* already closing */ }
|
|
8327
|
+
}
|
|
8328
|
+
this.conns.clear();
|
|
8329
|
+
this.msgCbs.clear();
|
|
8330
|
+
this.peerCbs.clear();
|
|
8331
|
+
}
|
|
8332
|
+
// -- internals -----------------------------------------------------------
|
|
8333
|
+
newPc(peerId) {
|
|
8334
|
+
const pc = new RTCPeerConnection({ iceServers: this.iceServers });
|
|
8335
|
+
const conn = { pc, reasm: new Map() };
|
|
8336
|
+
this.conns.set(peerId, conn);
|
|
8337
|
+
pc.onicecandidate = (e) => {
|
|
8338
|
+
if (e.candidate)
|
|
8339
|
+
this.signaler.send(peerId, "ice", e.candidate.toJSON());
|
|
8340
|
+
};
|
|
8341
|
+
pc.onconnectionstatechange = () => {
|
|
8342
|
+
if (["failed", "closed", "disconnected"].includes(pc.connectionState))
|
|
8343
|
+
this.drop(peerId);
|
|
8344
|
+
};
|
|
8345
|
+
pc.ondatachannel = (e) => this.bindChannel(peerId, conn, e.channel);
|
|
8346
|
+
return conn;
|
|
8347
|
+
}
|
|
8348
|
+
async initiate(peerId) {
|
|
8349
|
+
const conn = this.newPc(peerId);
|
|
8350
|
+
const dc = conn.pc.createDataChannel("blocks");
|
|
8351
|
+
this.bindChannel(peerId, conn, dc);
|
|
8352
|
+
const offer = await conn.pc.createOffer();
|
|
8353
|
+
await conn.pc.setLocalDescription(offer);
|
|
8354
|
+
this.signaler.send(peerId, "offer", offer);
|
|
8355
|
+
}
|
|
8356
|
+
bindChannel(peerId, conn, dc) {
|
|
8357
|
+
dc.binaryType = "arraybuffer";
|
|
8358
|
+
conn.dc = dc;
|
|
8359
|
+
dc.onopen = () => this.firePeerChange();
|
|
8360
|
+
dc.onclose = () => this.drop(peerId);
|
|
8361
|
+
dc.onmessage = (e) => this.onChunk(peerId, conn, new Uint8Array(e.data));
|
|
8362
|
+
}
|
|
8363
|
+
async onSignal(from, kind, data) {
|
|
8364
|
+
if (kind === "offer") {
|
|
8365
|
+
if (!this.conns.has(from) && this.conns.size >= this.maxPeers)
|
|
8366
|
+
return; // mesh cap
|
|
8367
|
+
const conn = this.conns.get(from) ?? this.newPc(from);
|
|
8368
|
+
await conn.pc.setRemoteDescription(data);
|
|
8369
|
+
const answer = await conn.pc.createAnswer();
|
|
8370
|
+
await conn.pc.setLocalDescription(answer);
|
|
8371
|
+
this.signaler.send(from, "answer", answer);
|
|
8372
|
+
}
|
|
8373
|
+
else if (kind === "answer") {
|
|
8374
|
+
const conn = this.conns.get(from);
|
|
8375
|
+
if (conn)
|
|
8376
|
+
await conn.pc.setRemoteDescription(data);
|
|
8377
|
+
}
|
|
8378
|
+
else if (kind === "ice") {
|
|
8379
|
+
const conn = this.conns.get(from);
|
|
8380
|
+
if (conn && data) {
|
|
8381
|
+
try {
|
|
8382
|
+
await conn.pc.addIceCandidate(data);
|
|
8383
|
+
}
|
|
8384
|
+
catch { /* pre-SRD race */ }
|
|
8385
|
+
}
|
|
8386
|
+
}
|
|
8387
|
+
else if (kind === "bye") {
|
|
8388
|
+
this.drop(from);
|
|
8389
|
+
}
|
|
8390
|
+
}
|
|
8391
|
+
drop(peerId) {
|
|
8392
|
+
const conn = this.conns.get(peerId);
|
|
8393
|
+
if (!conn)
|
|
8394
|
+
return;
|
|
8395
|
+
this.conns.delete(peerId);
|
|
8396
|
+
try {
|
|
8397
|
+
conn.dc?.close();
|
|
8398
|
+
}
|
|
8399
|
+
catch { /* already closing */ }
|
|
8400
|
+
try {
|
|
8401
|
+
conn.pc.close();
|
|
8402
|
+
}
|
|
8403
|
+
catch { /* already closing */ }
|
|
8404
|
+
this.firePeerChange();
|
|
8405
|
+
}
|
|
8406
|
+
firePeerChange() {
|
|
8407
|
+
const peers = this.peers();
|
|
8408
|
+
for (const cb of this.peerCbs)
|
|
8409
|
+
cb(peers);
|
|
8410
|
+
}
|
|
8411
|
+
// -- fragmentation -------------------------------------------------------
|
|
8412
|
+
async sendFragmented(dc, data, channel) {
|
|
8413
|
+
const msgId = this.nextMsgId++ >>> 0;
|
|
8414
|
+
const total = Math.max(1, Math.ceil(data.length / CHUNK));
|
|
8415
|
+
for (let i = 0; i < total; i++) {
|
|
8416
|
+
const slice = data.subarray(i * CHUNK, (i + 1) * CHUNK);
|
|
8417
|
+
const frame = new Uint8Array(HEADER$1 + slice.length);
|
|
8418
|
+
const dv = new DataView(frame.buffer);
|
|
8419
|
+
dv.setUint32(0, msgId);
|
|
8420
|
+
dv.setUint32(4, i);
|
|
8421
|
+
dv.setUint32(8, total);
|
|
8422
|
+
dv.setUint32(12, channel);
|
|
8423
|
+
frame.set(slice, HEADER$1);
|
|
8424
|
+
if (dc.bufferedAmount > MAX_BUFFERED)
|
|
8425
|
+
await this.drain(dc);
|
|
8426
|
+
dc.send(frame);
|
|
8427
|
+
}
|
|
8428
|
+
}
|
|
8429
|
+
drain(dc) {
|
|
8430
|
+
return new Promise((resolve) => {
|
|
8431
|
+
dc.bufferedAmountLowThreshold = MAX_BUFFERED / 2;
|
|
8432
|
+
const onLow = () => { dc.removeEventListener("bufferedamountlow", onLow); resolve(); };
|
|
8433
|
+
dc.addEventListener("bufferedamountlow", onLow);
|
|
8434
|
+
});
|
|
8435
|
+
}
|
|
8436
|
+
onChunk(peerId, conn, frame) {
|
|
8437
|
+
if (frame.length < HEADER$1)
|
|
8438
|
+
return;
|
|
8439
|
+
const dv = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
|
|
8440
|
+
const msgId = dv.getUint32(0);
|
|
8441
|
+
const index = dv.getUint32(4);
|
|
8442
|
+
const total = dv.getUint32(8);
|
|
8443
|
+
const channel = dv.getUint32(12);
|
|
8444
|
+
const chunk = frame.slice(HEADER$1);
|
|
8445
|
+
let r = conn.reasm.get(msgId);
|
|
8446
|
+
if (!r) {
|
|
8447
|
+
r = { chunks: new Array(total), received: 0, total, size: 0, channel };
|
|
8448
|
+
conn.reasm.set(msgId, r);
|
|
8449
|
+
}
|
|
8450
|
+
if (r.chunks[index])
|
|
8451
|
+
return; // dup
|
|
8452
|
+
r.chunks[index] = chunk;
|
|
8453
|
+
r.received++;
|
|
8454
|
+
r.size += chunk.length;
|
|
8455
|
+
if (r.received < r.total)
|
|
8456
|
+
return;
|
|
8457
|
+
conn.reasm.delete(msgId);
|
|
8458
|
+
const out = new Uint8Array(r.size);
|
|
8459
|
+
let off = 0;
|
|
8460
|
+
for (const c of r.chunks) {
|
|
8461
|
+
if (!c)
|
|
8462
|
+
return; // gap — shouldn't happen on reliable ordered
|
|
8463
|
+
out.set(c, off);
|
|
8464
|
+
off += c.length;
|
|
8465
|
+
}
|
|
8466
|
+
for (const cb of this.msgCbs)
|
|
8467
|
+
cb(peerId, out, r.channel);
|
|
8468
|
+
}
|
|
8469
|
+
}
|
|
8470
|
+
|
|
8471
|
+
/**
|
|
8472
|
+
* `PeerTransport` — the pluggable wire under the block engine. It moves opaque
|
|
8473
|
+
* frames between Space peers; it knows nothing about blocks, hashes, or the
|
|
8474
|
+
* protocol. {@link ./WebRtcTransport} implements it over RTCDataChannels today;
|
|
8475
|
+
* a future libp2p/Helia transport can implement the same interface without the
|
|
8476
|
+
* block engine or `ShardClient` changing.
|
|
8477
|
+
*
|
|
8478
|
+
* Implementations are responsible for fragmenting/ reassembling frames larger
|
|
8479
|
+
* than the underlying channel's max message size (a 4 MiB shard won't fit in one
|
|
8480
|
+
* `RTCDataChannel.send`); the engine always deals in whole frames.
|
|
8481
|
+
*/
|
|
8482
|
+
/**
|
|
8483
|
+
* Logical channels multiplexed over the one data link. `0` is block exchange
|
|
8484
|
+
* (the engine), `1` is gossip (app/CRDT data). The tag rides the framing header
|
|
8485
|
+
* so payloads aren't copied to prepend it.
|
|
8486
|
+
*/
|
|
8487
|
+
const CHANNEL_BLOCK = 0;
|
|
8488
|
+
const CHANNEL_GOSSIP = 1;
|
|
8489
|
+
|
|
8490
|
+
/**
|
|
8491
|
+
* Bitswap-lite wire protocol for peer block exchange. Four frame types over a
|
|
8492
|
+
* {@link ../transport/PeerTransport} data channel:
|
|
8493
|
+
*
|
|
8494
|
+
* WANT(hash) — "does anyone have this block?" / "send it to me"
|
|
8495
|
+
* HAVE(hash) — "I hold this block" (announce / reply to a WANT)
|
|
8496
|
+
* BLOCK(hash, bytes) — the block itself (verified by hash on receipt)
|
|
8497
|
+
* CANCEL(hash) — "stop, I got it elsewhere"
|
|
8498
|
+
*
|
|
8499
|
+
* Frames are compact binary so a 4 MiB shard isn't base64-bloated through JSON:
|
|
8500
|
+
*
|
|
8501
|
+
* byte 0 : type (0 WANT | 1 HAVE | 2 BLOCK | 3 CANCEL)
|
|
8502
|
+
* bytes 1..33 : 32-byte raw SHA-256 of the block (our shard hash, hex-decoded)
|
|
8503
|
+
* bytes 33.. : payload (BLOCK only)
|
|
8504
|
+
*
|
|
8505
|
+
* Blocks are AES-GCM ciphertext, so a frame carries nothing readable; the hash
|
|
8506
|
+
* is the capability check (the receiver re-hashes the payload and drops a
|
|
8507
|
+
* mismatch). See {@link ./blockEngine}.
|
|
8508
|
+
*/
|
|
8509
|
+
var FrameType;
|
|
8510
|
+
(function (FrameType) {
|
|
8511
|
+
FrameType[FrameType["WANT"] = 0] = "WANT";
|
|
8512
|
+
FrameType[FrameType["HAVE"] = 1] = "HAVE";
|
|
8513
|
+
FrameType[FrameType["BLOCK"] = 2] = "BLOCK";
|
|
8514
|
+
FrameType[FrameType["CANCEL"] = 3] = "CANCEL";
|
|
8515
|
+
})(FrameType || (FrameType = {}));
|
|
8516
|
+
const HASH_BYTES = 32;
|
|
8517
|
+
const HEADER = 1 + HASH_BYTES; // type + hash
|
|
8518
|
+
function hexToBytes(hex) {
|
|
8519
|
+
if (hex.length !== HASH_BYTES * 2) {
|
|
8520
|
+
throw new Error(`protocol: expected a ${HASH_BYTES}-byte hex hash, got ${hex.length / 2} bytes`);
|
|
8521
|
+
}
|
|
8522
|
+
const out = new Uint8Array(HASH_BYTES);
|
|
8523
|
+
for (let i = 0; i < HASH_BYTES; i++)
|
|
8524
|
+
out[i] = parseInt(hex.substr(i * 2, 2), 16);
|
|
8525
|
+
return out;
|
|
8526
|
+
}
|
|
8527
|
+
const HEX = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
|
|
8528
|
+
function bytesToHex(bytes) {
|
|
8529
|
+
let s = "";
|
|
8530
|
+
for (let i = 0; i < bytes.length; i++)
|
|
8531
|
+
s += HEX[bytes[i]];
|
|
8532
|
+
return s;
|
|
8533
|
+
}
|
|
8534
|
+
function header(type, hash, extra = 0) {
|
|
8535
|
+
const buf = new Uint8Array(HEADER + extra);
|
|
8536
|
+
buf[0] = type;
|
|
8537
|
+
buf.set(hexToBytes(hash), 1);
|
|
8538
|
+
return { buf, offset: HEADER };
|
|
8539
|
+
}
|
|
8540
|
+
function encodeWant(hash) {
|
|
8541
|
+
return header(FrameType.WANT, hash).buf;
|
|
8542
|
+
}
|
|
8543
|
+
function encodeHave(hash) {
|
|
8544
|
+
return header(FrameType.HAVE, hash).buf;
|
|
8545
|
+
}
|
|
8546
|
+
function encodeCancel(hash) {
|
|
8547
|
+
return header(FrameType.CANCEL, hash).buf;
|
|
8548
|
+
}
|
|
8549
|
+
function encodeBlock(hash, payload) {
|
|
8550
|
+
const { buf, offset } = header(FrameType.BLOCK, hash, payload.length);
|
|
8551
|
+
buf.set(payload, offset);
|
|
8552
|
+
return buf;
|
|
8553
|
+
}
|
|
8554
|
+
function decode(frame) {
|
|
8555
|
+
if (frame.length < HEADER)
|
|
8556
|
+
throw new Error("protocol: frame too short");
|
|
8557
|
+
const type = frame[0];
|
|
8558
|
+
const hash = bytesToHex(frame.subarray(1, HEADER));
|
|
8559
|
+
if (type === FrameType.BLOCK) {
|
|
8560
|
+
// Copy the payload out so it doesn't alias a transferred/pooled buffer.
|
|
8561
|
+
return { type, hash, payload: frame.slice(HEADER) };
|
|
8562
|
+
}
|
|
8563
|
+
return { type, hash };
|
|
8564
|
+
}
|
|
8565
|
+
|
|
8566
|
+
var protocol = /*#__PURE__*/Object.freeze({
|
|
8567
|
+
__proto__: null,
|
|
8568
|
+
get FrameType () { return FrameType; },
|
|
8569
|
+
decode: decode,
|
|
8570
|
+
encodeBlock: encodeBlock,
|
|
8571
|
+
encodeCancel: encodeCancel,
|
|
8572
|
+
encodeHave: encodeHave,
|
|
8573
|
+
encodeWant: encodeWant
|
|
8574
|
+
});
|
|
8575
|
+
|
|
8576
|
+
/**
|
|
8577
|
+
* `BlockEngine` — the bitswap-lite want/have ledger. Host-agnostic: it speaks
|
|
8578
|
+
* only to a {@link BlockStore} (get/put/has) and an `out` sink that ships
|
|
8579
|
+
* encoded frames to a peer (or `"*"` to broadcast). The host wires `out` and
|
|
8580
|
+
* `handleFrame` to a real transport — on the main thread or inside the Web
|
|
8581
|
+
* Worker (see {@link ./engineClient}) — so this class is pure and unit-testable.
|
|
8582
|
+
*
|
|
8583
|
+
* It never sees keys: blocks are AES-GCM ciphertext, addressed by SHA-256. The
|
|
8584
|
+
* one trust check is integrity — an inbound BLOCK is re-hashed and dropped on
|
|
8585
|
+
* mismatch, so a malicious peer can't poison the store (the requester just
|
|
8586
|
+
* falls back to origin).
|
|
8587
|
+
*/
|
|
8588
|
+
class BlockEngine {
|
|
8589
|
+
store;
|
|
8590
|
+
out;
|
|
8591
|
+
hash;
|
|
8592
|
+
wants = new Map();
|
|
8593
|
+
constructor(store, out, hash) {
|
|
8594
|
+
this.store = store;
|
|
8595
|
+
this.out = out;
|
|
8596
|
+
this.hash = hash;
|
|
8597
|
+
}
|
|
8598
|
+
/**
|
|
8599
|
+
* Ask the swarm for a block. Resolves with the verified bytes, or `null` on
|
|
8600
|
+
* timeout (caller then falls back to origin). Returns immediately if we
|
|
8601
|
+
* already hold it; concurrent wants for the same hash share one request.
|
|
8602
|
+
*/
|
|
8603
|
+
want(hash, timeoutMs) {
|
|
8604
|
+
return new Promise((resolve) => {
|
|
8605
|
+
void this.store.get(hash).then((local) => {
|
|
8606
|
+
if (local)
|
|
8607
|
+
return resolve(local);
|
|
8608
|
+
const existing = this.wants.get(hash);
|
|
8609
|
+
if (existing) {
|
|
8610
|
+
existing.resolvers.push(resolve);
|
|
8611
|
+
return;
|
|
8612
|
+
}
|
|
8613
|
+
const timer = setTimeout(() => {
|
|
8614
|
+
const w = this.wants.get(hash);
|
|
8615
|
+
this.wants.delete(hash);
|
|
8616
|
+
this.out("*", encodeCancel(hash));
|
|
8617
|
+
w?.resolvers.forEach((r) => r(null));
|
|
8618
|
+
}, timeoutMs);
|
|
8619
|
+
this.wants.set(hash, { resolvers: [resolve], timer });
|
|
8620
|
+
this.out("*", encodeWant(hash));
|
|
8621
|
+
});
|
|
8622
|
+
});
|
|
8623
|
+
}
|
|
8624
|
+
/** Tell the swarm we now hold `hash` (so peers wanting it can pull from us). */
|
|
8625
|
+
announce(hash) {
|
|
8626
|
+
this.out("*", encodeHave(hash));
|
|
8627
|
+
}
|
|
8628
|
+
/** Handle one inbound frame from `peer`. */
|
|
8629
|
+
async handleFrame(peer, frame) {
|
|
8630
|
+
let f;
|
|
8631
|
+
try {
|
|
8632
|
+
f = decode(frame);
|
|
8633
|
+
}
|
|
8634
|
+
catch {
|
|
8635
|
+
return; // malformed — ignore
|
|
8636
|
+
}
|
|
8637
|
+
switch (f.type) {
|
|
8638
|
+
case FrameType.WANT: {
|
|
8639
|
+
const bytes = await this.store.get(f.hash);
|
|
8640
|
+
if (bytes)
|
|
8641
|
+
this.out(peer, encodeBlock(f.hash, bytes));
|
|
8642
|
+
return;
|
|
8643
|
+
}
|
|
8644
|
+
case FrameType.HAVE: {
|
|
8645
|
+
// A peer announced a block we're still waiting for → pull from them.
|
|
8646
|
+
if (this.wants.has(f.hash))
|
|
8647
|
+
this.out(peer, encodeWant(f.hash));
|
|
8648
|
+
return;
|
|
8649
|
+
}
|
|
8650
|
+
case FrameType.BLOCK: {
|
|
8651
|
+
const pending = this.wants.get(f.hash);
|
|
8652
|
+
if (!pending || !f.payload)
|
|
8653
|
+
return; // unsolicited / empty — ignore
|
|
8654
|
+
// Integrity: a peer can't poison the store. Drop a mismatch and
|
|
8655
|
+
// keep waiting (the timeout will fall back to origin).
|
|
8656
|
+
const got = await this.hash(f.payload);
|
|
8657
|
+
if (got !== f.hash)
|
|
8658
|
+
return;
|
|
8659
|
+
await this.store.put(f.hash, f.payload);
|
|
8660
|
+
this.wants.delete(f.hash);
|
|
8661
|
+
clearTimeout(pending.timer);
|
|
8662
|
+
this.out("*", encodeCancel(f.hash)); // others can stop sending
|
|
8663
|
+
pending.resolvers.forEach((r) => r(f.payload));
|
|
8664
|
+
return;
|
|
8665
|
+
}
|
|
8666
|
+
case FrameType.CANCEL:
|
|
8667
|
+
// We serve WANTs synchronously, so there's nothing in flight to
|
|
8668
|
+
// cancel yet. Reserved for chunked-send cancellation later.
|
|
8669
|
+
return;
|
|
8670
|
+
}
|
|
8671
|
+
}
|
|
8672
|
+
/** Drop all pending waits (resolve null) — on teardown. */
|
|
8673
|
+
close() {
|
|
8674
|
+
for (const [, w] of this.wants) {
|
|
8675
|
+
clearTimeout(w.timer);
|
|
8676
|
+
w.resolvers.forEach((r) => r(null));
|
|
8677
|
+
}
|
|
8678
|
+
this.wants.clear();
|
|
8679
|
+
}
|
|
8680
|
+
}
|
|
8681
|
+
|
|
8682
|
+
/**
|
|
8683
|
+
* `EngineHost` — the boundary {@link ../PeerNetwork} wires a transport to. It
|
|
8684
|
+
* abstracts *where* the {@link ./blockEngine.BlockEngine} runs:
|
|
8685
|
+
* - {@link LocalEngineHost} runs it on the calling thread (tests, or a
|
|
8686
|
+
* fallback when the Web Worker can't be built by a consumer's bundler);
|
|
8687
|
+
* - the Worker proxy ({@link ./engineClient}) runs it off-thread.
|
|
8688
|
+
*
|
|
8689
|
+
* Either way the host exposes the same surface: drive `want`/`announce`, feed it
|
|
8690
|
+
* inbound frames via `handleFrame`, and receive its outbound frames via
|
|
8691
|
+
* `onOutbound` (which the network forwards to the transport).
|
|
8692
|
+
*/
|
|
8693
|
+
/** Runs the engine in-process (main thread or test). */
|
|
8694
|
+
class LocalEngineHost {
|
|
8695
|
+
engine;
|
|
8696
|
+
cbs = new Set();
|
|
8697
|
+
constructor(store, hasher) {
|
|
8698
|
+
this.engine = new BlockEngine(store, (target, frame) => {
|
|
8699
|
+
for (const cb of this.cbs)
|
|
8700
|
+
cb(target, frame);
|
|
8701
|
+
}, hasher);
|
|
8702
|
+
}
|
|
8703
|
+
want(hash, timeoutMs) {
|
|
8704
|
+
return this.engine.want(hash, timeoutMs);
|
|
8705
|
+
}
|
|
8706
|
+
announce(hash) {
|
|
8707
|
+
this.engine.announce(hash);
|
|
8708
|
+
}
|
|
8709
|
+
handleFrame(peer, frame) {
|
|
8710
|
+
void this.engine.handleFrame(peer, frame);
|
|
8711
|
+
}
|
|
8712
|
+
onOutbound(cb) {
|
|
8713
|
+
this.cbs.add(cb);
|
|
8714
|
+
return () => this.cbs.delete(cb);
|
|
8715
|
+
}
|
|
8716
|
+
close() {
|
|
8717
|
+
this.engine.close();
|
|
8718
|
+
this.cbs.clear();
|
|
8719
|
+
}
|
|
8720
|
+
}
|
|
8721
|
+
|
|
8722
|
+
/**
|
|
8723
|
+
* `WorkerEngineHost` — the main-thread proxy to a {@link ./blockEngine.BlockEngine}
|
|
8724
|
+
* running inside a dedicated Web Worker (so hashing, blockstore I/O, and the
|
|
8725
|
+
* protocol codec stay off the main thread). It speaks the small message protocol
|
|
8726
|
+
* in {@link ./blockEngine.worker} and satisfies {@link ./engineHost.EngineHost},
|
|
8727
|
+
* so {@link ../PeerNetwork} treats it identically to the in-process host.
|
|
8728
|
+
*
|
|
8729
|
+
* Block bytes cross the worker boundary as **transferable** ArrayBuffers
|
|
8730
|
+
* (zero-copy). Only encrypted blocks ever cross — keys never enter the worker.
|
|
8731
|
+
*/
|
|
8732
|
+
class WorkerEngineHost {
|
|
8733
|
+
worker;
|
|
8734
|
+
wants = new Map();
|
|
8735
|
+
cbs = new Set();
|
|
8736
|
+
nextId = 1;
|
|
8737
|
+
constructor(worker) {
|
|
8738
|
+
this.worker = worker;
|
|
8739
|
+
this.worker.onmessage = (e) => {
|
|
8740
|
+
const m = e.data;
|
|
8741
|
+
if (m.t === "out") {
|
|
8742
|
+
const frame = new Uint8Array(m.data);
|
|
8743
|
+
for (const cb of this.cbs)
|
|
8744
|
+
cb(m.target, frame);
|
|
8745
|
+
}
|
|
8746
|
+
else if (m.t === "want-result") {
|
|
8747
|
+
const resolve = this.wants.get(m.id);
|
|
8748
|
+
if (resolve) {
|
|
8749
|
+
this.wants.delete(m.id);
|
|
8750
|
+
resolve(m.data ? new Uint8Array(m.data) : null);
|
|
8751
|
+
}
|
|
8752
|
+
}
|
|
8753
|
+
};
|
|
8754
|
+
}
|
|
8755
|
+
want(hash, timeoutMs) {
|
|
8756
|
+
return new Promise((resolve) => {
|
|
8757
|
+
const id = this.nextId++;
|
|
8758
|
+
this.wants.set(id, resolve);
|
|
8759
|
+
this.worker.postMessage({ t: "want", id, hash, timeout: timeoutMs });
|
|
8760
|
+
});
|
|
8761
|
+
}
|
|
8762
|
+
announce(hash) {
|
|
8763
|
+
this.worker.postMessage({ t: "announce", hash });
|
|
8764
|
+
}
|
|
8765
|
+
handleFrame(peer, frame) {
|
|
8766
|
+
// Transfer an exact-size buffer; copy if the view doesn't own the whole buffer.
|
|
8767
|
+
const buf = frame.byteOffset === 0 && frame.byteLength === frame.buffer.byteLength
|
|
8768
|
+
? frame.buffer
|
|
8769
|
+
: frame.slice().buffer;
|
|
8770
|
+
this.worker.postMessage({ t: "frame", peer, data: buf }, [buf]);
|
|
8771
|
+
}
|
|
8772
|
+
onOutbound(cb) {
|
|
8773
|
+
this.cbs.add(cb);
|
|
8774
|
+
return () => this.cbs.delete(cb);
|
|
8775
|
+
}
|
|
8776
|
+
close() {
|
|
8777
|
+
try {
|
|
8778
|
+
this.worker.postMessage({ t: "close" });
|
|
8779
|
+
}
|
|
8780
|
+
catch { /* already gone */ }
|
|
8781
|
+
this.worker.terminate();
|
|
8782
|
+
this.wants.forEach((r) => r(null));
|
|
8783
|
+
this.wants.clear();
|
|
8784
|
+
this.cbs.clear();
|
|
8785
|
+
}
|
|
8786
|
+
}
|
|
8787
|
+
|
|
8788
|
+
/**
|
|
8789
|
+
* `PeerNetwork` — the per-Space orchestrator that ties the pieces together on
|
|
8790
|
+
* the main thread:
|
|
8791
|
+
*
|
|
8792
|
+
* transport (WebRTC) ⇄ engine host (Worker or in-process) → PeerExchange
|
|
8793
|
+
* ▲ │
|
|
8794
|
+
* └────────────── signaling over the Space relay ─────────────┘
|
|
8795
|
+
*
|
|
8796
|
+
* It owns one {@link ./transport/WebRtcTransport} + one {@link
|
|
8797
|
+
* ./worker/engineHost.EngineHost}, wires inbound frames to the engine and the
|
|
8798
|
+
* engine's outbound frames back to the transport, runs peer discovery over the
|
|
8799
|
+
* Space's ephemeral channel, and exposes a {@link ./PeerExchange.PeerExchange}
|
|
8800
|
+
* for `ShardClient` to consult. Scope is one Space (private swarm).
|
|
8801
|
+
*
|
|
8802
|
+
* `engineHost` defaults to in-process; pass `workerFactory` to run the block
|
|
8803
|
+
* engine off the main thread (the consumer's bundler resolves the worker URL).
|
|
8804
|
+
*/
|
|
8805
|
+
/**
|
|
8806
|
+
* Build the engine host, preferring an off-thread Web Worker:
|
|
8807
|
+
* 1. an explicit `workerFactory` (most reliable in app bundlers), else
|
|
8808
|
+
* 2. the bundled worker resolved relative to this module (works where the
|
|
8809
|
+
* bundler supports `new Worker(new URL(...))`, e.g. Vite builds), else
|
|
8810
|
+
* 3. the in-process engine (always works; tests + non-worker runtimes).
|
|
8811
|
+
* Any worker construction failure degrades to in-process.
|
|
8812
|
+
*/
|
|
8813
|
+
function buildEngineHost(opts) {
|
|
8814
|
+
try {
|
|
8815
|
+
const worker = opts.workerFactory ? opts.workerFactory() : defaultWorker();
|
|
8816
|
+
if (worker)
|
|
8817
|
+
return new WorkerEngineHost(worker);
|
|
8818
|
+
}
|
|
8819
|
+
catch {
|
|
8820
|
+
/* no Worker global / bundler can't resolve it → in-process */
|
|
8821
|
+
}
|
|
8822
|
+
return new LocalEngineHost(opts.store ?? new ShardCache(), shardHash);
|
|
8823
|
+
}
|
|
8824
|
+
/** Construct the bundled worker if the runtime + bundler support it, else null. */
|
|
8825
|
+
function defaultWorker() {
|
|
8826
|
+
try {
|
|
8827
|
+
if (typeof Worker === "undefined")
|
|
8828
|
+
return null;
|
|
8829
|
+
// The string is resolved by the consumer's bundler relative to the
|
|
8830
|
+
// built connect module (sibling of dist/browser/index.js).
|
|
8831
|
+
return new Worker(new URL("./blockEngine.worker.js", import.meta.url), { type: "module" });
|
|
8832
|
+
}
|
|
8833
|
+
catch {
|
|
8834
|
+
return null;
|
|
8835
|
+
}
|
|
8836
|
+
}
|
|
8837
|
+
class PeerNetwork {
|
|
8838
|
+
/** Pass this to a `ShardClient` as `peers`. */
|
|
8839
|
+
exchange;
|
|
8840
|
+
transport;
|
|
8841
|
+
host;
|
|
8842
|
+
signaler;
|
|
8843
|
+
offs = [];
|
|
8844
|
+
gossipCbs = new Set();
|
|
8845
|
+
closed = false;
|
|
8846
|
+
constructor(opts) {
|
|
8847
|
+
this.signaler = new SpaceSignaler(opts.space, opts.myId);
|
|
8848
|
+
this.transport =
|
|
8849
|
+
opts.transport ??
|
|
8850
|
+
new WebRtcTransport(this.signaler, opts.myId, {
|
|
8851
|
+
iceServers: opts.iceServers,
|
|
8852
|
+
maxPeers: opts.maxPeers,
|
|
8853
|
+
});
|
|
8854
|
+
this.host = buildEngineHost(opts);
|
|
8855
|
+
// Wire transport ⇄ engine, demuxing block (engine) from gossip (app).
|
|
8856
|
+
this.offs.push(this.transport.onMessage((peer, data, channel) => {
|
|
8857
|
+
if (channel === CHANNEL_GOSSIP) {
|
|
8858
|
+
for (const cb of this.gossipCbs)
|
|
8859
|
+
cb(peer, data);
|
|
8860
|
+
}
|
|
8861
|
+
else {
|
|
8862
|
+
this.host.handleFrame(peer, data);
|
|
8863
|
+
}
|
|
8864
|
+
}));
|
|
8865
|
+
this.offs.push(this.host.onOutbound((target, frame) => target === "*"
|
|
8866
|
+
? this.transport.broadcast(frame, CHANNEL_BLOCK)
|
|
8867
|
+
: this.transport.send(target, frame, CHANNEL_BLOCK)));
|
|
8868
|
+
// Discovery: a broadcast hello triggers a unicast hello back (so a
|
|
8869
|
+
// newcomer learns existing members); both sides connectTo (the smaller
|
|
8870
|
+
// id actually dials). Deterministic + idempotent.
|
|
8871
|
+
this.offs.push(this.signaler.onSignal((from, kind, data) => {
|
|
8872
|
+
if (kind !== "hello")
|
|
8873
|
+
return;
|
|
8874
|
+
this.transport.connectTo?.(from);
|
|
8875
|
+
if (data?.broadcast) {
|
|
8876
|
+
this.signaler.send(from, "hello", { broadcast: false });
|
|
8877
|
+
}
|
|
8878
|
+
}));
|
|
8879
|
+
this.exchange = new PeerExchange(this.host, { timeoutMs: opts.timeoutMs });
|
|
8880
|
+
}
|
|
8881
|
+
/**
|
|
8882
|
+
* Begin peer discovery — broadcast a hello so existing members dial us and
|
|
8883
|
+
* we learn them. Call once the signaling channel is live (the Space fires
|
|
8884
|
+
* this on CONNECTED); safe to call again on reconnect.
|
|
8885
|
+
*/
|
|
8886
|
+
start() {
|
|
8887
|
+
if (this.closed)
|
|
8888
|
+
return;
|
|
8889
|
+
this.signaler.broadcast("hello", { broadcast: true });
|
|
8890
|
+
}
|
|
8891
|
+
/**
|
|
8892
|
+
* Broadcast an app payload to all connected peers over the gossip channel —
|
|
8893
|
+
* a direct, server-bypassing data path for CRDT ops / high-frequency state
|
|
8894
|
+
* (cursors, presence, collaborative edits). Single-hop (no re-forwarding).
|
|
8895
|
+
*/
|
|
8896
|
+
gossip(data) {
|
|
8897
|
+
this.transport.broadcast(data, CHANNEL_GOSSIP);
|
|
8898
|
+
}
|
|
8899
|
+
/** Subscribe to gossip from peers. Returns an unsubscribe function. */
|
|
8900
|
+
onGossip(cb) {
|
|
8901
|
+
this.gossipCbs.add(cb);
|
|
8902
|
+
return () => this.gossipCbs.delete(cb);
|
|
8903
|
+
}
|
|
8904
|
+
/** Connected peer count (open data channels). */
|
|
8905
|
+
peerCount() {
|
|
8906
|
+
return this.transport.peers().length;
|
|
8907
|
+
}
|
|
8908
|
+
close() {
|
|
8909
|
+
if (this.closed)
|
|
8910
|
+
return;
|
|
8911
|
+
this.closed = true;
|
|
8912
|
+
try {
|
|
8913
|
+
this.signaler.broadcast("bye");
|
|
8914
|
+
}
|
|
8915
|
+
catch { /* socket may be gone */ }
|
|
8916
|
+
for (const off of this.offs)
|
|
8917
|
+
off();
|
|
8918
|
+
this.transport.close();
|
|
8919
|
+
this.host.close();
|
|
8920
|
+
}
|
|
8921
|
+
}
|
|
8922
|
+
|
|
8923
|
+
/**
|
|
8924
|
+
* Whether this environment can do WebRTC peering. `RTCPeerConnection` is
|
|
8925
|
+
* Window-only (absent in Node/Workers/SSR), so this gates whether a
|
|
8926
|
+
* {@link ./PeerNetwork} can be built at all.
|
|
8927
|
+
*/
|
|
8928
|
+
function isP2pCapable() {
|
|
8929
|
+
try {
|
|
8930
|
+
return typeof RTCPeerConnection !== "undefined";
|
|
8931
|
+
}
|
|
8932
|
+
catch {
|
|
8933
|
+
return false;
|
|
8934
|
+
}
|
|
8935
|
+
}
|
|
8936
|
+
|
|
8937
|
+
/**
|
|
8938
|
+
* `client.space` — the fan-out group-encryption surface.
|
|
8939
|
+
*
|
|
8940
|
+
* const space = await client.space.createSpace({ historyPolicy: "static" });
|
|
8941
|
+
* await space.sendMessage({ text: "hello" });
|
|
8942
|
+
* space.onMessage(e => …);
|
|
8943
|
+
*
|
|
8944
|
+
* const joined = await client.space.joinSpace(space.id); // newcomer
|
|
8945
|
+
*
|
|
8946
|
+
* A space owns a client-generated keypair whose public key is its id, a
|
|
8947
|
+
* symmetric group key distributed server-blind via the keyring, and a
|
|
8948
|
+
* persisted message log. This namespace wires the {@link Space} handle to the
|
|
8949
|
+
* HTTP {@link KeyringClient}, the member's identity keys (from {@link KeyStore},
|
|
8950
|
+
* shared with the Double Ratchet path), and a PersonalSpace-backed key cache.
|
|
8951
|
+
*/
|
|
8952
|
+
/** Thrown by `joinChannel` when no channel with that name is registered. */
|
|
8953
|
+
class ChannelNotFoundError extends Error {
|
|
8954
|
+
channelName;
|
|
8955
|
+
constructor(channelName) {
|
|
8956
|
+
super(`Channel "${channelName}" does not exist. Create it first.`);
|
|
8957
|
+
this.channelName = channelName;
|
|
8958
|
+
this.name = "ChannelNotFoundError";
|
|
8959
|
+
}
|
|
8960
|
+
}
|
|
8961
|
+
/** Thrown by `createChannel` when the name is already registered. */
|
|
8962
|
+
class ChannelExistsError extends Error {
|
|
8963
|
+
channelName;
|
|
8964
|
+
constructor(channelName) {
|
|
8965
|
+
super(`Channel "${channelName}" already exists.`);
|
|
8966
|
+
this.channelName = channelName;
|
|
8967
|
+
this.name = "ChannelExistsError";
|
|
8968
|
+
}
|
|
8969
|
+
}
|
|
8970
|
+
class SpaceNamespace {
|
|
8971
|
+
deps;
|
|
8972
|
+
spaceCache;
|
|
8973
|
+
/** Spaces built this session, by id — used to route offline send replays. */
|
|
8974
|
+
openSpaces = new Map();
|
|
8975
|
+
constructor(deps) {
|
|
8976
|
+
this.deps = deps;
|
|
8977
|
+
if (deps.offline?.enabled) {
|
|
8978
|
+
this.spaceCache = new SpaceCache(deps.offline);
|
|
8979
|
+
// Replay queued space frames on reconnect: route each to its space
|
|
8980
|
+
// (reopening + connecting it if needed), then re-send the raw frame.
|
|
8981
|
+
deps.offline.registerReplayer("space", async (entry) => {
|
|
8982
|
+
const { spaceId, frame } = entry.args;
|
|
8983
|
+
let space = this.openSpaces.get(spaceId);
|
|
8984
|
+
if (!space)
|
|
8985
|
+
space = await this.get(spaceId);
|
|
8986
|
+
await space.connect();
|
|
8987
|
+
if (!space.isConnected())
|
|
8988
|
+
throw new Error(`space ${spaceId} not connected for replay`);
|
|
8989
|
+
space.sendRaw(frame);
|
|
8990
|
+
});
|
|
8991
|
+
}
|
|
8992
|
+
}
|
|
8993
|
+
/**
|
|
8994
|
+
* Create a new space: generate its keypair (public key = id), register
|
|
8995
|
+
* metadata, mint the initial group key, and connect. Returns the open Space.
|
|
8996
|
+
*/
|
|
8997
|
+
async createSpace(opts = {}) {
|
|
8998
|
+
const historyPolicy = opts.historyPolicy ?? "static";
|
|
8999
|
+
const identity = await generateSpaceIdentity();
|
|
9000
|
+
// Register the space (server records pubkey + policy; the DO is lazy).
|
|
9001
|
+
// For a private space the server also records the creator (this user) as
|
|
9002
|
+
// its first member — so the metadata POST must carry the user session.
|
|
9003
|
+
await this.deps.http.post(`/api/spaces/${encodeURIComponent(identity.id)}/metadata`, { spacePubKey: identity.id, historyPolicy, visibility: opts.private ? "private" : "public" });
|
|
9004
|
+
const space = await this.build(identity.id, historyPolicy);
|
|
9005
|
+
await space.create();
|
|
9006
|
+
return space;
|
|
9007
|
+
}
|
|
9008
|
+
/** Join an existing space by id (its public key). Resolves once a key arrives. */
|
|
9009
|
+
async joinSpace(spaceId, opts = {}) {
|
|
9010
|
+
const policy = await this.fetchPolicy(spaceId);
|
|
9011
|
+
const space = await this.build(spaceId, policy);
|
|
9012
|
+
await space.join({ timeoutMs: opts.timeoutMs });
|
|
9013
|
+
return space;
|
|
9014
|
+
}
|
|
9015
|
+
/** A handle to a space without auto-connecting (uses cached keys on demand). */
|
|
9016
|
+
async get(spaceId, historyPolicy = "static") {
|
|
9017
|
+
return this.build(spaceId, historyPolicy);
|
|
9018
|
+
}
|
|
9019
|
+
// -------------------------------------------------------------------------
|
|
9020
|
+
// Channels — named, app-scoped pointers to spaces. The name → space-id
|
|
9021
|
+
// directory is app-public config (server-readable, scoped to this app via
|
|
9022
|
+
// its key); the channel CONTENTS stay fan-out E2E-encrypted. Lets chat use
|
|
9023
|
+
// human room names (`general`) instead of raw pubkey ids.
|
|
9024
|
+
// -------------------------------------------------------------------------
|
|
9025
|
+
/** List this app's channels (name → space-id) for the key's environment. */
|
|
9026
|
+
async listChannels() {
|
|
9027
|
+
const body = await this.deps.http.get("/api/app/channels");
|
|
9028
|
+
return body.channels ?? [];
|
|
9029
|
+
}
|
|
9030
|
+
/** Resolve a channel name to its space id, or null if it doesn't exist. */
|
|
9031
|
+
async resolveChannel(name) {
|
|
9032
|
+
try {
|
|
9033
|
+
const body = await this.deps.http.get(`/api/app/channels/${encodeURIComponent(name)}`);
|
|
9034
|
+
return body.spaceId ?? null;
|
|
9035
|
+
}
|
|
9036
|
+
catch (err) {
|
|
9037
|
+
if (err instanceof HttpError && err.status === 404)
|
|
9038
|
+
return null;
|
|
9039
|
+
throw err;
|
|
9040
|
+
}
|
|
9041
|
+
}
|
|
9042
|
+
/**
|
|
9043
|
+
* Create a new channel: mint a fan-out space (the caller becomes its first
|
|
9044
|
+
* key-holder) and register `name → spaceId` in the app directory. Throws
|
|
9045
|
+
* {@link ChannelExistsError} if the name is already taken.
|
|
9046
|
+
*/
|
|
9047
|
+
async createChannel(name, opts = {}) {
|
|
9048
|
+
const space = await this.createSpace(opts);
|
|
9049
|
+
// Register name → id first so a name clash doesn't leave an admitted keeper.
|
|
9050
|
+
try {
|
|
9051
|
+
await this.deps.http.post("/api/app/channels", { name, spaceId: space.id, visibility: opts.private ? "private" : "public" });
|
|
9052
|
+
}
|
|
9053
|
+
catch (err) {
|
|
9054
|
+
if (err instanceof HttpError && err.status === 409) {
|
|
9055
|
+
throw new ChannelExistsError(name);
|
|
9056
|
+
}
|
|
9057
|
+
throw err;
|
|
9058
|
+
}
|
|
9059
|
+
// Admit the app keeper so the channel is joinable with no member online.
|
|
9060
|
+
// Best-effort: a channel still works (members admit each other) without it.
|
|
9061
|
+
try {
|
|
9062
|
+
const { pubkey } = await this.deps.http.get("/api/app/keeper");
|
|
9063
|
+
if (pubkey && space.keyring)
|
|
9064
|
+
await space.keyring.admit(KEEPER_MEMBER_ID, pubkey);
|
|
9065
|
+
}
|
|
9066
|
+
catch (err) {
|
|
9067
|
+
console.warn("[client.space] keeper admit failed (channel still usable):", err);
|
|
7612
9068
|
}
|
|
7613
9069
|
return space;
|
|
7614
9070
|
}
|
|
7615
|
-
/**
|
|
7616
|
-
* Join an existing channel by name. Throws {@link ChannelNotFoundError} if
|
|
7617
|
-
* no channel with that name is registered (use {@link createChannel} first).
|
|
7618
|
-
*/
|
|
7619
|
-
async joinChannel(name, opts = {}) {
|
|
7620
|
-
const spaceId = await this.resolveChannel(name);
|
|
7621
|
-
if (!spaceId)
|
|
7622
|
-
throw new ChannelNotFoundError(name);
|
|
7623
|
-
return this.joinSpace(spaceId, opts);
|
|
9071
|
+
/**
|
|
9072
|
+
* Join an existing channel by name. Throws {@link ChannelNotFoundError} if
|
|
9073
|
+
* no channel with that name is registered (use {@link createChannel} first).
|
|
9074
|
+
*/
|
|
9075
|
+
async joinChannel(name, opts = {}) {
|
|
9076
|
+
const spaceId = await this.resolveChannel(name);
|
|
9077
|
+
if (!spaceId)
|
|
9078
|
+
throw new ChannelNotFoundError(name);
|
|
9079
|
+
return this.joinSpace(spaceId, opts);
|
|
9080
|
+
}
|
|
9081
|
+
// -------------------------------------------------------------------------
|
|
9082
|
+
// Invite links — shareable, keeper-gated invitations to a space. The token
|
|
9083
|
+
// is a capability: anyone signed in who redeems it is admitted by the
|
|
9084
|
+
// keeper. Mint/list/revoke require the caller to be the space's creator or
|
|
9085
|
+
// an existing member.
|
|
9086
|
+
// -------------------------------------------------------------------------
|
|
9087
|
+
/** Mint a shareable invite link for a space. `opts.expiresInSec` / `maxUses`
|
|
9088
|
+
* bound it (0/undefined = unlimited); `opts.role` ("viewer"|"editor") is the
|
|
9089
|
+
* access granted to redeemers (default viewer). Returns the link. */
|
|
9090
|
+
async createInviteLink(spaceId, opts = {}) {
|
|
9091
|
+
return this.keyringFor(spaceId).createInviteLink(opts);
|
|
9092
|
+
}
|
|
9093
|
+
/** Set a member's role on a space ("viewer" | "editor"). Owner-only. */
|
|
9094
|
+
async setMemberRole(spaceId, username, role) {
|
|
9095
|
+
await this.keyringFor(spaceId).setMemberRole(username, role);
|
|
9096
|
+
}
|
|
9097
|
+
/** Roster with roles (owner/members only). */
|
|
9098
|
+
async members(spaceId) {
|
|
9099
|
+
return this.keyringFor(spaceId).members();
|
|
9100
|
+
}
|
|
9101
|
+
/** List a space's active invite links (creator/members only). */
|
|
9102
|
+
async listInviteLinks(spaceId) {
|
|
9103
|
+
return this.keyringFor(spaceId).listInviteLinks();
|
|
9104
|
+
}
|
|
9105
|
+
/** Revoke an invite link by token. */
|
|
9106
|
+
async revokeInviteLink(spaceId, token) {
|
|
9107
|
+
await this.keyringFor(spaceId).revokeInviteLink(token);
|
|
9108
|
+
}
|
|
9109
|
+
/**
|
|
9110
|
+
* Join a space using an invite link: redeem the token (the keeper admits
|
|
9111
|
+
* this member), then connect and resolve once the group key arrives.
|
|
9112
|
+
* Returns the open {@link Space}.
|
|
9113
|
+
*/
|
|
9114
|
+
async joinByInvite(spaceId, token, opts = {}) {
|
|
9115
|
+
const memberId = this.myId();
|
|
9116
|
+
const { ecdhPub, ecdsaPub } = await this.ensureIdentity(memberId);
|
|
9117
|
+
await this.keyringFor(spaceId).redeemInvite({ token, identityEcdhPub: ecdhPub, identityEcdsaPub: ecdsaPub });
|
|
9118
|
+
return this.joinSpace(spaceId, opts);
|
|
9119
|
+
}
|
|
9120
|
+
/** The current member roster of a space (member id → identity public keys). */
|
|
9121
|
+
async roster(spaceId) {
|
|
9122
|
+
return this.keyringFor(spaceId).fetchRoster();
|
|
9123
|
+
}
|
|
9124
|
+
keyringFor(spaceId) {
|
|
9125
|
+
return new KeyringClient({
|
|
9126
|
+
spaceId,
|
|
9127
|
+
httpBaseUrl: this.deps.http.baseUrl,
|
|
9128
|
+
fetch: this.deps.http.fetch,
|
|
9129
|
+
});
|
|
9130
|
+
}
|
|
9131
|
+
// -------------------------------------------------------------------------
|
|
9132
|
+
// Internals
|
|
9133
|
+
// -------------------------------------------------------------------------
|
|
9134
|
+
async build(spaceId, historyPolicy) {
|
|
9135
|
+
const memberId = this.myId();
|
|
9136
|
+
const { ecdhPub, ecdsaPub } = await this.ensureIdentity(memberId);
|
|
9137
|
+
const transport = new KeyringClient({
|
|
9138
|
+
spaceId,
|
|
9139
|
+
httpBaseUrl: this.deps.http.baseUrl,
|
|
9140
|
+
fetch: this.deps.http.fetch,
|
|
9141
|
+
});
|
|
9142
|
+
const keyring = new SpaceKeyring({
|
|
9143
|
+
spaceId,
|
|
9144
|
+
memberId,
|
|
9145
|
+
identityEcdhPub: ecdhPub,
|
|
9146
|
+
identityEcdsaPub: ecdsaPub,
|
|
9147
|
+
ownPrivateKey: () => KeyStore.getInstance().getKeyPair(memberId)?.privateKey ?? null,
|
|
9148
|
+
transport,
|
|
9149
|
+
cache: this.deps.cache,
|
|
9150
|
+
historyPolicy,
|
|
9151
|
+
});
|
|
9152
|
+
const space = new Space({
|
|
9153
|
+
name: spaceId,
|
|
9154
|
+
wsBaseUrl: this.deps.wsBaseUrl,
|
|
9155
|
+
httpBaseUrl: this.deps.http.baseUrl,
|
|
9156
|
+
fetch: this.deps.http.fetch,
|
|
9157
|
+
myId: () => memberId,
|
|
9158
|
+
fetchTicket: () => this.fetchTicket(),
|
|
9159
|
+
keyring,
|
|
9160
|
+
historyPolicy,
|
|
9161
|
+
offline: this.spaceCache,
|
|
9162
|
+
shardCache: this.deps.offline?.fileCache,
|
|
9163
|
+
deferShardUpload: this.deps.offline
|
|
9164
|
+
? (hash) => this.deps.offline.deferShardUpload(hash)
|
|
9165
|
+
: undefined,
|
|
9166
|
+
});
|
|
9167
|
+
this.openSpaces.set(spaceId, space);
|
|
9168
|
+
// Attach a private P2P swarm scoped to this Space (block exchange among
|
|
9169
|
+
// members; signaled over the Space relay). Discovery starts on connect.
|
|
9170
|
+
if (this.deps.p2p?.enabled && isP2pCapable()) {
|
|
9171
|
+
space.attachPeerNetwork(new PeerNetwork({
|
|
9172
|
+
space,
|
|
9173
|
+
myId: memberId,
|
|
9174
|
+
workerFactory: this.deps.p2p.workerFactory,
|
|
9175
|
+
iceServers: this.deps.p2p.iceServers,
|
|
9176
|
+
maxPeers: this.deps.p2p.maxPeers,
|
|
9177
|
+
}));
|
|
9178
|
+
}
|
|
9179
|
+
return space;
|
|
9180
|
+
}
|
|
9181
|
+
/** Ensure the member has an identity keypair; return its public ECDH + ECDSA keys. */
|
|
9182
|
+
async ensureIdentity(memberId) {
|
|
9183
|
+
const store = KeyStore.getInstance();
|
|
9184
|
+
if (!store.getKeyPair(memberId)) {
|
|
9185
|
+
await store.generateOwnKeyPair(memberId);
|
|
9186
|
+
}
|
|
9187
|
+
const ecdhPub = await exportEcdhPublicKey(store.getKeyPair(memberId).publicKey);
|
|
9188
|
+
const ecdsaPub = await exportEcdsaPublicKey(store.getAuthKeyPair(memberId).publicKey);
|
|
9189
|
+
return { ecdhPub, ecdsaPub };
|
|
9190
|
+
}
|
|
9191
|
+
async fetchPolicy(spaceId) {
|
|
9192
|
+
const meta = await new KeyringClient({
|
|
9193
|
+
spaceId,
|
|
9194
|
+
httpBaseUrl: this.deps.http.baseUrl,
|
|
9195
|
+
fetch: this.deps.http.fetch,
|
|
9196
|
+
}).fetchMetadata();
|
|
9197
|
+
return meta?.historyPolicy ?? "static";
|
|
9198
|
+
}
|
|
9199
|
+
async fetchTicket() {
|
|
9200
|
+
try {
|
|
9201
|
+
const res = await this.deps.http.post("/api/ws-ticket", {});
|
|
9202
|
+
return res?.ticket ?? null;
|
|
9203
|
+
}
|
|
9204
|
+
catch {
|
|
9205
|
+
return null;
|
|
9206
|
+
}
|
|
9207
|
+
}
|
|
9208
|
+
myId() {
|
|
9209
|
+
const id = this.deps.session.username;
|
|
9210
|
+
if (!id)
|
|
9211
|
+
throw new Error("client.space: not signed in — call client.auth.zk.login() first.");
|
|
9212
|
+
return id;
|
|
9213
|
+
}
|
|
9214
|
+
}
|
|
9215
|
+
|
|
9216
|
+
/**
|
|
9217
|
+
* `client.agents` — manage an app's **Programmable Agents** (server-side
|
|
9218
|
+
* "virtual users" backed by Workers AI).
|
|
9219
|
+
*
|
|
9220
|
+
* const { config } = await client.agents.create(appId, {
|
|
9221
|
+
* handle: '@assistant', displayName: 'Assistant',
|
|
9222
|
+
* systemPrompt: 'You are a helpful teammate in this Space.',
|
|
9223
|
+
* triggers: [{ type: 'mention' }],
|
|
9224
|
+
* });
|
|
9225
|
+
* await client.agents.enable(appId, config.agentId, spaceId); // per-Space opt-in
|
|
9226
|
+
*
|
|
9227
|
+
* These are MANAGEMENT calls — the agent itself runs on the accelerator. Only an
|
|
9228
|
+
* app's owner or an editor of its shared Space may create/update/enable an agent
|
|
9229
|
+
* (viewers can read). Authorization rides the user session token; non-owner
|
|
9230
|
+
* editors pass their management Space via `opts.space`.
|
|
9231
|
+
*
|
|
9232
|
+
* An agent is "prompts + persona": a system prompt, a model, named prompt
|
|
9233
|
+
* `skills`, and `triggers` that decide when it speaks — plus optional `tools`
|
|
9234
|
+
* that let it act on the app (query/write its database, call its functions,
|
|
9235
|
+
* resolve its channels) via a server-side function-calling loop. See
|
|
9236
|
+
* {@link AgentToolsConfig}. Pair with the `@MuhkooAgent`/`@MuhkooSpace`/
|
|
9237
|
+
* `@MuhkooDB`/`@MuhkooFunction` decorators + `ejectAgentPrompt`/`ejectAgentTools`
|
|
9238
|
+
* to generate `systemPrompt` and `tools` straight from your app's surface.
|
|
9239
|
+
*/
|
|
9240
|
+
class AgentsNamespace {
|
|
9241
|
+
deps;
|
|
9242
|
+
constructor(deps) {
|
|
9243
|
+
this.deps = deps;
|
|
9244
|
+
}
|
|
9245
|
+
/** List the app's agents. */
|
|
9246
|
+
async list(appId, opts = {}) {
|
|
9247
|
+
const res = await this.deps.http.get(this.path(appId, "", opts));
|
|
9248
|
+
return res.agents ?? [];
|
|
9249
|
+
}
|
|
9250
|
+
/** Read one agent (config + identity keys). */
|
|
9251
|
+
async get(appId, agentId, opts = {}) {
|
|
9252
|
+
return this.deps.http.get(this.path(appId, `/${encodeURIComponent(agentId)}`, opts));
|
|
9253
|
+
}
|
|
9254
|
+
/** Create an agent (owner/editor only). */
|
|
9255
|
+
async create(appId, input, opts = {}) {
|
|
9256
|
+
return this.deps.http.post(this.path(appId, "", {}), { ...input, space: opts.space });
|
|
9257
|
+
}
|
|
9258
|
+
/** Update an agent's editor-settable fields (owner/editor only). */
|
|
9259
|
+
async update(appId, agentId, patch, opts = {}) {
|
|
9260
|
+
return this.deps.http.patch(this.path(appId, `/${encodeURIComponent(agentId)}`, {}), { ...patch, space: opts.space });
|
|
9261
|
+
}
|
|
9262
|
+
/** Delete an agent (owner/editor only). */
|
|
9263
|
+
async delete(appId, agentId, opts = {}) {
|
|
9264
|
+
return this.deps.http.del(this.path(appId, `/${encodeURIComponent(agentId)}`, {}), { space: opts.space });
|
|
9265
|
+
}
|
|
9266
|
+
/** Enable the agent on a Space (per-Space opt-in). */
|
|
9267
|
+
async enable(appId, agentId, spaceId, opts = {}) {
|
|
9268
|
+
return this.deps.http.post(this.path(appId, `/${encodeURIComponent(agentId)}/enable`, {}), { targetSpaceId: spaceId, space: opts.space });
|
|
9269
|
+
}
|
|
9270
|
+
/** Disable the agent on a Space. */
|
|
9271
|
+
async disable(appId, agentId, spaceId, opts = {}) {
|
|
9272
|
+
return this.deps.http.post(this.path(appId, `/${encodeURIComponent(agentId)}/disable`, {}), { targetSpaceId: spaceId, space: opts.space });
|
|
9273
|
+
}
|
|
9274
|
+
path(appId, suffix, opts) {
|
|
9275
|
+
const q = opts.space ? `?space=${encodeURIComponent(opts.space)}` : "";
|
|
9276
|
+
return `/api/apps/${encodeURIComponent(appId)}/agents${suffix}${q}`;
|
|
9277
|
+
}
|
|
9278
|
+
}
|
|
9279
|
+
|
|
9280
|
+
/**
|
|
9281
|
+
* `client.functions` — manage an app's **serverless functions** (developer-
|
|
9282
|
+
* authored code that runs on the accelerator).
|
|
9283
|
+
*
|
|
9284
|
+
* const { config } = await client.functions.deploy(appId, {
|
|
9285
|
+
* name: 'hello', displayName: 'Hello',
|
|
9286
|
+
* code: `export default { fetch: () => new Response('hi') }`,
|
|
9287
|
+
* triggers: { http: { enabled: true } },
|
|
9288
|
+
* });
|
|
9289
|
+
* // HTTP-reachable at hello--<slug>.fns.muhkoo.dev
|
|
9290
|
+
* await client.functions.enable(appId, config.functionId, spaceId); // Space-bound
|
|
9291
|
+
* const out = await client.functions.invoke({ name: 'hello', slug }, { body: { q: 1 } });
|
|
9292
|
+
*
|
|
9293
|
+
* These are MANAGEMENT calls — the function itself runs on the accelerator
|
|
9294
|
+
* (uploaded just-in-time, decrypted only at invocation). Only an app's owner or
|
|
9295
|
+
* an editor of its shared Space may deploy/update/enable (viewers read).
|
|
9296
|
+
* Authorization rides the user session token; non-owner editors pass their
|
|
9297
|
+
* management Space via `opts.space`.
|
|
9298
|
+
*
|
|
9299
|
+
* v1 is "compute-only": a function is an untrusted single-module ES worker
|
|
9300
|
+
* (`export default { fetch }`) with NO platform bindings — it reaches the
|
|
9301
|
+
* platform as an external client (the end-user session is passed through). Two
|
|
9302
|
+
* triggers: `http` (its own subdomain) and `space` (invoked on Space messages,
|
|
9303
|
+
* like a Programmable Agent).
|
|
9304
|
+
*/
|
|
9305
|
+
/** The hosted platform's HTTP-function zone. */
|
|
9306
|
+
const DEFAULT_FN_HOST_SUFFIX = "fns.muhkoo.dev";
|
|
9307
|
+
/** Bodies `fetch` accepts natively — everything else is JSON-encoded. */
|
|
9308
|
+
function isRawBody(body) {
|
|
9309
|
+
return (typeof body === "string" ||
|
|
9310
|
+
body instanceof FormData ||
|
|
9311
|
+
body instanceof Blob ||
|
|
9312
|
+
body instanceof ArrayBuffer ||
|
|
9313
|
+
ArrayBuffer.isView(body) ||
|
|
9314
|
+
body instanceof URLSearchParams ||
|
|
9315
|
+
(typeof ReadableStream !== "undefined" && body instanceof ReadableStream));
|
|
9316
|
+
}
|
|
9317
|
+
class FunctionsNamespace {
|
|
9318
|
+
deps;
|
|
9319
|
+
constructor(deps) {
|
|
9320
|
+
this.deps = deps;
|
|
9321
|
+
}
|
|
9322
|
+
/** List the app's functions. */
|
|
9323
|
+
async list(appId, opts = {}) {
|
|
9324
|
+
const res = await this.deps.http.get(this.path(appId, "", opts));
|
|
9325
|
+
return res.functions ?? [];
|
|
9326
|
+
}
|
|
9327
|
+
/** Read one function's config. */
|
|
9328
|
+
async get(appId, functionId, opts = {}) {
|
|
9329
|
+
return this.deps.http.get(this.path(appId, `/${encodeURIComponent(functionId)}`, opts));
|
|
9330
|
+
}
|
|
9331
|
+
/** Read a function's decrypted source (owner/editor; for the editor UI). */
|
|
9332
|
+
async code(appId, functionId, opts = {}) {
|
|
9333
|
+
return this.deps.http.get(this.path(appId, `/${encodeURIComponent(functionId)}/code`, opts));
|
|
9334
|
+
}
|
|
9335
|
+
/** Deploy (create) a function (owner/editor only). */
|
|
9336
|
+
async deploy(appId, input, opts = {}) {
|
|
9337
|
+
return this.deps.http.post(this.path(appId, "", {}), { ...input, space: opts.space });
|
|
9338
|
+
}
|
|
9339
|
+
/** Update a function's editor-settable fields and/or redeploy its `code`. */
|
|
9340
|
+
async update(appId, functionId, patch, opts = {}) {
|
|
9341
|
+
return this.deps.http.patch(this.path(appId, `/${encodeURIComponent(functionId)}`, {}), { ...patch, space: opts.space });
|
|
9342
|
+
}
|
|
9343
|
+
/** Delete a function (owner/editor only). */
|
|
9344
|
+
async delete(appId, functionId, opts = {}) {
|
|
9345
|
+
return this.deps.http.del(this.path(appId, `/${encodeURIComponent(functionId)}`, {}), { space: opts.space });
|
|
9346
|
+
}
|
|
9347
|
+
/** Enable the function on a Space (per-Space opt-in for the `space` trigger). */
|
|
9348
|
+
async enable(appId, functionId, spaceId, opts = {}) {
|
|
9349
|
+
return this.deps.http.post(this.path(appId, `/${encodeURIComponent(functionId)}/enable`, {}), { targetSpaceId: spaceId, space: opts.space });
|
|
9350
|
+
}
|
|
9351
|
+
/** Disable the function on a Space. */
|
|
9352
|
+
async disable(appId, functionId, spaceId, opts = {}) {
|
|
9353
|
+
return this.deps.http.post(this.path(appId, `/${encodeURIComponent(functionId)}/disable`, {}), { targetSpaceId: spaceId, space: opts.space });
|
|
9354
|
+
}
|
|
9355
|
+
/**
|
|
9356
|
+
* Invoke an HTTP-triggered function and parse its JSON response.
|
|
9357
|
+
*
|
|
9358
|
+
* const out = await client.functions.invoke({ name: 'hello', slug: 'myapp' });
|
|
9359
|
+
* const out = await client.functions.invoke('hello--myapp', { body: { q: 1 } });
|
|
9360
|
+
* const out = await client.functions.invoke('https://hello--myapp.fns.muhkoo.dev/x');
|
|
9361
|
+
*
|
|
9362
|
+
* The request carries the SDK's credentials (`X-Muhkoo-Key` and, when a
|
|
9363
|
+
* user is signed in, `X-Muhkoo-Session`) — the platform passes the session
|
|
9364
|
+
* token through to the function, which can verify it server-side via
|
|
9365
|
+
* `POST /api/auth/verify` to establish the caller's identity.
|
|
9366
|
+
*
|
|
9367
|
+
* Non-2xx responses throw {@link HttpError}. For functions that return
|
|
9368
|
+
* something other than JSON, use {@link invokeRaw}.
|
|
9369
|
+
*/
|
|
9370
|
+
async invoke(target, opts = {}) {
|
|
9371
|
+
const res = await this.invokeRaw(target, opts);
|
|
9372
|
+
let parsed = null;
|
|
9373
|
+
try {
|
|
9374
|
+
parsed = await res.json();
|
|
9375
|
+
}
|
|
9376
|
+
catch {
|
|
9377
|
+
// Non-JSON body — fall through to the status-only error below.
|
|
9378
|
+
}
|
|
9379
|
+
if (!res.ok) {
|
|
9380
|
+
const msg = parsed && typeof parsed === "object" && "error" in parsed
|
|
9381
|
+
? String(parsed.error)
|
|
9382
|
+
: `${res.status} ${res.statusText}`;
|
|
9383
|
+
throw new HttpError(msg, res.status, parsed);
|
|
9384
|
+
}
|
|
9385
|
+
return parsed;
|
|
9386
|
+
}
|
|
9387
|
+
/** Invoke an HTTP-triggered function and return the raw `Response`. */
|
|
9388
|
+
async invokeRaw(target, opts = {}) {
|
|
9389
|
+
const init = {
|
|
9390
|
+
method: opts.method ?? (opts.body !== undefined ? "POST" : "GET"),
|
|
9391
|
+
};
|
|
9392
|
+
const headers = new Headers(opts.headers);
|
|
9393
|
+
if (opts.body !== undefined) {
|
|
9394
|
+
if (isRawBody(opts.body)) {
|
|
9395
|
+
init.body = opts.body;
|
|
9396
|
+
}
|
|
9397
|
+
else {
|
|
9398
|
+
init.body = JSON.stringify(opts.body);
|
|
9399
|
+
if (!headers.has("Content-Type"))
|
|
9400
|
+
headers.set("Content-Type", "application/json");
|
|
9401
|
+
}
|
|
9402
|
+
}
|
|
9403
|
+
init.headers = headers;
|
|
9404
|
+
return this.deps.http.fetch(this.invokeUrl(target, opts.path), init);
|
|
9405
|
+
}
|
|
9406
|
+
/** Resolve an invoke target to the function's absolute URL. */
|
|
9407
|
+
invokeUrl(target, path = "") {
|
|
9408
|
+
const suffix = this.deps.fnHostSuffix ?? DEFAULT_FN_HOST_SUFFIX;
|
|
9409
|
+
let base;
|
|
9410
|
+
if (typeof target === "string") {
|
|
9411
|
+
base = /^https?:\/\//.test(target) ? target : `https://${target}.${suffix}`;
|
|
9412
|
+
}
|
|
9413
|
+
else {
|
|
9414
|
+
base = `https://${target.name}--${target.slug}.${suffix}`;
|
|
9415
|
+
}
|
|
9416
|
+
if (!path)
|
|
9417
|
+
return base;
|
|
9418
|
+
return `${base.replace(/\/+$/, "")}${path.startsWith("/") ? "" : "/"}${path}`;
|
|
9419
|
+
}
|
|
9420
|
+
path(appId, suffix, opts) {
|
|
9421
|
+
const q = opts.space ? `?space=${encodeURIComponent(opts.space)}` : "";
|
|
9422
|
+
return `/api/apps/${encodeURIComponent(appId)}/functions${suffix}${q}`;
|
|
9423
|
+
}
|
|
9424
|
+
}
|
|
9425
|
+
|
|
9426
|
+
/**
|
|
9427
|
+
* `HlcTimestamp` — a Hybrid Logical Clock reading, packed into a single
|
|
9428
|
+
* lexicographically-sortable string so the whole offline layer (and the
|
|
9429
|
+
* accelerator) can compare causality with one plain string comparison.
|
|
9430
|
+
*
|
|
9431
|
+
* An HLC fuses a physical wall-clock millisecond with a logical `counter`
|
|
9432
|
+
* (breaks ties inside the same millisecond and lets the clock advance past a
|
|
9433
|
+
* remote event without the wall clock moving) and a stable `nodeId` (a final,
|
|
9434
|
+
* deterministic tiebreak so two devices never produce an "equal but different"
|
|
9435
|
+
* stamp). See {@link ./HLC}.
|
|
9436
|
+
*
|
|
9437
|
+
* The packed form is `"{wall}:{counter}:{nodeId}"` with `wall` and `counter`
|
|
9438
|
+
* zero-padded to a FIXED width. Fixed width is what makes lexicographic order
|
|
9439
|
+
* equal to numeric order — `"0000000000123:..."` < `"0000000000124:..."`. The
|
|
9440
|
+
* server only ever compares these strings; it never has to parse them.
|
|
9441
|
+
*/
|
|
9442
|
+
/** Zero-padded width of the wall-clock field. 15 digits holds ms timestamps
|
|
9443
|
+
* past the year 5000, so `Date.now()` (currently 13 digits) never overflows. */
|
|
9444
|
+
const WALL_DIGITS = 15;
|
|
9445
|
+
/** Zero-padded width of the logical counter. 5 digits allows 100k events in a
|
|
9446
|
+
* single wall-clock millisecond before the clock is forced forward — far more
|
|
9447
|
+
* than any real burst. */
|
|
9448
|
+
const COUNTER_DIGITS = 5;
|
|
9449
|
+
/** The smallest possible stamp — used as the "nothing stored yet" sentinel so a
|
|
9450
|
+
* first real write always wins (`compareHlc(real, ZERO_HLC) > 0`). */
|
|
9451
|
+
const ZERO_HLC = pack(0, 0, "");
|
|
9452
|
+
function padNum(n, width) {
|
|
9453
|
+
const s = Math.max(0, Math.floor(n)).toString();
|
|
9454
|
+
return s.length >= width ? s : "0".repeat(width - s.length) + s;
|
|
9455
|
+
}
|
|
9456
|
+
/** Pack the three components into the sortable string form. */
|
|
9457
|
+
function pack(wall, counter, nodeId) {
|
|
9458
|
+
return `${padNum(wall, WALL_DIGITS)}:${padNum(counter, COUNTER_DIGITS)}:${nodeId}`;
|
|
9459
|
+
}
|
|
9460
|
+
/** Parse a packed stamp back into its components. Tolerates the empty/zero
|
|
9461
|
+
* sentinel. Throws on a malformed string so corruption surfaces loudly. */
|
|
9462
|
+
function unpack(stamp) {
|
|
9463
|
+
const idx1 = stamp.indexOf(":");
|
|
9464
|
+
const idx2 = stamp.indexOf(":", idx1 + 1);
|
|
9465
|
+
if (idx1 < 0 || idx2 < 0) {
|
|
9466
|
+
throw new Error(`malformed HLC timestamp: ${JSON.stringify(stamp)}`);
|
|
9467
|
+
}
|
|
9468
|
+
return {
|
|
9469
|
+
wall: Number(stamp.slice(0, idx1)),
|
|
9470
|
+
counter: Number(stamp.slice(idx1 + 1, idx2)),
|
|
9471
|
+
nodeId: stamp.slice(idx2 + 1),
|
|
9472
|
+
};
|
|
9473
|
+
}
|
|
9474
|
+
/**
|
|
9475
|
+
* Total order over packed HLC strings: negative if `a < b`, positive if
|
|
9476
|
+
* `a > b`, `0` only when they are byte-identical. Because the fields are
|
|
9477
|
+
* fixed-width and ordered (wall, counter, nodeId), a single string comparison
|
|
9478
|
+
* already yields causal order — this helper just normalizes the result to the
|
|
9479
|
+
* `-1 | 0 | 1` shape callers expect.
|
|
9480
|
+
*/
|
|
9481
|
+
function compareHlc(a, b) {
|
|
9482
|
+
if (a === b)
|
|
9483
|
+
return 0;
|
|
9484
|
+
return a < b ? -1 : 1;
|
|
9485
|
+
}
|
|
9486
|
+
/** Convenience: is `a` strictly newer than `b`? */
|
|
9487
|
+
function isNewer(a, b) {
|
|
9488
|
+
return compareHlc(a, b) > 0;
|
|
9489
|
+
}
|
|
9490
|
+
|
|
9491
|
+
/**
|
|
9492
|
+
* `HLC` — a Hybrid Logical Clock. Hands out monotonically increasing
|
|
9493
|
+
* {@link HlcTimestamp} stamps that (a) track real time closely enough to be
|
|
9494
|
+
* human-meaningful, yet (b) never go backwards, even across a reload or when a
|
|
9495
|
+
* peer's clock is ahead of ours.
|
|
9496
|
+
*
|
|
9497
|
+
* Two operations, both the textbook HLC algorithm (Kulkarni et al.):
|
|
9498
|
+
* - `now()` — stamp a local event.
|
|
9499
|
+
* - `update(remote)` — observe a remote stamp (e.g. arriving on the realtime
|
|
9500
|
+
* change feed) and advance our clock past it, so any event we generate
|
|
9501
|
+
* afterwards is causally `> remote`.
|
|
9502
|
+
*
|
|
9503
|
+
* State is just `{ wall, counter }`; `nodeId` is fixed for the install. The
|
|
9504
|
+
* owner ({@link ../OfflineManager}) seeds the clock from persisted state on
|
|
9505
|
+
* boot and persists `getState()` after each tick so monotonicity survives page
|
|
9506
|
+
* reloads — without that, a reload could mint a stamp that loses to one written
|
|
9507
|
+
* seconds earlier.
|
|
9508
|
+
*/
|
|
9509
|
+
class HLC {
|
|
9510
|
+
wall;
|
|
9511
|
+
counter;
|
|
9512
|
+
nodeId;
|
|
9513
|
+
physical;
|
|
9514
|
+
constructor(nodeId, opts = {}) {
|
|
9515
|
+
this.nodeId = nodeId;
|
|
9516
|
+
this.physical = opts.physical ?? (() => Date.now());
|
|
9517
|
+
this.wall = opts.state?.wall ?? 0;
|
|
9518
|
+
this.counter = opts.state?.counter ?? 0;
|
|
9519
|
+
}
|
|
9520
|
+
/** Stamp a locally-generated event. Advances the clock. */
|
|
9521
|
+
now() {
|
|
9522
|
+
const pt = this.physical();
|
|
9523
|
+
const prevWall = this.wall;
|
|
9524
|
+
this.wall = Math.max(prevWall, pt);
|
|
9525
|
+
this.counter = this.wall === prevWall ? this.counter + 1 : 0;
|
|
9526
|
+
return this.current();
|
|
9527
|
+
}
|
|
9528
|
+
/**
|
|
9529
|
+
* Merge in an observed remote stamp and advance our clock strictly past it.
|
|
9530
|
+
* Call this for every inbound HLC (change-feed frames, history rows) so the
|
|
9531
|
+
* next `now()` is causally after anything we've seen.
|
|
9532
|
+
*/
|
|
9533
|
+
update(remote) {
|
|
9534
|
+
const pt = this.physical();
|
|
9535
|
+
const prevWall = this.wall;
|
|
9536
|
+
const newWall = Math.max(prevWall, remote.wall, pt);
|
|
9537
|
+
if (newWall === prevWall && newWall === remote.wall) {
|
|
9538
|
+
this.counter = Math.max(this.counter, remote.counter) + 1;
|
|
9539
|
+
}
|
|
9540
|
+
else if (newWall === prevWall) {
|
|
9541
|
+
this.counter = this.counter + 1;
|
|
9542
|
+
}
|
|
9543
|
+
else if (newWall === remote.wall) {
|
|
9544
|
+
this.counter = remote.counter + 1;
|
|
9545
|
+
}
|
|
9546
|
+
else {
|
|
9547
|
+
this.counter = 0;
|
|
9548
|
+
}
|
|
9549
|
+
this.wall = newWall;
|
|
9550
|
+
return this.current();
|
|
9551
|
+
}
|
|
9552
|
+
/** The current stamp without advancing the clock. */
|
|
9553
|
+
current() {
|
|
9554
|
+
return pack(this.wall, this.counter, this.nodeId);
|
|
9555
|
+
}
|
|
9556
|
+
/** Serializable `{ wall, counter }` for persistence. */
|
|
9557
|
+
getState() {
|
|
9558
|
+
return { wall: this.wall, counter: this.counter };
|
|
9559
|
+
}
|
|
9560
|
+
}
|
|
9561
|
+
|
|
9562
|
+
/**
|
|
9563
|
+
* `ConnectivityManager` — the single source of truth for "are we online", which
|
|
9564
|
+
* the offline layer needs to decide between a network call and a cache read, and
|
|
9565
|
+
* to know when to flush the durable outbound queue.
|
|
9566
|
+
*
|
|
9567
|
+
* `navigator.onLine` alone lies often (captive portals, VPN flaps, a laptop
|
|
9568
|
+
* that's "connected" to a network with no route), so we fuse three signals:
|
|
9569
|
+
* 1. the browser `online`/`offline` events (coarse but free),
|
|
9570
|
+
* 2. real fetch outcomes reported by {@link ../core/HttpClient} and the shard
|
|
9571
|
+
* client — a thrown `TypeError`/abort is the most reliable "offline" tell,
|
|
9572
|
+
* and a 2xx is the most reliable "online" tell,
|
|
9573
|
+
* 3. the sync engine, which flips us to `syncing` while it drains the queue.
|
|
9574
|
+
*
|
|
9575
|
+
* The offline→online edge fires `onReconnect` (wired to the sync engine) so
|
|
9576
|
+
* queued writes replay automatically. Transitions are debounced to avoid
|
|
9577
|
+
* flapping the UI on a brief blip. State changes also emit on {@link EventCore}
|
|
9578
|
+
* (`ONLINE`/`OFFLINE`/`SYNCING`) so apps can render a status indicator.
|
|
9579
|
+
*/
|
|
9580
|
+
class ConnectivityManager {
|
|
9581
|
+
state;
|
|
9582
|
+
listeners = new Set();
|
|
9583
|
+
onReconnect;
|
|
9584
|
+
debounceMs;
|
|
9585
|
+
offlineTimer = null;
|
|
9586
|
+
started = false;
|
|
9587
|
+
handleOnline = () => this.markOnline();
|
|
9588
|
+
handleOffline = () => this.scheduleOffline();
|
|
9589
|
+
constructor(opts = {}) {
|
|
9590
|
+
this.onReconnect = opts.onReconnect;
|
|
9591
|
+
this.debounceMs = opts.debounceMs ?? 1500;
|
|
9592
|
+
this.state =
|
|
9593
|
+
typeof navigator !== "undefined" && navigator.onLine === false ? "offline" : "online";
|
|
9594
|
+
}
|
|
9595
|
+
/** Attach to the browser online/offline events. Safe to call in Node (no-op). */
|
|
9596
|
+
start() {
|
|
9597
|
+
if (this.started || typeof window === "undefined")
|
|
9598
|
+
return;
|
|
9599
|
+
window.addEventListener("online", this.handleOnline);
|
|
9600
|
+
window.addEventListener("offline", this.handleOffline);
|
|
9601
|
+
this.started = true;
|
|
7624
9602
|
}
|
|
7625
|
-
|
|
7626
|
-
|
|
7627
|
-
|
|
7628
|
-
|
|
7629
|
-
|
|
7630
|
-
|
|
7631
|
-
|
|
7632
|
-
|
|
7633
|
-
|
|
7634
|
-
|
|
7635
|
-
return this.
|
|
9603
|
+
stop() {
|
|
9604
|
+
if (!this.started || typeof window === "undefined")
|
|
9605
|
+
return;
|
|
9606
|
+
window.removeEventListener("online", this.handleOnline);
|
|
9607
|
+
window.removeEventListener("offline", this.handleOffline);
|
|
9608
|
+
if (this.offlineTimer)
|
|
9609
|
+
clearTimeout(this.offlineTimer);
|
|
9610
|
+
this.started = false;
|
|
9611
|
+
}
|
|
9612
|
+
get current() {
|
|
9613
|
+
return this.state;
|
|
9614
|
+
}
|
|
9615
|
+
get isOnline() {
|
|
9616
|
+
return this.state !== "offline";
|
|
9617
|
+
}
|
|
9618
|
+
/** Subscribe to state changes. Returns an unsubscribe function. */
|
|
9619
|
+
onChange(cb) {
|
|
9620
|
+
this.listeners.add(cb);
|
|
9621
|
+
return () => this.listeners.delete(cb);
|
|
9622
|
+
}
|
|
9623
|
+
/** Report a failed network attempt (thrown fetch / abort). */
|
|
9624
|
+
reportFetchFailure() {
|
|
9625
|
+
this.scheduleOffline();
|
|
9626
|
+
}
|
|
9627
|
+
/** Report a successful network round-trip — strongest "we're online" signal. */
|
|
9628
|
+
reportFetchSuccess() {
|
|
9629
|
+
this.markOnline();
|
|
9630
|
+
}
|
|
9631
|
+
/** The sync engine entering its drain phase. */
|
|
9632
|
+
markSyncing() {
|
|
9633
|
+
if (this.offlineTimer) {
|
|
9634
|
+
clearTimeout(this.offlineTimer);
|
|
9635
|
+
this.offlineTimer = null;
|
|
9636
|
+
}
|
|
9637
|
+
this.set("syncing");
|
|
9638
|
+
}
|
|
9639
|
+
/** The sync engine finished — settle back to online. */
|
|
9640
|
+
markSynced() {
|
|
9641
|
+
this.set("online");
|
|
9642
|
+
}
|
|
9643
|
+
markOnline() {
|
|
9644
|
+
if (this.offlineTimer) {
|
|
9645
|
+
clearTimeout(this.offlineTimer);
|
|
9646
|
+
this.offlineTimer = null;
|
|
9647
|
+
}
|
|
9648
|
+
// The sync engine owns the syncing→online transition (via markSynced).
|
|
9649
|
+
// A request succeeding mid-drain must not cut the syncing state short.
|
|
9650
|
+
if (this.state === "syncing")
|
|
9651
|
+
return;
|
|
9652
|
+
const wasOffline = this.state === "offline";
|
|
9653
|
+
this.set("online");
|
|
9654
|
+
if (wasOffline) {
|
|
9655
|
+
try {
|
|
9656
|
+
this.onReconnect?.();
|
|
9657
|
+
}
|
|
9658
|
+
catch {
|
|
9659
|
+
/* a reconnect handler must not break connectivity tracking */
|
|
9660
|
+
}
|
|
9661
|
+
}
|
|
7636
9662
|
}
|
|
7637
|
-
|
|
7638
|
-
|
|
7639
|
-
|
|
9663
|
+
scheduleOffline() {
|
|
9664
|
+
if (this.state === "offline" || this.offlineTimer)
|
|
9665
|
+
return;
|
|
9666
|
+
this.offlineTimer = setTimeout(() => {
|
|
9667
|
+
this.offlineTimer = null;
|
|
9668
|
+
this.set("offline");
|
|
9669
|
+
}, this.debounceMs);
|
|
7640
9670
|
}
|
|
7641
|
-
|
|
7642
|
-
|
|
7643
|
-
|
|
9671
|
+
set(state) {
|
|
9672
|
+
if (state === this.state)
|
|
9673
|
+
return;
|
|
9674
|
+
this.state = state;
|
|
9675
|
+
for (const cb of this.listeners) {
|
|
9676
|
+
try {
|
|
9677
|
+
cb(state);
|
|
9678
|
+
}
|
|
9679
|
+
catch {
|
|
9680
|
+
/* a bad listener mustn't break the others */
|
|
9681
|
+
}
|
|
9682
|
+
}
|
|
9683
|
+
const event = state === "online"
|
|
9684
|
+
? EventCoreEvents.ONLINE
|
|
9685
|
+
: state === "offline"
|
|
9686
|
+
? EventCoreEvents.OFFLINE
|
|
9687
|
+
: EventCoreEvents.SYNCING;
|
|
9688
|
+
EventCore.emit(event, { state });
|
|
7644
9689
|
}
|
|
7645
|
-
|
|
7646
|
-
|
|
7647
|
-
|
|
9690
|
+
}
|
|
9691
|
+
|
|
9692
|
+
/**
|
|
9693
|
+
* `OutboundQueue` — the durable record of writes made while offline (or that
|
|
9694
|
+
* failed mid-flight) plus the machinery to replay them on reconnect. This is
|
|
9695
|
+
* what makes offline writes *survive a reload*: unlike the in-memory buffer in
|
|
9696
|
+
* {@link ../transport/WSTransport}, every entry lives in IndexedDB until it has
|
|
9697
|
+
* been acknowledged by the server.
|
|
9698
|
+
*
|
|
9699
|
+
* Each domain registers a **replayer** — a function that re-applies one queued
|
|
9700
|
+
* mutation by re-invoking the real namespace method (with an `isReplay` flag so
|
|
9701
|
+
* it doesn't re-enqueue). `drain()` replays entries in HLC order:
|
|
9702
|
+
* - success → remove the entry;
|
|
9703
|
+
* - permanent failure (4xx) → drop it and surface a `SYNC_ERROR` (a write the
|
|
9704
|
+
* server will never accept must not wedge the queue forever);
|
|
9705
|
+
* - transient/network failure → stop draining and leave the rest for the next
|
|
9706
|
+
* reconnect.
|
|
9707
|
+
*
|
|
9708
|
+
* Replays are idempotent by construction (kv/db are LWW-by-key, messages dedupe
|
|
9709
|
+
* by `clientId`, file PUTs are content-addressed), so re-running a partially
|
|
9710
|
+
* drained queue is safe.
|
|
9711
|
+
*/
|
|
9712
|
+
class OutboundQueue {
|
|
9713
|
+
store;
|
|
9714
|
+
replayers = new Map();
|
|
9715
|
+
draining = null;
|
|
9716
|
+
constructor(store) {
|
|
9717
|
+
this.store = store;
|
|
7648
9718
|
}
|
|
7649
|
-
/**
|
|
7650
|
-
|
|
7651
|
-
|
|
9719
|
+
/** A domain registers how to replay its mutations. */
|
|
9720
|
+
register(domain, replayer) {
|
|
9721
|
+
this.replayers.set(domain, replayer);
|
|
9722
|
+
}
|
|
9723
|
+
/** Record a mutation for later replay; resolves with its assigned seq. */
|
|
9724
|
+
enqueue(entry) {
|
|
9725
|
+
return this.store.enqueue(entry);
|
|
9726
|
+
}
|
|
9727
|
+
/** Number of writes still awaiting sync. */
|
|
9728
|
+
async pending() {
|
|
9729
|
+
return (await this.store.queued()).length;
|
|
7652
9730
|
}
|
|
7653
9731
|
/**
|
|
7654
|
-
*
|
|
7655
|
-
*
|
|
7656
|
-
*
|
|
9732
|
+
* Replay queued mutations in HLC order. Coalesces concurrent calls into a
|
|
9733
|
+
* single in-flight drain. Stops on the first transient failure, leaving the
|
|
9734
|
+
* remaining entries for the next attempt.
|
|
7657
9735
|
*/
|
|
7658
|
-
|
|
7659
|
-
|
|
7660
|
-
|
|
7661
|
-
|
|
7662
|
-
|
|
9736
|
+
drain() {
|
|
9737
|
+
if (this.draining)
|
|
9738
|
+
return this.draining;
|
|
9739
|
+
this.draining = this.runDrain().finally(() => {
|
|
9740
|
+
this.draining = null;
|
|
9741
|
+
});
|
|
9742
|
+
return this.draining;
|
|
9743
|
+
}
|
|
9744
|
+
async runDrain() {
|
|
9745
|
+
const entries = await this.store.queued();
|
|
9746
|
+
for (const entry of entries) {
|
|
9747
|
+
const replayer = this.replayers.get(entry.domain);
|
|
9748
|
+
if (!replayer) {
|
|
9749
|
+
// No handler registered (namespace not wired) — drop it rather
|
|
9750
|
+
// than block the queue; nothing can ever replay it.
|
|
9751
|
+
await this.store.dequeue(entry.seq);
|
|
9752
|
+
continue;
|
|
9753
|
+
}
|
|
9754
|
+
try {
|
|
9755
|
+
await replayer(entry);
|
|
9756
|
+
await this.store.dequeue(entry.seq);
|
|
9757
|
+
EventCore.emit(EventCoreEvents.SYNC_PROGRESS, {
|
|
9758
|
+
domain: entry.domain,
|
|
9759
|
+
method: entry.method,
|
|
9760
|
+
});
|
|
9761
|
+
}
|
|
9762
|
+
catch (err) {
|
|
9763
|
+
if (isPermanent(err)) {
|
|
9764
|
+
await this.store.dequeue(entry.seq);
|
|
9765
|
+
EventCore.emit(EventCoreEvents.SYNC_ERROR, {
|
|
9766
|
+
domain: entry.domain,
|
|
9767
|
+
method: entry.method,
|
|
9768
|
+
error: String(err?.message ?? err),
|
|
9769
|
+
dropped: true,
|
|
9770
|
+
});
|
|
9771
|
+
continue;
|
|
9772
|
+
}
|
|
9773
|
+
// Transient (offline again, 5xx) — keep the entry, stop here.
|
|
9774
|
+
throw err;
|
|
9775
|
+
}
|
|
9776
|
+
}
|
|
7663
9777
|
}
|
|
7664
|
-
|
|
7665
|
-
|
|
7666
|
-
|
|
9778
|
+
}
|
|
9779
|
+
/** A 4xx is the server permanently refusing the write; anything else is worth
|
|
9780
|
+
* retrying on the next reconnect. */
|
|
9781
|
+
function isPermanent(err) {
|
|
9782
|
+
return err instanceof HttpError && err.status >= 400 && err.status < 500;
|
|
9783
|
+
}
|
|
9784
|
+
|
|
9785
|
+
/**
|
|
9786
|
+
* `SyncEngine` — orchestrates catch-up when the client comes back online. It's
|
|
9787
|
+
* the thing wired to the {@link ConnectivityManager}'s offline→online edge.
|
|
9788
|
+
*
|
|
9789
|
+
* One `run()` does, in order:
|
|
9790
|
+
* 1. **Guard** — only proceed when the session is authenticated and unlocked
|
|
9791
|
+
* (replays may need to re-seal/re-encrypt, and there's no point pushing
|
|
9792
|
+
* writes for a logged-out user).
|
|
9793
|
+
* 2. **Drain** the durable {@link OutboundQueue} — push every offline write.
|
|
9794
|
+
* 3. **Catch-up tasks** — domain-registered pulls that reconcile *inbound*
|
|
9795
|
+
* state we missed while offline (e.g. a Space paging history forward to its
|
|
9796
|
+
* last-seen handle). Registered by the namespaces.
|
|
9797
|
+
*
|
|
9798
|
+
* Concurrent triggers coalesce into a single in-flight run (a reconnect often
|
|
9799
|
+
* fires several signals at once), mirroring `Client.recoverInFlight`.
|
|
9800
|
+
*/
|
|
9801
|
+
class SyncEngine {
|
|
9802
|
+
deps;
|
|
9803
|
+
catchUpTasks = new Set();
|
|
9804
|
+
running = null;
|
|
9805
|
+
constructor(deps) {
|
|
9806
|
+
this.deps = deps;
|
|
7667
9807
|
}
|
|
7668
|
-
|
|
7669
|
-
|
|
7670
|
-
|
|
7671
|
-
|
|
7672
|
-
|
|
9808
|
+
/** Register an inbound reconciliation task run after the queue drains. */
|
|
9809
|
+
registerCatchUp(task) {
|
|
9810
|
+
this.catchUpTasks.add(task);
|
|
9811
|
+
return () => this.catchUpTasks.delete(task);
|
|
9812
|
+
}
|
|
9813
|
+
/** Drain + catch up. Concurrent calls share one in-flight run. */
|
|
9814
|
+
run() {
|
|
9815
|
+
if (this.running)
|
|
9816
|
+
return this.running;
|
|
9817
|
+
this.running = this.execute().finally(() => {
|
|
9818
|
+
this.running = null;
|
|
7673
9819
|
});
|
|
9820
|
+
return this.running;
|
|
7674
9821
|
}
|
|
7675
|
-
|
|
7676
|
-
|
|
7677
|
-
|
|
7678
|
-
|
|
7679
|
-
|
|
7680
|
-
|
|
7681
|
-
|
|
7682
|
-
|
|
7683
|
-
|
|
7684
|
-
|
|
9822
|
+
async execute() {
|
|
9823
|
+
if (!this.deps.canSync())
|
|
9824
|
+
return;
|
|
9825
|
+
this.deps.connectivity.markSyncing();
|
|
9826
|
+
try {
|
|
9827
|
+
await this.deps.queue.drain();
|
|
9828
|
+
for (const task of this.catchUpTasks) {
|
|
9829
|
+
try {
|
|
9830
|
+
await task();
|
|
9831
|
+
}
|
|
9832
|
+
catch {
|
|
9833
|
+
// One domain's catch-up failing shouldn't abort the others
|
|
9834
|
+
// or wedge the sync; it'll retry on the next reconnect.
|
|
9835
|
+
}
|
|
9836
|
+
}
|
|
9837
|
+
EventCore.emit(EventCoreEvents.SYNC_COMPLETE, {});
|
|
9838
|
+
}
|
|
9839
|
+
finally {
|
|
9840
|
+
this.deps.connectivity.markSynced();
|
|
9841
|
+
}
|
|
9842
|
+
}
|
|
9843
|
+
}
|
|
9844
|
+
|
|
9845
|
+
/**
|
|
9846
|
+
* Errors specific to the offline layer.
|
|
9847
|
+
*/
|
|
9848
|
+
/**
|
|
9849
|
+
* Thrown when an offline read/write needs the user's identity (to decrypt or
|
|
9850
|
+
* re-encrypt cached data) but the client is locked — typically right after a
|
|
9851
|
+
* reload, where only the session token was restored and `unlock()` hasn't run
|
|
9852
|
+
* yet. Distinct from the generic "not signed in" identity error so apps can
|
|
9853
|
+
* specifically prompt the user to unlock to see their offline data.
|
|
9854
|
+
*/
|
|
9855
|
+
class OfflineLockedError extends Error {
|
|
9856
|
+
constructor(message = "Unlock required to access encrypted offline data. Call client.auth.zk.unlock(password).") {
|
|
9857
|
+
super(message);
|
|
9858
|
+
this.name = "OfflineLockedError";
|
|
9859
|
+
}
|
|
9860
|
+
}
|
|
9861
|
+
|
|
9862
|
+
/**
|
|
9863
|
+
* `client.offline.snapshot` — a small, encrypted, app-state cache. Apps stash
|
|
9864
|
+
* whatever they need to repaint instantly on a cold offline boot: the last open
|
|
9865
|
+
* Space, a draft message, scroll position, the current route. It's deliberately
|
|
9866
|
+
* separate from `client.kv`: snapshots are **local-only by default** (instant,
|
|
9867
|
+
* never metered, never leave the device) and hold view state rather than domain
|
|
9868
|
+
* data.
|
|
9869
|
+
*
|
|
9870
|
+
* await client.offline.snapshot.save('ui', { route, openSpaceId, draft })
|
|
9871
|
+
* const ui = await client.offline.snapshot.load<UiState>('ui')
|
|
9872
|
+
*
|
|
9873
|
+
* Values are encrypted at rest with the same identity-derived {@link StorageCipher}
|
|
9874
|
+
* as `client.kv`, so a snapshot is unreadable until the user unlocks — `load`
|
|
9875
|
+
* returns `null` when locked or absent, and `save` throws {@link OfflineLockedError}.
|
|
9876
|
+
* Conflict model is a single-writer LWW register (the latest save wins).
|
|
9877
|
+
*/
|
|
9878
|
+
class SnapshotNamespace {
|
|
9879
|
+
deps;
|
|
9880
|
+
cipher = null;
|
|
9881
|
+
cipherIdentity = null;
|
|
9882
|
+
constructor(deps) {
|
|
9883
|
+
this.deps = deps;
|
|
9884
|
+
}
|
|
9885
|
+
/** Persist `state` under `name` (encrypted). No-op when offline support is off. */
|
|
9886
|
+
async save(name, state) {
|
|
9887
|
+
if (!this.deps.store.enabled)
|
|
9888
|
+
return;
|
|
9889
|
+
const envelope = await this.requireCipher().encrypt(state);
|
|
9890
|
+
const record = {
|
|
9891
|
+
envelope,
|
|
9892
|
+
hlc: await this.deps.nextHlc(),
|
|
9893
|
+
updatedAt: Date.now(),
|
|
9894
|
+
};
|
|
9895
|
+
await this.deps.store.put("snapshots", this.key(name), record);
|
|
9896
|
+
}
|
|
9897
|
+
/** Load and decrypt the snapshot `name`, or `null` if absent/locked. */
|
|
9898
|
+
async load(name) {
|
|
9899
|
+
if (!this.deps.store.enabled)
|
|
9900
|
+
return null;
|
|
9901
|
+
const record = await this.deps.store.get("snapshots", this.key(name));
|
|
9902
|
+
if (!record)
|
|
9903
|
+
return null;
|
|
9904
|
+
if (!this.deps.session.isUnlocked)
|
|
9905
|
+
return null;
|
|
9906
|
+
return this.requireCipher().decrypt(record.envelope);
|
|
9907
|
+
}
|
|
9908
|
+
/** Remove the snapshot `name`. */
|
|
9909
|
+
async delete(name) {
|
|
9910
|
+
if (!this.deps.store.enabled)
|
|
9911
|
+
return;
|
|
9912
|
+
await this.deps.store.delete("snapshots", this.key(name));
|
|
9913
|
+
}
|
|
9914
|
+
/** Names of all snapshots for the current user. */
|
|
9915
|
+
async list() {
|
|
9916
|
+
if (!this.deps.store.enabled)
|
|
9917
|
+
return [];
|
|
9918
|
+
const prefix = `${this.commitment()}|`;
|
|
9919
|
+
const rows = await this.deps.store.prefix("snapshots", prefix);
|
|
9920
|
+
return rows.map((r) => r.key.slice(prefix.length));
|
|
9921
|
+
}
|
|
9922
|
+
key(name) {
|
|
9923
|
+
return `${this.commitment()}|${name}`;
|
|
9924
|
+
}
|
|
9925
|
+
commitment() {
|
|
9926
|
+
// Snapshots are scoped per user; before sign-in they share an "anon"
|
|
9927
|
+
// bucket so pre-auth UI state (e.g. a chosen theme) still persists.
|
|
9928
|
+
return this.deps.session.commitment ?? "anon";
|
|
9929
|
+
}
|
|
9930
|
+
requireCipher() {
|
|
9931
|
+
if (!this.deps.session.isUnlocked)
|
|
9932
|
+
throw new OfflineLockedError();
|
|
9933
|
+
const identity = this.deps.session.requireIdentity();
|
|
9934
|
+
if (this.cipher && this.cipherIdentity === identity)
|
|
9935
|
+
return this.cipher;
|
|
9936
|
+
this.cipher = new StorageCipher(identity.secretHex, identity.saltHex);
|
|
9937
|
+
this.cipherIdentity = identity;
|
|
9938
|
+
return this.cipher;
|
|
9939
|
+
}
|
|
9940
|
+
}
|
|
9941
|
+
|
|
9942
|
+
/**
|
|
9943
|
+
* IndexedDB schema for the offline layer — the single source of truth for the
|
|
9944
|
+
* database name, version, and object stores. Bump {@link DB_VERSION} whenever
|
|
9945
|
+
* the store set changes; {@link ./idb}'s `onupgradeneeded` creates any store
|
|
9946
|
+
* that doesn't exist yet (additive migrations only — never drop
|
|
9947
|
+
* `outbound-queue`, it holds un-synced writes).
|
|
9948
|
+
*
|
|
9949
|
+
* One database per origin. Records are partitioned *by key* (most keys are
|
|
9950
|
+
* prefixed with the user's `commitment`) rather than by separate databases, so
|
|
9951
|
+
* a single connection serves every signed-in user on the origin and prefix
|
|
9952
|
+
* scans isolate a user's data.
|
|
9953
|
+
*/
|
|
9954
|
+
const DB_NAME = "muhkoo.offline";
|
|
9955
|
+
const DB_VERSION = 1;
|
|
9956
|
+
const STORES = [
|
|
9957
|
+
{ name: "kv-cache" },
|
|
9958
|
+
{ name: "db-row-cache" },
|
|
9959
|
+
{ name: "db-query-cache" },
|
|
9960
|
+
{ name: "space-messages" },
|
|
9961
|
+
{ name: "space-cursors" },
|
|
9962
|
+
{ name: "outbound-queue", autoIncrement: true, keyPath: "seq" },
|
|
9963
|
+
{ name: "snapshots" },
|
|
9964
|
+
{ name: "hlc-state" },
|
|
9965
|
+
{ name: "meta" },
|
|
9966
|
+
];
|
|
9967
|
+
/** Fixed keys in the `meta` store. */
|
|
9968
|
+
const META_NODE_ID = "nodeId";
|
|
9969
|
+
|
|
9970
|
+
/**
|
|
9971
|
+
* `OfflineManager` — the object `client.offline` points at, and the single
|
|
9972
|
+
* owner of everything the offline layer needs at runtime: the {@link
|
|
9973
|
+
* OfflineStore}, the {@link HLC} (Hybrid Logical Clock), the {@link
|
|
9974
|
+
* ConnectivityManager}, the durable {@link OutboundQueue}, the {@link
|
|
9975
|
+
* SyncEngine}, and the {@link SnapshotNamespace}.
|
|
9976
|
+
*
|
|
9977
|
+
* The {@link ../core/Client} constructs one of these (always — it's a cheap
|
|
9978
|
+
* no-op when offline support is off) and hands the namespaces the small set of
|
|
9979
|
+
* primitives they need to participate: `nextHlc()` to stamp a write,
|
|
9980
|
+
* `observeHlc()` to fold in a remote stamp, `enqueue()` to record an offline
|
|
9981
|
+
* mutation, and `registerReplayer()`/`registerCatchUp()` to define how their
|
|
9982
|
+
* domain re-syncs on reconnect. Keeping that wiring here means the namespaces
|
|
9983
|
+
* stay decoupled from IndexedDB and the clock.
|
|
9984
|
+
*/
|
|
9985
|
+
const HLC_STATE_KEY = "global";
|
|
9986
|
+
class OfflineManager {
|
|
9987
|
+
/** True in browsers with IndexedDB; false when running over a NoopStore. */
|
|
9988
|
+
enabled;
|
|
9989
|
+
store;
|
|
9990
|
+
connectivity;
|
|
9991
|
+
queue;
|
|
9992
|
+
sync;
|
|
9993
|
+
/** `client.offline.snapshot` — encrypted app-state cache. */
|
|
9994
|
+
snapshot;
|
|
9995
|
+
/** Cache-API store for file-shard bytes (browser only). */
|
|
9996
|
+
fileCache;
|
|
9997
|
+
hlc = null;
|
|
9998
|
+
nodeId = "";
|
|
9999
|
+
bootPromise;
|
|
10000
|
+
persistTimer = null;
|
|
10001
|
+
constructor(deps) {
|
|
10002
|
+
this.enabled = deps.enabled;
|
|
10003
|
+
this.store = deps.store;
|
|
10004
|
+
this.queue = new OutboundQueue(deps.store);
|
|
10005
|
+
this.connectivity = new ConnectivityManager({
|
|
10006
|
+
onReconnect: () => void this.sync.run(),
|
|
7685
10007
|
});
|
|
7686
|
-
|
|
7687
|
-
|
|
7688
|
-
|
|
7689
|
-
|
|
7690
|
-
|
|
7691
|
-
|
|
7692
|
-
transport,
|
|
7693
|
-
cache: this.deps.cache,
|
|
7694
|
-
historyPolicy,
|
|
10008
|
+
this.sync = new SyncEngine({
|
|
10009
|
+
queue: this.queue,
|
|
10010
|
+
connectivity: this.connectivity,
|
|
10011
|
+
// Replays may need to re-seal/re-encrypt, and there's no point
|
|
10012
|
+
// pushing a logged-out user's writes — gate on a usable session.
|
|
10013
|
+
canSync: () => deps.session.isAuthenticated && deps.session.isUnlocked,
|
|
7695
10014
|
});
|
|
7696
|
-
|
|
7697
|
-
|
|
7698
|
-
|
|
7699
|
-
|
|
7700
|
-
fetch: this.deps.http.fetch,
|
|
7701
|
-
myId: () => memberId,
|
|
7702
|
-
fetchTicket: () => this.fetchTicket(),
|
|
7703
|
-
keyring,
|
|
7704
|
-
historyPolicy,
|
|
10015
|
+
this.snapshot = new SnapshotNamespace({
|
|
10016
|
+
store: deps.store,
|
|
10017
|
+
session: deps.session,
|
|
10018
|
+
nextHlc: () => this.nextHlc(),
|
|
7705
10019
|
});
|
|
10020
|
+
if (this.enabled && ShardCache.available())
|
|
10021
|
+
this.fileCache = new ShardCache();
|
|
10022
|
+
this.bootPromise = this.boot();
|
|
10023
|
+
if (this.enabled)
|
|
10024
|
+
this.connectivity.start();
|
|
7706
10025
|
}
|
|
7707
|
-
/**
|
|
7708
|
-
|
|
7709
|
-
|
|
7710
|
-
|
|
7711
|
-
|
|
10026
|
+
/**
|
|
10027
|
+
* Queue a shard upload that couldn't reach the network. The bytes are
|
|
10028
|
+
* already cached (keyed by `hash`), so only the hash is recorded; the file
|
|
10029
|
+
* replayer re-PUTs from the cache on reconnect.
|
|
10030
|
+
*/
|
|
10031
|
+
async deferShardUpload(hash) {
|
|
10032
|
+
await this.enqueue({
|
|
10033
|
+
hlc: await this.nextHlc(),
|
|
10034
|
+
clientId: this.newClientId(),
|
|
10035
|
+
domain: "file",
|
|
10036
|
+
method: "putShard",
|
|
10037
|
+
args: { hash },
|
|
10038
|
+
});
|
|
10039
|
+
}
|
|
10040
|
+
/** Resolve once the clock + node id are loaded. */
|
|
10041
|
+
ready() {
|
|
10042
|
+
return this.bootPromise;
|
|
10043
|
+
}
|
|
10044
|
+
/** Current connectivity state (`online` | `offline` | `syncing`). */
|
|
10045
|
+
get status() {
|
|
10046
|
+
return this.connectivity.current;
|
|
10047
|
+
}
|
|
10048
|
+
/** Subscribe to connectivity changes. Returns an unsubscribe function. */
|
|
10049
|
+
onStatusChange(cb) {
|
|
10050
|
+
return this.connectivity.onChange(cb);
|
|
10051
|
+
}
|
|
10052
|
+
/** Stamp a locally-generated write with a fresh, monotonic HLC. */
|
|
10053
|
+
async nextHlc() {
|
|
10054
|
+
await this.bootPromise;
|
|
10055
|
+
const stamp = this.hlc.now();
|
|
10056
|
+
this.schedulePersist();
|
|
10057
|
+
return stamp;
|
|
10058
|
+
}
|
|
10059
|
+
/** Fold an observed remote HLC into the clock (call for inbound stamps). */
|
|
10060
|
+
async observeHlc(remote) {
|
|
10061
|
+
await this.bootPromise;
|
|
10062
|
+
this.hlc.update(unpack(remote));
|
|
10063
|
+
this.schedulePersist();
|
|
10064
|
+
}
|
|
10065
|
+
/** A fresh client-generated id (idempotent replay / message dedupe). */
|
|
10066
|
+
newClientId() {
|
|
10067
|
+
return generateId();
|
|
10068
|
+
}
|
|
10069
|
+
/** Record an offline mutation for replay on reconnect. */
|
|
10070
|
+
enqueue(entry) {
|
|
10071
|
+
return this.queue.enqueue(entry);
|
|
10072
|
+
}
|
|
10073
|
+
/** A domain registers how to replay its queued mutations. */
|
|
10074
|
+
registerReplayer(domain, replayer) {
|
|
10075
|
+
this.queue.register(domain, replayer);
|
|
10076
|
+
}
|
|
10077
|
+
/** A domain registers an inbound catch-up task (run after the queue drains). */
|
|
10078
|
+
registerCatchUp(task) {
|
|
10079
|
+
return this.sync.registerCatchUp(task);
|
|
10080
|
+
}
|
|
10081
|
+
/** Surface a network failure to connectivity tracking. */
|
|
10082
|
+
reportFetchFailure() {
|
|
10083
|
+
this.connectivity.reportFetchFailure();
|
|
10084
|
+
}
|
|
10085
|
+
/** Surface a successful round-trip to connectivity tracking. */
|
|
10086
|
+
reportFetchSuccess() {
|
|
10087
|
+
this.connectivity.reportFetchSuccess();
|
|
10088
|
+
}
|
|
10089
|
+
/** Tear down listeners (tests / hot-reload). */
|
|
10090
|
+
dispose() {
|
|
10091
|
+
this.connectivity.stop();
|
|
10092
|
+
if (this.persistTimer)
|
|
10093
|
+
clearTimeout(this.persistTimer);
|
|
10094
|
+
}
|
|
10095
|
+
async boot() {
|
|
10096
|
+
if (!this.enabled) {
|
|
10097
|
+
// In-memory clock only; nothing is persisted over a NoopStore.
|
|
10098
|
+
this.nodeId = generateId();
|
|
10099
|
+
this.hlc = new HLC(this.nodeId);
|
|
10100
|
+
return;
|
|
7712
10101
|
}
|
|
7713
|
-
|
|
7714
|
-
|
|
7715
|
-
|
|
10102
|
+
let nodeId = await this.store.get("meta", META_NODE_ID);
|
|
10103
|
+
if (!nodeId) {
|
|
10104
|
+
nodeId = generateId();
|
|
10105
|
+
await this.store.put("meta", META_NODE_ID, nodeId);
|
|
10106
|
+
}
|
|
10107
|
+
this.nodeId = nodeId;
|
|
10108
|
+
const state = await this.store.get("hlc-state", HLC_STATE_KEY);
|
|
10109
|
+
this.hlc = new HLC(nodeId, { state });
|
|
10110
|
+
}
|
|
10111
|
+
/** Coalesce HLC-state writes — a burst of stamps persists once. */
|
|
10112
|
+
schedulePersist() {
|
|
10113
|
+
if (!this.enabled || this.persistTimer)
|
|
10114
|
+
return;
|
|
10115
|
+
this.persistTimer = setTimeout(() => {
|
|
10116
|
+
this.persistTimer = null;
|
|
10117
|
+
if (this.hlc)
|
|
10118
|
+
void this.store.put("hlc-state", HLC_STATE_KEY, this.hlc.getState());
|
|
10119
|
+
}, 200);
|
|
10120
|
+
}
|
|
10121
|
+
}
|
|
10122
|
+
|
|
10123
|
+
/**
|
|
10124
|
+
* Environment probe deciding whether the offline layer turns itself on. Offline
|
|
10125
|
+
* support is **on by default in browsers** and a no-op everywhere else, so this
|
|
10126
|
+
* is the gate the {@link ../core/Client} consults to pick {@link
|
|
10127
|
+
* ./store/IndexedDbStore} vs {@link ./store/NoopStore}.
|
|
10128
|
+
*
|
|
10129
|
+
* We require all three web APIs the layer leans on — `indexedDB` (structured
|
|
10130
|
+
* cache + durable queue), the Cache API (file-shard bytes), and a `window`
|
|
10131
|
+
* (so we're in a real document with online/offline events) — and we *touch*
|
|
10132
|
+
* them inside a try/catch, because some sandboxed or private-mode contexts
|
|
10133
|
+
* expose the globals but throw on use (mirrors `defaultSessionStore`).
|
|
10134
|
+
*/
|
|
10135
|
+
function isOfflineCapable() {
|
|
10136
|
+
try {
|
|
10137
|
+
return (typeof window !== "undefined" &&
|
|
10138
|
+
typeof indexedDB !== "undefined" &&
|
|
10139
|
+
typeof caches !== "undefined");
|
|
10140
|
+
}
|
|
10141
|
+
catch {
|
|
10142
|
+
return false;
|
|
10143
|
+
}
|
|
10144
|
+
}
|
|
10145
|
+
|
|
10146
|
+
/**
|
|
10147
|
+
* A ~thin promise wrapper over the raw IndexedDB API — just enough surface for
|
|
10148
|
+
* {@link ./IndexedDbStore}: open-with-migration, get/put/delete, prefix and
|
|
10149
|
+
* range cursor scans, autoincrement append, and clear. We hand-roll this rather
|
|
10150
|
+
* than depend on `idb`/`dexie` because the access patterns are simple and
|
|
10151
|
+
* `@muhkoo/connect` keeps its runtime dependency surface minimal.
|
|
10152
|
+
*
|
|
10153
|
+
* Everything here assumes a browser (or a `fake-indexeddb` test shim) — callers
|
|
10154
|
+
* gate construction behind {@link ../detect}.
|
|
10155
|
+
*/
|
|
10156
|
+
function req(request) {
|
|
10157
|
+
return new Promise((resolve, reject) => {
|
|
10158
|
+
request.onsuccess = () => resolve(request.result);
|
|
10159
|
+
request.onerror = () => reject(request.error);
|
|
10160
|
+
});
|
|
10161
|
+
}
|
|
10162
|
+
/** Open (and migrate) the offline database. Creates any missing object store. */
|
|
10163
|
+
function openDatabase() {
|
|
10164
|
+
return new Promise((resolve, reject) => {
|
|
10165
|
+
const open = indexedDB.open(DB_NAME, DB_VERSION);
|
|
10166
|
+
open.onupgradeneeded = () => {
|
|
10167
|
+
const db = open.result;
|
|
10168
|
+
for (const def of STORES) {
|
|
10169
|
+
if (db.objectStoreNames.contains(def.name))
|
|
10170
|
+
continue;
|
|
10171
|
+
db.createObjectStore(def.name, def.autoIncrement ? { keyPath: def.keyPath, autoIncrement: true } : undefined);
|
|
10172
|
+
}
|
|
10173
|
+
};
|
|
10174
|
+
open.onsuccess = () => resolve(open.result);
|
|
10175
|
+
open.onerror = () => reject(open.error);
|
|
10176
|
+
// A blocked upgrade means another tab holds an older version open; let
|
|
10177
|
+
// it surface as an error rather than hang forever.
|
|
10178
|
+
open.onblocked = () => reject(new Error("offline DB upgrade blocked by another tab"));
|
|
10179
|
+
});
|
|
10180
|
+
}
|
|
10181
|
+
/** Wraps an open connection with the typed operations the store needs. */
|
|
10182
|
+
class IdbConnection {
|
|
10183
|
+
db;
|
|
10184
|
+
constructor(db) {
|
|
10185
|
+
this.db = db;
|
|
10186
|
+
}
|
|
10187
|
+
tx(store, mode) {
|
|
10188
|
+
return this.db.transaction(store, mode).objectStore(store);
|
|
10189
|
+
}
|
|
10190
|
+
get(store, key) {
|
|
10191
|
+
return req(this.tx(store, "readonly").get(key)).then((v) => v ?? null);
|
|
10192
|
+
}
|
|
10193
|
+
async put(store, key, value) {
|
|
10194
|
+
await req(this.tx(store, "readwrite").put(value, key));
|
|
10195
|
+
}
|
|
10196
|
+
async delete(store, key) {
|
|
10197
|
+
await req(this.tx(store, "readwrite").delete(key));
|
|
10198
|
+
}
|
|
10199
|
+
async clear(store) {
|
|
10200
|
+
await req(this.tx(store, "readwrite").clear());
|
|
10201
|
+
}
|
|
10202
|
+
/** Append to an autoincrement store; resolves with the generated key. */
|
|
10203
|
+
async append(store, value) {
|
|
10204
|
+
const key = await req(this.tx(store, "readwrite").add(value));
|
|
10205
|
+
return key;
|
|
10206
|
+
}
|
|
10207
|
+
/** All entries whose string key falls in `[lower, upper)`, in key order. */
|
|
10208
|
+
range(store, lower, upper) {
|
|
10209
|
+
return this.scan(store, IDBKeyRange.bound(lower, upper, false, true));
|
|
10210
|
+
}
|
|
10211
|
+
/** All entries whose key starts with `prefix`, in key order. */
|
|
10212
|
+
prefix(store, prefix) {
|
|
10213
|
+
// "" is the largest BMP code unit, so `[prefix, prefix+)`
|
|
10214
|
+
// bounds every key that begins with `prefix`.
|
|
10215
|
+
return this.scan(store, IDBKeyRange.bound(prefix, prefix + "", false, true));
|
|
10216
|
+
}
|
|
10217
|
+
/** Every entry in the store, in key order. */
|
|
10218
|
+
all(store) {
|
|
10219
|
+
return this.scan(store, null);
|
|
10220
|
+
}
|
|
10221
|
+
scan(store, range) {
|
|
10222
|
+
return new Promise((resolve, reject) => {
|
|
10223
|
+
const out = [];
|
|
10224
|
+
const cursorReq = this.tx(store, "readonly").openCursor(range ?? undefined);
|
|
10225
|
+
cursorReq.onsuccess = () => {
|
|
10226
|
+
const cursor = cursorReq.result;
|
|
10227
|
+
if (!cursor)
|
|
10228
|
+
return resolve(out);
|
|
10229
|
+
out.push({ key: String(cursor.key), value: cursor.value });
|
|
10230
|
+
cursor.continue();
|
|
10231
|
+
};
|
|
10232
|
+
cursorReq.onerror = () => reject(cursorReq.error);
|
|
10233
|
+
});
|
|
10234
|
+
}
|
|
10235
|
+
}
|
|
10236
|
+
|
|
10237
|
+
/**
|
|
10238
|
+
* `IndexedDbStore` — the browser {@link OfflineStore}, backed by one IndexedDB
|
|
10239
|
+
* database per origin (see {@link ./schema}). It opens lazily on first use and
|
|
10240
|
+
* memoizes the connection. The queue methods sit on the autoincrement
|
|
10241
|
+
* `outbound-queue` store and return mutations in HLC order for the sync engine.
|
|
10242
|
+
*
|
|
10243
|
+
* Construct only where `indexedDB` exists — {@link ../detect.isOfflineCapable}
|
|
10244
|
+
* gates this, and the {@link ../../core/Client} falls back to {@link ./NoopStore}
|
|
10245
|
+
* everywhere else.
|
|
10246
|
+
*/
|
|
10247
|
+
class IndexedDbStore {
|
|
10248
|
+
enabled = true;
|
|
10249
|
+
conn = null;
|
|
10250
|
+
opening = null;
|
|
10251
|
+
connect() {
|
|
10252
|
+
if (this.conn)
|
|
10253
|
+
return Promise.resolve(this.conn);
|
|
10254
|
+
if (!this.opening) {
|
|
10255
|
+
this.opening = openDatabase()
|
|
10256
|
+
.then((db) => {
|
|
10257
|
+
this.conn = new IdbConnection(db);
|
|
10258
|
+
return this.conn;
|
|
10259
|
+
})
|
|
10260
|
+
.finally(() => {
|
|
10261
|
+
this.opening = null;
|
|
10262
|
+
});
|
|
10263
|
+
}
|
|
10264
|
+
return this.opening;
|
|
10265
|
+
}
|
|
10266
|
+
async ready() {
|
|
10267
|
+
await this.connect();
|
|
10268
|
+
}
|
|
10269
|
+
async get(store, key) {
|
|
10270
|
+
return (await this.connect()).get(store, key);
|
|
10271
|
+
}
|
|
10272
|
+
async put(store, key, value) {
|
|
10273
|
+
await (await this.connect()).put(store, key, value);
|
|
10274
|
+
}
|
|
10275
|
+
async delete(store, key) {
|
|
10276
|
+
await (await this.connect()).delete(store, key);
|
|
10277
|
+
}
|
|
10278
|
+
async prefix(store, prefix) {
|
|
10279
|
+
return (await this.connect()).prefix(store, prefix);
|
|
10280
|
+
}
|
|
10281
|
+
async range(store, lower, upper) {
|
|
10282
|
+
return (await this.connect()).range(store, lower, upper);
|
|
10283
|
+
}
|
|
10284
|
+
async all(store) {
|
|
10285
|
+
return (await this.connect()).all(store);
|
|
10286
|
+
}
|
|
10287
|
+
async clear(store) {
|
|
10288
|
+
await (await this.connect()).clear(store);
|
|
10289
|
+
}
|
|
10290
|
+
async enqueue(entry) {
|
|
10291
|
+
// keyPath autoincrement writes the generated `seq` back into the record.
|
|
10292
|
+
return (await this.connect()).append("outbound-queue", entry);
|
|
10293
|
+
}
|
|
10294
|
+
async dequeue(seq) {
|
|
10295
|
+
await (await this.connect()).delete("outbound-queue", seq);
|
|
10296
|
+
}
|
|
10297
|
+
async queued() {
|
|
10298
|
+
const rows = await (await this.connect()).all("outbound-queue");
|
|
10299
|
+
return rows.map((r) => r.value).sort((a, b) => compareHlc(a.hlc, b.hlc));
|
|
10300
|
+
}
|
|
10301
|
+
}
|
|
10302
|
+
|
|
10303
|
+
/**
|
|
10304
|
+
* `NoopStore` — the {@link OfflineStore} used wherever offline support is off
|
|
10305
|
+
* (Node, Workers, SSR, or when a consumer disables it). Every read returns
|
|
10306
|
+
* empty and every write is dropped, so the namespace hooks that call into it
|
|
10307
|
+
* behave exactly as they did before the offline layer existed. `enabled` is
|
|
10308
|
+
* `false` so hot paths can skip the call entirely.
|
|
10309
|
+
*/
|
|
10310
|
+
class NoopStore {
|
|
10311
|
+
enabled = false;
|
|
10312
|
+
async ready() { }
|
|
10313
|
+
async get(_store, _key) {
|
|
10314
|
+
return null;
|
|
7716
10315
|
}
|
|
7717
|
-
async
|
|
7718
|
-
|
|
7719
|
-
|
|
7720
|
-
|
|
7721
|
-
fetch: this.deps.http.fetch,
|
|
7722
|
-
}).fetchMetadata();
|
|
7723
|
-
return meta?.historyPolicy ?? "static";
|
|
10316
|
+
async put(_store, _key, _value) { }
|
|
10317
|
+
async delete(_store, _key) { }
|
|
10318
|
+
async prefix(_store, _prefix) {
|
|
10319
|
+
return [];
|
|
7724
10320
|
}
|
|
7725
|
-
async
|
|
7726
|
-
|
|
7727
|
-
const res = await this.deps.http.post("/api/ws-ticket", {});
|
|
7728
|
-
return res?.ticket ?? null;
|
|
7729
|
-
}
|
|
7730
|
-
catch {
|
|
7731
|
-
return null;
|
|
7732
|
-
}
|
|
10321
|
+
async range(_store, _lower, _upper) {
|
|
10322
|
+
return [];
|
|
7733
10323
|
}
|
|
7734
|
-
|
|
7735
|
-
|
|
7736
|
-
|
|
7737
|
-
|
|
7738
|
-
|
|
10324
|
+
async all(_store) {
|
|
10325
|
+
return [];
|
|
10326
|
+
}
|
|
10327
|
+
async clear(_store) { }
|
|
10328
|
+
async enqueue(_entry) {
|
|
10329
|
+
return -1;
|
|
10330
|
+
}
|
|
10331
|
+
async dequeue(_seq) { }
|
|
10332
|
+
async queued() {
|
|
10333
|
+
return [];
|
|
7739
10334
|
}
|
|
7740
10335
|
}
|
|
7741
10336
|
|
|
7742
10337
|
/**
|
|
7743
|
-
* `
|
|
7744
|
-
*
|
|
7745
|
-
*
|
|
7746
|
-
*
|
|
7747
|
-
*
|
|
7748
|
-
*
|
|
7749
|
-
*
|
|
7750
|
-
*
|
|
7751
|
-
*
|
|
7752
|
-
*
|
|
7753
|
-
* These are MANAGEMENT calls — the agent itself runs on the accelerator. Only an
|
|
7754
|
-
* app's owner or an editor of its shared Space may create/update/enable an agent
|
|
7755
|
-
* (viewers can read). Authorization rides the user session token; non-owner
|
|
7756
|
-
* editors pass their management Space via `opts.space`.
|
|
7757
|
-
*
|
|
7758
|
-
* An agent is "prompts + persona": a system prompt, a model, named prompt
|
|
7759
|
-
* `skills`, and `triggers` that decide when it speaks — plus optional `tools`
|
|
7760
|
-
* that let it act on the app (query/write its database, call its functions,
|
|
7761
|
-
* resolve its channels) via a server-side function-calling loop. See
|
|
7762
|
-
* {@link AgentToolsConfig}. Pair with the `@MuhkooAgent`/`@MuhkooSpace`/
|
|
7763
|
-
* `@MuhkooDB`/`@MuhkooFunction` decorators + `ejectAgentPrompt`/`ejectAgentTools`
|
|
7764
|
-
* to generate `systemPrompt` and `tools` straight from your app's surface.
|
|
10338
|
+
* `LWWRegister` — a last-write-wins register CRDT, the conflict model for a
|
|
10339
|
+
* `kv` value and for an app-state snapshot. A register holds a single value
|
|
10340
|
+
* stamped with the {@link HlcTimestamp} of the write that produced it. Two
|
|
10341
|
+
* concurrent writes converge to the one with the greater HLC; the loser is
|
|
10342
|
+
* discarded. A delete is just a write that sets `deleted` (a tombstone), so a
|
|
10343
|
+
* stale write that arrives after a newer delete can't resurrect the value.
|
|
10344
|
+
*
|
|
10345
|
+
* Because the HLC total-orders every write deterministically (down to a nodeId
|
|
10346
|
+
* tiebreak), every replica that has seen the same set of writes lands on the
|
|
10347
|
+
* same value — that's the convergence guarantee.
|
|
7765
10348
|
*/
|
|
7766
|
-
|
|
7767
|
-
|
|
7768
|
-
|
|
7769
|
-
|
|
10349
|
+
/** An empty register that loses to any real write. */
|
|
10350
|
+
function emptyRegister() {
|
|
10351
|
+
return { value: null, hlc: ZERO_HLC, deleted: true };
|
|
10352
|
+
}
|
|
10353
|
+
/**
|
|
10354
|
+
* Merge two register states, returning the winner (greater HLC). Pure: neither
|
|
10355
|
+
* input is mutated. Used identically on the inbound change feed and on
|
|
10356
|
+
* sync-replay reconciliation so there's exactly one definition of "who wins".
|
|
10357
|
+
*/
|
|
10358
|
+
function mergeRegister(a, b) {
|
|
10359
|
+
return compareHlc(a.hlc, b.hlc) >= 0 ? a : b;
|
|
10360
|
+
}
|
|
10361
|
+
/** Build the register produced by a `set`. */
|
|
10362
|
+
function writeRegister(value, hlc) {
|
|
10363
|
+
return { value, hlc, deleted: false };
|
|
10364
|
+
}
|
|
10365
|
+
/** Build the register produced by a `delete` (a tombstone at `hlc`). */
|
|
10366
|
+
function deleteRegister(hlc) {
|
|
10367
|
+
return { value: null, hlc, deleted: true };
|
|
10368
|
+
}
|
|
10369
|
+
|
|
10370
|
+
/**
|
|
10371
|
+
* `KvCache` — the offline adapter for `client.kv`. Stores each value's
|
|
10372
|
+
* ciphertext envelope (never plaintext) under `${commitment}|${collection}/${id}`
|
|
10373
|
+
* as an LWW-Register stamped with an HLC, so reads work offline and concurrent
|
|
10374
|
+
* writes converge (see {@link ./crdt/LWWRegister}). Deletes are causal
|
|
10375
|
+
* tombstones — a stale write can't resurrect a key a newer delete removed.
|
|
10376
|
+
*
|
|
10377
|
+
* True cross-device convergence also needs the accelerator to honor the HLC
|
|
10378
|
+
* (see the server's `kvmeta` sidecar); this adapter embeds the stamp now and
|
|
10379
|
+
* degrades gracefully (last-writer-by-arrival) until that ships.
|
|
10380
|
+
*/
|
|
10381
|
+
class KvCache {
|
|
10382
|
+
manager;
|
|
10383
|
+
store;
|
|
10384
|
+
constructor(manager) {
|
|
10385
|
+
this.manager = manager;
|
|
10386
|
+
this.store = manager.store;
|
|
7770
10387
|
}
|
|
7771
|
-
|
|
7772
|
-
|
|
7773
|
-
const res = await this.deps.http.get(this.path(appId, "", opts));
|
|
7774
|
-
return res.agents ?? [];
|
|
10388
|
+
get enabled() {
|
|
10389
|
+
return this.manager.enabled;
|
|
7775
10390
|
}
|
|
7776
|
-
|
|
7777
|
-
|
|
7778
|
-
return this.deps.http.get(this.path(appId, `/${encodeURIComponent(agentId)}`, opts));
|
|
10391
|
+
nextHlc() {
|
|
10392
|
+
return this.manager.nextHlc();
|
|
7779
10393
|
}
|
|
7780
|
-
|
|
7781
|
-
|
|
7782
|
-
return this.deps.http.post(this.path(appId, "", {}), { ...input, space: opts.space });
|
|
10394
|
+
newClientId() {
|
|
10395
|
+
return this.manager.newClientId();
|
|
7783
10396
|
}
|
|
7784
|
-
|
|
7785
|
-
|
|
7786
|
-
return this.deps.http.patch(this.path(appId, `/${encodeURIComponent(agentId)}`, {}), { ...patch, space: opts.space });
|
|
10397
|
+
key(commitment, fullKey) {
|
|
10398
|
+
return `${commitment}|${fullKey}`;
|
|
7787
10399
|
}
|
|
7788
|
-
/**
|
|
7789
|
-
|
|
7790
|
-
return this.
|
|
10400
|
+
/** Read the cached register for `collection/id`, or null when absent. */
|
|
10401
|
+
read(commitment, fullKey) {
|
|
10402
|
+
return this.store.get("kv-cache", this.key(commitment, fullKey));
|
|
7791
10403
|
}
|
|
7792
|
-
/**
|
|
7793
|
-
async
|
|
7794
|
-
|
|
10404
|
+
/** Optimistically record a local `set` (ciphertext envelope + HLC). */
|
|
10405
|
+
async writeLocal(commitment, fullKey, envelope, hlc) {
|
|
10406
|
+
await this.store.put("kv-cache", this.key(commitment, fullKey), writeRegister(envelope, hlc));
|
|
7795
10407
|
}
|
|
7796
|
-
/**
|
|
7797
|
-
async
|
|
7798
|
-
|
|
10408
|
+
/** Optimistically record a local `delete` (tombstone at HLC). */
|
|
10409
|
+
async deleteLocal(commitment, fullKey, hlc) {
|
|
10410
|
+
await this.store.put("kv-cache", this.key(commitment, fullKey), deleteRegister(hlc));
|
|
7799
10411
|
}
|
|
7800
|
-
|
|
7801
|
-
|
|
7802
|
-
|
|
10412
|
+
/**
|
|
10413
|
+
* Merge an authoritative value (from a GET refresh or a realtime frame) into
|
|
10414
|
+
* the cache by HLC. When the server doesn't supply an HLC yet, treat it as
|
|
10415
|
+
* "now-ish" by stamping our clock so a server value still beats stale local
|
|
10416
|
+
* state but a fresh local write wins.
|
|
10417
|
+
*/
|
|
10418
|
+
async mergeRemote(commitment, fullKey, envelope, op, hlc) {
|
|
10419
|
+
const k = this.key(commitment, fullKey);
|
|
10420
|
+
const incoming = op === "delete"
|
|
10421
|
+
? deleteRegister(hlc ?? (await this.nextHlc()))
|
|
10422
|
+
: writeRegister(envelope, hlc ?? (await this.nextHlc()));
|
|
10423
|
+
const local = (await this.store.get("kv-cache", k)) ?? emptyRegister();
|
|
10424
|
+
await this.store.put("kv-cache", k, mergeRegister(local, incoming));
|
|
10425
|
+
}
|
|
10426
|
+
/** Non-tombstoned full keys for a commitment (used for offline `list`). */
|
|
10427
|
+
async liveKeys(commitment) {
|
|
10428
|
+
const prefix = `${commitment}|`;
|
|
10429
|
+
const rows = await this.store.prefix("kv-cache", prefix);
|
|
10430
|
+
return rows.filter((r) => !r.value.deleted).map((r) => r.key.slice(prefix.length));
|
|
10431
|
+
}
|
|
10432
|
+
/** Queue a kv mutation for replay on reconnect. */
|
|
10433
|
+
async enqueue(method, args, hlc, clientId) {
|
|
10434
|
+
await this.manager.enqueue({ hlc, clientId, domain: "kv", method, args });
|
|
7803
10435
|
}
|
|
7804
10436
|
}
|
|
7805
10437
|
|
|
7806
10438
|
/**
|
|
7807
|
-
* `
|
|
7808
|
-
*
|
|
7809
|
-
*
|
|
7810
|
-
*
|
|
7811
|
-
*
|
|
7812
|
-
*
|
|
7813
|
-
*
|
|
7814
|
-
*
|
|
7815
|
-
*
|
|
7816
|
-
* await client.functions.enable(appId, config.functionId, spaceId); // Space-bound
|
|
7817
|
-
* const out = await client.functions.invoke({ name: 'hello', slug }, { body: { q: 1 } });
|
|
7818
|
-
*
|
|
7819
|
-
* These are MANAGEMENT calls — the function itself runs on the accelerator
|
|
7820
|
-
* (uploaded just-in-time, decrypted only at invocation). Only an app's owner or
|
|
7821
|
-
* an editor of its shared Space may deploy/update/enable (viewers read).
|
|
7822
|
-
* Authorization rides the user session token; non-owner editors pass their
|
|
7823
|
-
* management Space via `opts.space`.
|
|
7824
|
-
*
|
|
7825
|
-
* v1 is "compute-only": a function is an untrusted single-module ES worker
|
|
7826
|
-
* (`export default { fetch }`) with NO platform bindings — it reaches the
|
|
7827
|
-
* platform as an external client (the end-user session is passed through). Two
|
|
7828
|
-
* triggers: `http` (its own subdomain) and `space` (invoked on Space messages,
|
|
7829
|
-
* like a Programmable Agent).
|
|
10439
|
+
* `DbCache` — the offline adapter for `client.db`. Unlike kv, db rows are
|
|
10440
|
+
* server-plaintext, so they're cached as-is. The SDK doesn't know a table's
|
|
10441
|
+
* primary-key column, so it caches schema-agnostically:
|
|
10442
|
+
* - `get(id)` results keyed by the requested id (`${table}|row|${id}`),
|
|
10443
|
+
* - `query(q)` result pages keyed by a stable hash of the query.
|
|
10444
|
+
*
|
|
10445
|
+
* Writes carry per-column HLC stamps (`_hlc`) so the accelerator's per-column
|
|
10446
|
+
* LWW merge can converge concurrent edits; offline writes are queued and
|
|
10447
|
+
* replayed. Optimistic row patches keep `get` fresh between sync cycles.
|
|
7830
10448
|
*/
|
|
7831
|
-
/**
|
|
7832
|
-
|
|
7833
|
-
|
|
7834
|
-
|
|
7835
|
-
|
|
7836
|
-
|
|
7837
|
-
|
|
7838
|
-
|
|
7839
|
-
|
|
7840
|
-
|
|
7841
|
-
(
|
|
7842
|
-
}
|
|
7843
|
-
class
|
|
7844
|
-
|
|
7845
|
-
|
|
7846
|
-
|
|
10449
|
+
/** Stable JSON for query-cache keys (sorted object keys → deterministic). */
|
|
10450
|
+
function stableStringify(value) {
|
|
10451
|
+
if (value === null || typeof value !== "object")
|
|
10452
|
+
return JSON.stringify(value);
|
|
10453
|
+
if (Array.isArray(value))
|
|
10454
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
10455
|
+
const obj = value;
|
|
10456
|
+
return `{${Object.keys(obj)
|
|
10457
|
+
.sort()
|
|
10458
|
+
.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`)
|
|
10459
|
+
.join(",")}}`;
|
|
10460
|
+
}
|
|
10461
|
+
class DbCache {
|
|
10462
|
+
manager;
|
|
10463
|
+
store;
|
|
10464
|
+
constructor(manager) {
|
|
10465
|
+
this.manager = manager;
|
|
10466
|
+
this.store = manager.store;
|
|
7847
10467
|
}
|
|
7848
|
-
|
|
7849
|
-
|
|
7850
|
-
const res = await this.deps.http.get(this.path(appId, "", opts));
|
|
7851
|
-
return res.functions ?? [];
|
|
10468
|
+
get enabled() {
|
|
10469
|
+
return this.manager.enabled;
|
|
7852
10470
|
}
|
|
7853
|
-
|
|
7854
|
-
|
|
7855
|
-
return this.deps.http.get(this.path(appId, `/${encodeURIComponent(functionId)}`, opts));
|
|
10471
|
+
nextHlc() {
|
|
10472
|
+
return this.manager.nextHlc();
|
|
7856
10473
|
}
|
|
7857
|
-
|
|
7858
|
-
|
|
7859
|
-
return this.deps.http.get(this.path(appId, `/${encodeURIComponent(functionId)}/code`, opts));
|
|
10474
|
+
newClientId() {
|
|
10475
|
+
return this.manager.newClientId();
|
|
7860
10476
|
}
|
|
7861
|
-
/**
|
|
7862
|
-
|
|
7863
|
-
|
|
10477
|
+
/** Build the `{ [col]: hlc }` map for a write (one stamp across its columns). */
|
|
10478
|
+
columnStamps(values, hlc) {
|
|
10479
|
+
const out = {};
|
|
10480
|
+
for (const col of Object.keys(values))
|
|
10481
|
+
out[col] = hlc;
|
|
10482
|
+
return out;
|
|
7864
10483
|
}
|
|
7865
|
-
|
|
7866
|
-
|
|
7867
|
-
return this.deps.http.patch(this.path(appId, `/${encodeURIComponent(functionId)}`, {}), { ...patch, space: opts.space });
|
|
10484
|
+
rowKey(table, id) {
|
|
10485
|
+
return `${table}|row|${id}`;
|
|
7868
10486
|
}
|
|
7869
|
-
|
|
7870
|
-
|
|
7871
|
-
return this.deps.http.del(this.path(appId, `/${encodeURIComponent(functionId)}`, {}), { space: opts.space });
|
|
10487
|
+
queryKey(table, q) {
|
|
10488
|
+
return `${table}|query|${stableStringify(q)}`;
|
|
7872
10489
|
}
|
|
7873
|
-
|
|
7874
|
-
|
|
7875
|
-
return this.deps.http.post(this.path(appId, `/${encodeURIComponent(functionId)}/enable`, {}), { targetSpaceId: spaceId, space: opts.space });
|
|
10490
|
+
async readRow(table, id) {
|
|
10491
|
+
return this.store.get("db-row-cache", this.rowKey(table, id));
|
|
7876
10492
|
}
|
|
7877
|
-
|
|
7878
|
-
|
|
7879
|
-
return this.deps.http.post(this.path(appId, `/${encodeURIComponent(functionId)}/disable`, {}), { targetSpaceId: spaceId, space: opts.space });
|
|
10493
|
+
async writeRow(table, id, row) {
|
|
10494
|
+
await this.store.put("db-row-cache", this.rowKey(table, id), row);
|
|
7880
10495
|
}
|
|
7881
|
-
/**
|
|
7882
|
-
|
|
7883
|
-
|
|
7884
|
-
|
|
7885
|
-
* const out = await client.functions.invoke('hello--myapp', { body: { q: 1 } });
|
|
7886
|
-
* const out = await client.functions.invoke('https://hello--myapp.fns.muhkoo.dev/x');
|
|
7887
|
-
*
|
|
7888
|
-
* The request carries the SDK's credentials (`X-Muhkoo-Key` and, when a
|
|
7889
|
-
* user is signed in, `X-Muhkoo-Session`) — the platform passes the session
|
|
7890
|
-
* token through to the function, which can verify it server-side via
|
|
7891
|
-
* `POST /api/auth/verify` to establish the caller's identity.
|
|
7892
|
-
*
|
|
7893
|
-
* Non-2xx responses throw {@link HttpError}. For functions that return
|
|
7894
|
-
* something other than JSON, use {@link invokeRaw}.
|
|
7895
|
-
*/
|
|
7896
|
-
async invoke(target, opts = {}) {
|
|
7897
|
-
const res = await this.invokeRaw(target, opts);
|
|
7898
|
-
let parsed = null;
|
|
7899
|
-
try {
|
|
7900
|
-
parsed = await res.json();
|
|
7901
|
-
}
|
|
7902
|
-
catch {
|
|
7903
|
-
// Non-JSON body — fall through to the status-only error below.
|
|
7904
|
-
}
|
|
7905
|
-
if (!res.ok) {
|
|
7906
|
-
const msg = parsed && typeof parsed === "object" && "error" in parsed
|
|
7907
|
-
? String(parsed.error)
|
|
7908
|
-
: `${res.status} ${res.statusText}`;
|
|
7909
|
-
throw new HttpError(msg, res.status, parsed);
|
|
7910
|
-
}
|
|
7911
|
-
return parsed;
|
|
10496
|
+
/** Shallow-merge a partial update into a cached row (optimistic). */
|
|
10497
|
+
async patchRow(table, id, values) {
|
|
10498
|
+
const existing = (await this.readRow(table, id)) ?? {};
|
|
10499
|
+
await this.writeRow(table, id, { ...existing, ...values });
|
|
7912
10500
|
}
|
|
7913
|
-
|
|
7914
|
-
|
|
7915
|
-
const init = {
|
|
7916
|
-
method: opts.method ?? (opts.body !== undefined ? "POST" : "GET"),
|
|
7917
|
-
};
|
|
7918
|
-
const headers = new Headers(opts.headers);
|
|
7919
|
-
if (opts.body !== undefined) {
|
|
7920
|
-
if (isRawBody(opts.body)) {
|
|
7921
|
-
init.body = opts.body;
|
|
7922
|
-
}
|
|
7923
|
-
else {
|
|
7924
|
-
init.body = JSON.stringify(opts.body);
|
|
7925
|
-
if (!headers.has("Content-Type"))
|
|
7926
|
-
headers.set("Content-Type", "application/json");
|
|
7927
|
-
}
|
|
7928
|
-
}
|
|
7929
|
-
init.headers = headers;
|
|
7930
|
-
return this.deps.http.fetch(this.invokeUrl(target, opts.path), init);
|
|
10501
|
+
async deleteRow(table, id) {
|
|
10502
|
+
await this.store.delete("db-row-cache", this.rowKey(table, id));
|
|
7931
10503
|
}
|
|
7932
|
-
|
|
7933
|
-
|
|
7934
|
-
const suffix = this.deps.fnHostSuffix ?? DEFAULT_FN_HOST_SUFFIX;
|
|
7935
|
-
let base;
|
|
7936
|
-
if (typeof target === "string") {
|
|
7937
|
-
base = /^https?:\/\//.test(target) ? target : `https://${target}.${suffix}`;
|
|
7938
|
-
}
|
|
7939
|
-
else {
|
|
7940
|
-
base = `https://${target.name}--${target.slug}.${suffix}`;
|
|
7941
|
-
}
|
|
7942
|
-
if (!path)
|
|
7943
|
-
return base;
|
|
7944
|
-
return `${base.replace(/\/+$/, "")}${path.startsWith("/") ? "" : "/"}${path}`;
|
|
10504
|
+
async readQuery(table, q) {
|
|
10505
|
+
return this.store.get("db-query-cache", this.queryKey(table, q));
|
|
7945
10506
|
}
|
|
7946
|
-
|
|
7947
|
-
|
|
7948
|
-
|
|
10507
|
+
async writeQuery(table, q, page) {
|
|
10508
|
+
await this.store.put("db-query-cache", this.queryKey(table, q), page);
|
|
10509
|
+
}
|
|
10510
|
+
async enqueue(method, args, hlc, clientId) {
|
|
10511
|
+
await this.manager.enqueue({ hlc, clientId, domain: "db", method, args });
|
|
7949
10512
|
}
|
|
7950
10513
|
}
|
|
7951
10514
|
|
|
@@ -8001,6 +10564,8 @@ class Client {
|
|
|
8001
10564
|
agents;
|
|
8002
10565
|
/** Serverless functions — `client.functions.deploy(appId, …)`, etc. */
|
|
8003
10566
|
functions;
|
|
10567
|
+
/** Offline cache + sync — `client.offline.status`, `client.offline.snapshot`, etc. */
|
|
10568
|
+
offline;
|
|
8004
10569
|
session;
|
|
8005
10570
|
http;
|
|
8006
10571
|
/** Subscribers notified when a session expires and can't be recovered. */
|
|
@@ -8020,11 +10585,40 @@ class Client {
|
|
|
8020
10585
|
}
|
|
8021
10586
|
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
8022
10587
|
this.session = new SessionState(options.sessionStore ?? defaultSessionStore());
|
|
10588
|
+
// Offline layer. On by default in browsers, a no-op (NoopStore) in Node,
|
|
10589
|
+
// Workers, and SSR — so existing consumers are unaffected unless they're
|
|
10590
|
+
// in a browser that can actually cache.
|
|
10591
|
+
const offlineEnabled = options.offline?.enabled ?? isOfflineCapable();
|
|
10592
|
+
const offlineStore = offlineEnabled
|
|
10593
|
+
? (options.offline?.store ?? new IndexedDbStore())
|
|
10594
|
+
: new NoopStore();
|
|
10595
|
+
this.offline = new OfflineManager({
|
|
10596
|
+
store: offlineStore,
|
|
10597
|
+
session: this.session,
|
|
10598
|
+
enabled: offlineEnabled,
|
|
10599
|
+
});
|
|
10600
|
+
// Wrap fetch so every accelerator round-trip feeds the connectivity
|
|
10601
|
+
// detector: a thrown fetch is the strongest "offline" signal, a 2xx the
|
|
10602
|
+
// strongest "online" one. Only when offline support is active.
|
|
10603
|
+
const baseFetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
10604
|
+
const reportingFetch = offlineEnabled && baseFetch
|
|
10605
|
+
? async (input, init) => {
|
|
10606
|
+
try {
|
|
10607
|
+
const res = await baseFetch(input, init);
|
|
10608
|
+
this.offline.reportFetchSuccess();
|
|
10609
|
+
return res;
|
|
10610
|
+
}
|
|
10611
|
+
catch (err) {
|
|
10612
|
+
this.offline.reportFetchFailure();
|
|
10613
|
+
throw err;
|
|
10614
|
+
}
|
|
10615
|
+
}
|
|
10616
|
+
: options.fetch;
|
|
8023
10617
|
this.http = new HttpClient({
|
|
8024
10618
|
baseUrl: this.baseUrl,
|
|
8025
10619
|
apiKey: options.apiKey,
|
|
8026
10620
|
getSessionToken: () => this.session.token,
|
|
8027
|
-
fetch:
|
|
10621
|
+
fetch: reportingFetch,
|
|
8028
10622
|
// Self-heal stale sessions: on a 401, try a silent re-auth (only
|
|
8029
10623
|
// works while unlocked). If it can't, `recoverSession` fires the
|
|
8030
10624
|
// session-expired event so the app can redirect to login.
|
|
@@ -8035,9 +10629,24 @@ class Client {
|
|
|
8035
10629
|
const wsBaseUrl = toWsBase(this.baseUrl);
|
|
8036
10630
|
const authBaseUrl = (options.authBaseUrl ?? DEFAULT_AUTH_BASE_URL).replace(/\/+$/, "");
|
|
8037
10631
|
this.auth = new AuthNamespace({ auth: authClient, circuits, session: this.session, authBaseUrl });
|
|
8038
|
-
|
|
8039
|
-
|
|
8040
|
-
|
|
10632
|
+
const deferShardUpload = offlineEnabled
|
|
10633
|
+
? (hash) => this.offline.deferShardUpload(hash)
|
|
10634
|
+
: undefined;
|
|
10635
|
+
const kvCache = offlineEnabled ? new KvCache(this.offline) : undefined;
|
|
10636
|
+
this.kv = new KvNamespace({ http: this.http, session: this.session, wsBaseUrl, offline: kvCache });
|
|
10637
|
+
if (offlineEnabled)
|
|
10638
|
+
this.offline.registerReplayer("kv", (e) => this.kv.replay(e));
|
|
10639
|
+
const dbCache = offlineEnabled ? new DbCache(this.offline) : undefined;
|
|
10640
|
+
this.db = new DbNamespace({ http: this.http, offline: dbCache });
|
|
10641
|
+
if (offlineEnabled)
|
|
10642
|
+
this.offline.registerReplayer("db", (e) => this.db.replay(e));
|
|
10643
|
+
this.storage = new StorageNamespace({
|
|
10644
|
+
http: this.http,
|
|
10645
|
+
baseUrl: this.baseUrl,
|
|
10646
|
+
kv: this.kv,
|
|
10647
|
+
shardCache: this.offline.fileCache,
|
|
10648
|
+
deferShardUpload,
|
|
10649
|
+
});
|
|
8041
10650
|
this.message = new MessageNamespace({ http: this.http, session: this.session, wsBaseUrl });
|
|
8042
10651
|
// Group keys are cached in the user's PersonalSpace (encrypted at rest
|
|
8043
10652
|
// by client.kv), so a returning member hydrates without a fresh keyring
|
|
@@ -8051,9 +10660,29 @@ class Client {
|
|
|
8051
10660
|
session: this.session,
|
|
8052
10661
|
wsBaseUrl,
|
|
8053
10662
|
cache: spaceKeyCache,
|
|
10663
|
+
offline: this.offline,
|
|
10664
|
+
p2p: options.p2p,
|
|
8054
10665
|
});
|
|
8055
10666
|
this.agents = new AgentsNamespace({ http: this.http });
|
|
8056
10667
|
this.functions = new FunctionsNamespace({ http: this.http, fnHostSuffix: options.functionsHostSuffix });
|
|
10668
|
+
// Replay deferred shard uploads on reconnect. The bytes live in the
|
|
10669
|
+
// Cache API keyed by hash; re-PUT them through a (defer-free) shard
|
|
10670
|
+
// client so a failure surfaces as transient and the entry stays queued.
|
|
10671
|
+
if (offlineEnabled && this.offline.fileCache) {
|
|
10672
|
+
const replayShards = new ShardClient({
|
|
10673
|
+
baseUrl: this.baseUrl,
|
|
10674
|
+
pathPrefix: "/api/shards",
|
|
10675
|
+
fetch: this.http.fetch,
|
|
10676
|
+
cache: this.offline.fileCache,
|
|
10677
|
+
});
|
|
10678
|
+
this.offline.registerReplayer("file", async (entry) => {
|
|
10679
|
+
const { hash } = entry.args;
|
|
10680
|
+
const bytes = await this.offline.fileCache.get(hash);
|
|
10681
|
+
if (!bytes)
|
|
10682
|
+
return; // evicted from cache — nothing to replay
|
|
10683
|
+
await replayShards.putShard(hash, bytes);
|
|
10684
|
+
});
|
|
10685
|
+
}
|
|
8057
10686
|
}
|
|
8058
10687
|
/** The currently signed-in user, or `null`. */
|
|
8059
10688
|
get user() {
|
|
@@ -8107,6 +10736,116 @@ class Client {
|
|
|
8107
10736
|
}
|
|
8108
10737
|
}
|
|
8109
10738
|
|
|
10739
|
+
/**
|
|
10740
|
+
* `LWWMap` — a map of independent last-write-wins registers, one per column.
|
|
10741
|
+
* This is the conflict model for a `db` row: two devices that edit *different*
|
|
10742
|
+
* columns of the same row while offline both keep their edits (no lost update),
|
|
10743
|
+
* and two that edit the *same* column converge to the greater-HLC write. A row
|
|
10744
|
+
* delete is a causal tombstone (see {@link ./ORSet}); a later write to any
|
|
10745
|
+
* column resurrects the row with just the surviving columns.
|
|
10746
|
+
*
|
|
10747
|
+
* This mirrors, on the client, exactly what the accelerator's `_muhkoo_crdt`
|
|
10748
|
+
* sidecar does server-side — so a row materializes identically whether it was
|
|
10749
|
+
* merged locally (offline) or by the server on replay.
|
|
10750
|
+
*/
|
|
10751
|
+
function emptyMap() {
|
|
10752
|
+
return { fields: {}, columnHlc: {}, tombstone: null };
|
|
10753
|
+
}
|
|
10754
|
+
/** Apply a local column write (insert/update) at the given per-column HLCs. */
|
|
10755
|
+
function writeColumns(map, values, hlc) {
|
|
10756
|
+
const next = {
|
|
10757
|
+
fields: { ...map.fields },
|
|
10758
|
+
columnHlc: { ...map.columnHlc },
|
|
10759
|
+
tombstone: map.tombstone,
|
|
10760
|
+
};
|
|
10761
|
+
for (const [col, value] of Object.entries(values)) {
|
|
10762
|
+
const prev = next.columnHlc[col];
|
|
10763
|
+
if (!prev || compareHlc(hlc, prev) > 0) {
|
|
10764
|
+
next.fields[col] = value;
|
|
10765
|
+
next.columnHlc[col] = hlc;
|
|
10766
|
+
}
|
|
10767
|
+
}
|
|
10768
|
+
return next;
|
|
10769
|
+
}
|
|
10770
|
+
/** Apply a row delete at `hlc` (causal tombstone). */
|
|
10771
|
+
function tombstoneRow(map, hlc) {
|
|
10772
|
+
const tombstone = !map.tombstone || compareHlc(hlc, map.tombstone) > 0 ? hlc : map.tombstone;
|
|
10773
|
+
return { fields: { ...map.fields }, columnHlc: { ...map.columnHlc }, tombstone };
|
|
10774
|
+
}
|
|
10775
|
+
/** Merge two row states column-by-column. Pure; neither input is mutated. */
|
|
10776
|
+
function mergeMap(a, b) {
|
|
10777
|
+
const cols = new Set([...Object.keys(a.columnHlc), ...Object.keys(b.columnHlc)]);
|
|
10778
|
+
const out = emptyMap();
|
|
10779
|
+
for (const col of cols) {
|
|
10780
|
+
const ah = a.columnHlc[col];
|
|
10781
|
+
const bh = b.columnHlc[col];
|
|
10782
|
+
const aWins = ah && (!bh || compareHlc(ah, bh) >= 0);
|
|
10783
|
+
const winner = aWins ? a : b;
|
|
10784
|
+
out.fields[col] = winner.fields[col];
|
|
10785
|
+
out.columnHlc[col] = winner.columnHlc[col];
|
|
10786
|
+
}
|
|
10787
|
+
out.tombstone =
|
|
10788
|
+
a.tombstone && b.tombstone
|
|
10789
|
+
? compareHlc(a.tombstone, b.tombstone) >= 0
|
|
10790
|
+
? a.tombstone
|
|
10791
|
+
: b.tombstone
|
|
10792
|
+
: (a.tombstone ?? b.tombstone);
|
|
10793
|
+
return out;
|
|
10794
|
+
}
|
|
10795
|
+
/**
|
|
10796
|
+
* Materialize the visible row from merged state, or `null` when the row is
|
|
10797
|
+
* deleted. A column is visible only when its write is causally newer than the
|
|
10798
|
+
* tombstone — so a delete hides every prior column, but a post-delete write to
|
|
10799
|
+
* a column brings the row back with just that column populated.
|
|
10800
|
+
*/
|
|
10801
|
+
function materializeRow(map) {
|
|
10802
|
+
const visible = {};
|
|
10803
|
+
let any = false;
|
|
10804
|
+
for (const [col, hlc] of Object.entries(map.columnHlc)) {
|
|
10805
|
+
if (!map.tombstone || compareHlc(hlc, map.tombstone) > 0) {
|
|
10806
|
+
visible[col] = map.fields[col];
|
|
10807
|
+
any = true;
|
|
10808
|
+
}
|
|
10809
|
+
}
|
|
10810
|
+
return any ? visible : null;
|
|
10811
|
+
}
|
|
10812
|
+
|
|
10813
|
+
/**
|
|
10814
|
+
* Tombstone helpers shared by the LWW CRDTs. We don't need a full
|
|
10815
|
+
* observed-remove set — every domain that deletes (kv keys, db rows) does so
|
|
10816
|
+
* by stamping a delete with an HLC and keeping that stamp around. The only
|
|
10817
|
+
* rule that matters is the causal one:
|
|
10818
|
+
*
|
|
10819
|
+
* a delete at HLC `d` hides the value, but a *later* write at HLC `w > d`
|
|
10820
|
+
* resurrects it; a *stale* write at `w <= d` is dropped.
|
|
10821
|
+
*
|
|
10822
|
+
* Keeping the delete HLC (rather than physically forgetting the key) is what
|
|
10823
|
+
* prevents a delayed offline write from "un-deleting" something a peer already
|
|
10824
|
+
* removed. Callers GC tombstones older than any possible offline window.
|
|
10825
|
+
*/
|
|
10826
|
+
/**
|
|
10827
|
+
* Does a write at `writeHlc` win against a tombstone at `tombstoneHlc`?
|
|
10828
|
+
* `true` ⇒ the write applies (resurrecting a deleted item if needed); `false`
|
|
10829
|
+
* ⇒ the tombstone still holds and the write is dropped. A missing tombstone
|
|
10830
|
+
* (`null`) means there's nothing suppressing the write.
|
|
10831
|
+
*/
|
|
10832
|
+
function writeBeatsTombstone(writeHlc, tombstoneHlc) {
|
|
10833
|
+
if (!tombstoneHlc)
|
|
10834
|
+
return true;
|
|
10835
|
+
return compareHlc(writeHlc, tombstoneHlc) > 0;
|
|
10836
|
+
}
|
|
10837
|
+
/**
|
|
10838
|
+
* Does a delete at `deleteHlc` win against the latest write at `writeHlc`?
|
|
10839
|
+
* `true` ⇒ the item is now tombstoned. Ties go to the delete (remove-wins) so
|
|
10840
|
+
* that a delete and a write stamped in the same instant resolve deterministically
|
|
10841
|
+
* toward removal.
|
|
10842
|
+
*/
|
|
10843
|
+
function deleteBeatsWrite(deleteHlc, writeHlc) {
|
|
10844
|
+
if (!writeHlc)
|
|
10845
|
+
return true;
|
|
10846
|
+
return compareHlc(deleteHlc, writeHlc) >= 0;
|
|
10847
|
+
}
|
|
10848
|
+
|
|
8110
10849
|
/**
|
|
8111
10850
|
* Muhkoo app decorators — declare your app's agent-facing surface in code, then
|
|
8112
10851
|
* "eject" a system prompt you hand to a Programmable Agent.
|
|
@@ -8814,5 +11553,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
|
|
|
8814
11553
|
default: loadRsWasm
|
|
8815
11554
|
});
|
|
8816
11555
|
|
|
8817
|
-
export { AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, DEFAULT_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DbNamespace, DbTable, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HashKnowledge, HttpClient, HttpError, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvNamespace, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PersonalSpaceClient, Poseidon, PreimagePoK, Ready, ReedSolomonCodec, Retry, Space as Room, SerializeMessage, SessionState, ShardClient, SharedSpaceClient, Space, SpaceKeyring, SpaceNamespace, SpacePacketCipher, StorageNamespace, VERSION, VaultUnavailableError, WSTransport, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, compilePrograms, concatBytes, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, ejectAgentPrompt, ejectAgentTools, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, fromBase64$1 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNumber, log, manifestToStat, poseidonHash, quickVerify, randomBytes, randomState, sealSeed, serialize, shardHash, signMessage, toBase64$1 as toBase64, toBase64Url, toField, toHex, unsealSeed, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase };
|
|
11556
|
+
export { AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, ConnectivityManager, DEFAULT_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DbCache, DbNamespace, DbTable, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HttpClient, HttpError, IndexedDbStore, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, Ready, ReedSolomonCodec, Retry, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VERSION, VaultUnavailableError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, compareHlc, compilePrograms, concatBytes, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, fromBase64$1 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isNumber, isOfflineCapable, isP2pCapable, isProvisional, log, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, sealSeed, serialize, shardHash, signMessage, stableStringify, toBase64$1 as toBase64, toBase64Url, toField, toHex, tombstoneRow, unpack, unsealSeed, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
|
|
8818
11557
|
//# sourceMappingURL=index.js.map
|