@unicitylabs/sphere-sdk 0.14.0-dev.5 → 0.14.0-dev.6

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/index.cjs CHANGED
@@ -27643,6 +27643,10 @@ function isValidationReject(err) {
27643
27643
  if (e === null) return false;
27644
27644
  return e.status === 422 || e.statusCode === 422 || e.code === "VALIDATION" || e.code === "VALIDATION_FAILED";
27645
27645
  }
27646
+ function retryAfterMsOf(err) {
27647
+ const retryAfter = err !== null && typeof err === "object" ? err.retryAfter : void 0;
27648
+ return typeof retryAfter === "number" && Number.isFinite(retryAfter) && retryAfter >= 0 ? retryAfter : null;
27649
+ }
27646
27650
  var chainRegistry = /* @__PURE__ */ new WeakMap();
27647
27651
  var replayRegistry = /* @__PURE__ */ new WeakMap();
27648
27652
  function registryFor(registry, kv, storeKey, make) {
@@ -28280,6 +28284,217 @@ function validatePayload(raw) {
28280
28284
  return p;
28281
28285
  }
28282
28286
 
28287
+ // modules/payments-v2/convergence.ts
28288
+ var HEARTBEAT_SEED_MS = 5e3;
28289
+ var HEARTBEAT_CAP_MS = 12e4;
28290
+ var ConvergenceHeartbeat = class {
28291
+ constructor(deps) {
28292
+ this.deps = deps;
28293
+ }
28294
+ timer = null;
28295
+ delayMs = HEARTBEAT_SEED_MS;
28296
+ notBefore = 0;
28297
+ running = false;
28298
+ start() {
28299
+ this.running = true;
28300
+ this.delayMs = HEARTBEAT_SEED_MS;
28301
+ this.notBefore = 0;
28302
+ }
28303
+ /** §7 quiescence unchanged: a mid-backoff stop() cancels cleanly. */
28304
+ stop() {
28305
+ this.running = false;
28306
+ this.clearTimer();
28307
+ }
28308
+ /** Reset to seed on progress, ×2 to the cap otherwise; idle stops rescheduling. */
28309
+ settle(outcome, pending) {
28310
+ if (outcome.retryAfterMs !== null) {
28311
+ this.notBefore = Math.max(this.notBefore, this.deps.now() + outcome.retryAfterMs);
28312
+ }
28313
+ if (!pending) {
28314
+ this.delayMs = HEARTBEAT_SEED_MS;
28315
+ return;
28316
+ }
28317
+ const delay = outcome.progress ? HEARTBEAT_SEED_MS : this.delayMs;
28318
+ this.delayMs = Math.min(delay * 2, HEARTBEAT_CAP_MS);
28319
+ this.schedule(delay);
28320
+ }
28321
+ /** New pending work (keep-open park / journaled delivery / mint hold): prompt retry. */
28322
+ arm() {
28323
+ if (!this.running) return;
28324
+ this.delayMs = Math.min(HEARTBEAT_SEED_MS * 2, HEARTBEAT_CAP_MS);
28325
+ this.schedule(HEARTBEAT_SEED_MS);
28326
+ }
28327
+ /** connection:status recovery: the backoff resets; a pending tick re-fires at the seed. */
28328
+ recovered() {
28329
+ this.delayMs = HEARTBEAT_SEED_MS;
28330
+ if (this.timer !== null) this.arm();
28331
+ }
28332
+ schedule(delayMs) {
28333
+ this.clearTimer();
28334
+ if (!this.running) return;
28335
+ const floored = Math.max(delayMs, this.notBefore - this.deps.now());
28336
+ this.timer = setTimeout(() => {
28337
+ this.timer = null;
28338
+ this.deps.onTick();
28339
+ }, floored);
28340
+ }
28341
+ clearTimer() {
28342
+ if (this.timer !== null) clearTimeout(this.timer);
28343
+ this.timer = null;
28344
+ }
28345
+ };
28346
+ var Converger = class {
28347
+ constructor(deps) {
28348
+ this.deps = deps;
28349
+ }
28350
+ /** Resume open intents, then drain the delivery + mint journals (all idempotent). */
28351
+ async convergeOnce() {
28352
+ let progress = false;
28353
+ let retryAfterMs = null;
28354
+ try {
28355
+ progress = await this.resumePass();
28356
+ } catch (err) {
28357
+ retryAfterMs = retryAfterMsOf(err);
28358
+ }
28359
+ try {
28360
+ const stats = await this.deps.stores.deliveryJournal.replay(this.replayDeps());
28361
+ progress = progress || stats.delivered > 0;
28362
+ } catch (err) {
28363
+ retryAfterMs ??= retryAfterMsOf(err);
28364
+ }
28365
+ try {
28366
+ progress = await this.deps.replayMints() > 0 || progress;
28367
+ } catch (err) {
28368
+ retryAfterMs ??= retryAfterMsOf(err);
28369
+ }
28370
+ return { progress, retryAfterMs };
28371
+ }
28372
+ /** §4 heartbeat predicate — read fresh from the §6 stores, never cached. */
28373
+ async pendingWork() {
28374
+ const [backstop, journal, mints] = await Promise.all([
28375
+ this.deps.stores.backstop.list(),
28376
+ this.deps.stores.deliveryJournal.list(),
28377
+ this.deps.stores.mintJournal.list()
28378
+ ]);
28379
+ return backstop.length > 0 || journal.some((e) => e.undeliverable !== true) || mints.length > 0;
28380
+ }
28381
+ async resumePass() {
28382
+ const report = await resumeAll(this.filteredMachineDeps());
28383
+ const progress = report.resumed.length > 0 || report.conflicted.length > 0;
28384
+ await this.deps.reconcile({
28385
+ resumed: report.resumed,
28386
+ conflicted: report.conflicted,
28387
+ open: report.failed
28388
+ });
28389
+ if (progress) await this.deps.refreshView().catch(() => void 0);
28390
+ return progress;
28391
+ }
28392
+ filteredMachineDeps() {
28393
+ const intents = this.deps.machineDeps.intents;
28394
+ return {
28395
+ ...this.deps.machineDeps,
28396
+ intents: {
28397
+ ...intents,
28398
+ listOpen: async () => (await intents.listOpen()).filter((intent) => !this.deps.isActiveOp(intent.transferId))
28399
+ }
28400
+ };
28401
+ }
28402
+ replayDeps() {
28403
+ return {
28404
+ delivery: this.deps.delivery,
28405
+ now: () => this.deps.now(),
28406
+ attention: (transferId, code, detail) => {
28407
+ this.deps.emit(
28408
+ "transfer:attention",
28409
+ detail === void 0 ? { transferId, code } : { transferId, code, detail }
28410
+ );
28411
+ }
28412
+ };
28413
+ }
28414
+ };
28415
+ async function derivePendingTransfers(stores, decryptPayload) {
28416
+ const [backstop, journal, shortfalls] = await Promise.all([
28417
+ stores.backstop.list(),
28418
+ stores.deliveryJournal.list(),
28419
+ stores.shortfalls.list()
28420
+ ]);
28421
+ const byTransfer = groupJournal(journal);
28422
+ const out = [];
28423
+ const seen = /* @__PURE__ */ new Set();
28424
+ for (const entry of backstop) {
28425
+ if (entry.disposition !== "open") continue;
28426
+ seen.add(entry.transferId);
28427
+ out.push(await openRow(entry, byTransfer.get(entry.transferId) ?? [], decryptPayload));
28428
+ }
28429
+ for (const [transferId, entries] of byTransfer) {
28430
+ if (seen.has(transferId)) continue;
28431
+ seen.add(transferId);
28432
+ out.push(journalOnlyRow(transferId, entries));
28433
+ }
28434
+ for (const entry of shortfalls) {
28435
+ if (!seen.has(entry.transferId)) out.push(shortfallRow(entry));
28436
+ }
28437
+ return out;
28438
+ }
28439
+ function groupJournal(journal) {
28440
+ const byTransfer = /* @__PURE__ */ new Map();
28441
+ for (const entry of journal) {
28442
+ const mine = byTransfer.get(entry.transferId);
28443
+ if (mine === void 0) byTransfer.set(entry.transferId, [entry]);
28444
+ else mine.push(entry);
28445
+ }
28446
+ return byTransfer;
28447
+ }
28448
+ async function openRow(entry, journal, decryptPayload) {
28449
+ let payload = null;
28450
+ try {
28451
+ const raw = await decryptPayload(entry.payloadEnvelope);
28452
+ if (raw !== null && typeof raw === "object") payload = raw;
28453
+ } catch {
28454
+ payload = null;
28455
+ }
28456
+ const total = Array.isArray(payload?.direct) ? payload.direct.length + (payload.split !== void 0 ? 1 : 0) : 0;
28457
+ const certified = distinctOpCount(journal);
28458
+ return {
28459
+ transferId: entry.transferId,
28460
+ kind: "open",
28461
+ recipient: typeof payload?.recipient === "string" ? payload.recipient : "",
28462
+ coinId: typeof payload?.coinId === "string" ? payload.coinId : "",
28463
+ amount: typeof payload?.amount === "string" ? payload.amount : "",
28464
+ legs: { certified, total: Math.max(total, certified) },
28465
+ deliveryPending: journal.length > 0,
28466
+ createdAt: entry.createdAt
28467
+ };
28468
+ }
28469
+ function journalOnlyRow(transferId, entries) {
28470
+ const certified = distinctOpCount(entries);
28471
+ return {
28472
+ transferId,
28473
+ kind: "open",
28474
+ recipient: entries[0].recipientPubkey,
28475
+ coinId: "",
28476
+ amount: "",
28477
+ legs: { certified, total: certified },
28478
+ deliveryPending: true,
28479
+ createdAt: 0
28480
+ };
28481
+ }
28482
+ function shortfallRow(entry) {
28483
+ return {
28484
+ transferId: entry.transferId,
28485
+ kind: "shortfall",
28486
+ recipient: entry.recipient,
28487
+ coinId: entry.coinId,
28488
+ amount: entry.remainingAmount,
28489
+ legs: { certified: entry.committedTokenIds.length, total: entry.committedTokenIds.length },
28490
+ deliveryPending: false,
28491
+ createdAt: entry.createdAt
28492
+ };
28493
+ }
28494
+ function distinctOpCount(entries) {
28495
+ return new Set(entries.map((entry) => entry.opIndex)).size;
28496
+ }
28497
+
28283
28498
  // modules/payments-v2/history/History.ts
28284
28499
  var WIRE_TOKEN_ID = /^(?:[0-9a-f]{2}){1,64}$/;
28285
28500
  var WIRE_PUBKEY = /^0[23][0-9a-f]{64}$/;
@@ -29669,6 +29884,21 @@ var PaymentsFacade = class {
29669
29884
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
29670
29885
  this.requests = parts.requests;
29671
29886
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
29887
+ this.heartbeat = new ConvergenceHeartbeat({
29888
+ now: () => this.nowMs(),
29889
+ onTick: () => this.trackTail(this.runConvergencePass())
29890
+ });
29891
+ this.converger = new Converger({
29892
+ machineDeps: this.machineDeps,
29893
+ stores: this.machineStores,
29894
+ delivery: deps.deliveryPort,
29895
+ reconcile: (report) => this.requests.reconcile(report),
29896
+ refreshView: () => this.view.delta(),
29897
+ replayMints: () => this.replayMints(),
29898
+ isActiveOp: (id) => this.activeMoneyOps.has(id),
29899
+ emit: deps.emit,
29900
+ now: () => this.nowMs()
29901
+ });
29672
29902
  }
29673
29903
  view;
29674
29904
  ledger;
@@ -29685,10 +29915,18 @@ var PaymentsFacade = class {
29685
29915
  pendingOps = /* @__PURE__ */ new Set();
29686
29916
  unsubscribers = [];
29687
29917
  started = false;
29918
+ // §7 heartbeat: in-memory timing cell + pass runner (see convergence.ts).
29919
+ heartbeat;
29920
+ converger;
29921
+ /** Resume single-flight (§7): ticks, start() and resumeNow() coalesce onto ONE pass. */
29922
+ resumeFlight = new SingleFlight();
29923
+ /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
29924
+ activeMoneyOps = /* @__PURE__ */ new Set();
29688
29925
  // ── lifecycle ──────────────────────────────────────────────────────────────
29689
29926
  async start() {
29690
29927
  if (this.started) return;
29691
29928
  this.started = true;
29929
+ this.heartbeat.start();
29692
29930
  await this.deps.session.start();
29693
29931
  this.unsubscribers.push(
29694
29932
  this.deps.session.subscribeStream("mailbox", () => {
@@ -29701,20 +29939,11 @@ var PaymentsFacade = class {
29701
29939
  this.trackTail(this.requests.drainIncoming());
29702
29940
  })
29703
29941
  );
29704
- this.trackTail(this.runResume());
29705
- this.trackTail(
29706
- this.machineStores.deliveryJournal.replay({
29707
- delivery: this.deps.deliveryPort,
29708
- now: this.deps.now ?? Date.now,
29709
- attention: (transferId, code, detail) => {
29710
- this.deps.emit(
29711
- "transfer:attention",
29712
- detail === void 0 ? { transferId, code } : { transferId, code, detail }
29713
- );
29714
- }
29715
- })
29716
- );
29717
- this.trackTail(this.replayMints());
29942
+ const statusUnsub = this.deps.session.subscribeStatus?.((status) => {
29943
+ if (status === "connected") this.heartbeat.recovered();
29944
+ });
29945
+ if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
29946
+ this.trackTail(this.runConvergencePass());
29718
29947
  this.trackTail(this.seedHeldStates());
29719
29948
  await this.view.fullPull().catch(() => void 0);
29720
29949
  this.receiveLoop.start(this.deps.receivePollMs);
@@ -29722,6 +29951,7 @@ var PaymentsFacade = class {
29722
29951
  /** §7 same-address restart gate: resolves only after in-flight ops settle. */
29723
29952
  async stop() {
29724
29953
  this.started = false;
29954
+ this.heartbeat.stop();
29725
29955
  this.receiveLoop.stop();
29726
29956
  for (const unsubscribe of this.unsubscribers) unsubscribe();
29727
29957
  this.unsubscribers = [];
@@ -29747,6 +29977,10 @@ var PaymentsFacade = class {
29747
29977
  history(page) {
29748
29978
  return this.historyStore.page(page ?? {});
29749
29979
  }
29980
+ /** §4 pending-transfers UI surface — derived on read, never cached (convergence.ts). */
29981
+ pendingTransfers() {
29982
+ return derivePendingTransfers(this.machineStores, this.machineDeps.decryptPayload);
29983
+ }
29750
29984
  // ── money movement ─────────────────────────────────────────────────────────
29751
29985
  send(request) {
29752
29986
  return this.track(this.sendWithPolicy(request));
@@ -29759,6 +29993,22 @@ var PaymentsFacade = class {
29759
29993
  mint(coinId, amount) {
29760
29994
  return this.track(this.mintInner(coinId, amount));
29761
29995
  }
29996
+ // §4 retry verb: coalesces onto the running pass — NEVER re-issue send() (#631/#676).
29997
+ async resumeNow() {
29998
+ if (!this.started) return;
29999
+ await this.track(this.runConvergencePass());
30000
+ }
30001
+ /** One single-flighted pass + its reschedule: concurrent callers coalesce. */
30002
+ runConvergencePass() {
30003
+ return this.resumeFlight.run(async () => {
30004
+ const outcome = await this.converger.convergeOnce();
30005
+ const pending = await this.converger.pendingWork().catch(() => true);
30006
+ this.heartbeat.settle(outcome, pending);
30007
+ });
30008
+ }
30009
+ nowMs() {
30010
+ return (this.deps.now ?? Date.now)();
30011
+ }
29762
30012
  // ── send policy (§5.5 + old-loop parity; the machine stays policy-free) ────
29763
30013
  async sendWithPolicy(request) {
29764
30014
  const recipient = await this.requireSameNetworkRecipient(request.recipient);
@@ -29808,12 +30058,15 @@ var PaymentsFacade = class {
29808
30058
  return recipient;
29809
30059
  }
29810
30060
  async runAttempt(ctx) {
30061
+ this.activeMoneyOps.add(ctx.transferId);
29811
30062
  try {
29812
30063
  const { deliveryPending } = await this.machine.run(ctx.plan);
29813
30064
  this.settleSuccess(ctx, ctx.sourceIds);
29814
30065
  return { kind: "success", deliveryPending };
29815
30066
  } catch (err) {
29816
- return this.disposeFailedAttempt(ctx, err);
30067
+ return await this.disposeFailedAttempt(ctx, err);
30068
+ } finally {
30069
+ this.activeMoneyOps.delete(ctx.transferId);
29817
30070
  }
29818
30071
  }
29819
30072
  /**
@@ -29822,7 +30075,10 @@ var PaymentsFacade = class {
29822
30075
  * the remainder under a NEW transferId — never the full amount.
29823
30076
  */
29824
30077
  async consumePartial(ctx, disposition, run, attempt) {
29825
- run.pendingAny = run.pendingAny || disposition.deliveryPending;
30078
+ if (disposition.deliveryPending) {
30079
+ run.pendingAny = true;
30080
+ this.heartbeat.arm();
30081
+ }
29826
30082
  const partial = disposition.entry;
29827
30083
  run.delivered.push(...partial.committedTokenIds);
29828
30084
  run.firstPartialId ??= partial.transferId;
@@ -29980,6 +30236,7 @@ var PaymentsFacade = class {
29980
30236
  /** Open intent: sources stay reserved + in-flight (§5.2) until resume adopts them. */
29981
30237
  settleKeepOpen(ctx) {
29982
30238
  this.queue.clearExpectedChange(ctx.transferId);
30239
+ this.heartbeat.arm();
29983
30240
  }
29984
30241
  settlePartial(ctx, partial) {
29985
30242
  this.ledger.cancel(ctx.transferId);
@@ -30016,6 +30273,7 @@ var PaymentsFacade = class {
30016
30273
  for (const id of run.settledShortfalls.splice(0)) {
30017
30274
  await this.machineStores.shortfalls.removeByKey(id).catch(() => void 0);
30018
30275
  }
30276
+ if (run.pendingAny) this.heartbeat.arm();
30019
30277
  const result = {
30020
30278
  id: transferId,
30021
30279
  status: run.pendingAny ? "confirmed" : "delivered",
@@ -30060,21 +30318,29 @@ var PaymentsFacade = class {
30060
30318
  if (!/^(?:[0-9a-f]{2})+$/.test(coinId) || amount <= 0n) {
30061
30319
  return { success: false, error: "coinId must be even-length lowercase hex and amount positive" };
30062
30320
  }
30063
- const engine = this.engine();
30064
30321
  const mintId = this.newId();
30065
- const params = this.mintParams(coinId, amount);
30322
+ this.activeMoneyOps.add(mintId);
30323
+ try {
30324
+ return await this.mintUnderJournal(mintId, coinId, amount);
30325
+ } finally {
30326
+ this.activeMoneyOps.delete(mintId);
30327
+ }
30328
+ }
30329
+ async mintUnderJournal(mintId, coinId, amount) {
30330
+ const engine = this.engine();
30066
30331
  const entry = {
30067
30332
  mintId,
30068
30333
  coinId,
30069
30334
  amount: amount.toString(),
30070
30335
  tokenId: "",
30071
- createdAt: (this.deps.now ?? Date.now)()
30336
+ createdAt: this.nowMs()
30072
30337
  };
30073
30338
  await this.machineStores.mintJournal.upsert(entry);
30074
30339
  let token;
30075
30340
  try {
30076
- token = await engine.mint(params, { transferId: mintId });
30341
+ token = await engine.mint(this.mintParams(coinId, amount), { transferId: mintId });
30077
30342
  } catch (err) {
30343
+ this.heartbeat.arm();
30078
30344
  return { success: false, error: messageOf(err) };
30079
30345
  }
30080
30346
  if (token.blob.tokenId !== entry.tokenId) {
@@ -30083,6 +30349,7 @@ var PaymentsFacade = class {
30083
30349
  try {
30084
30350
  await this.finalizeMint(engine, mintId, token, coinId, amount.toString());
30085
30351
  } catch (err) {
30352
+ this.heartbeat.arm();
30086
30353
  return { success: false, tokenId: token.blob.tokenId, error: messageOf(err) };
30087
30354
  }
30088
30355
  return { success: true, tokenId: token.blob.tokenId };
@@ -30105,18 +30372,22 @@ var PaymentsFacade = class {
30105
30372
  this.heldStates.set(token.blob.tokenId, (await engine.deliveryKeys(bytes)).stateHash);
30106
30373
  this.trackTail(this.view.delta());
30107
30374
  }
30375
+ /** @returns how many journal entries were RESOLVED (cleared) — heartbeat progress. */
30108
30376
  async replayMints() {
30377
+ let resolved = 0;
30109
30378
  for (const entry of await this.machineStores.mintJournal.list()) {
30379
+ if (this.activeMoneyOps.has(entry.mintId)) continue;
30110
30380
  try {
30111
- await this.replayMint(entry);
30381
+ if (await this.replayMint(entry)) resolved += 1;
30112
30382
  } catch {
30113
30383
  }
30114
30384
  }
30385
+ return resolved;
30115
30386
  }
30116
30387
  async replayMint(entry) {
30117
30388
  if (entry.tokenId !== "" && await this.tokenInServerInventory(entry.tokenId)) {
30118
30389
  await this.machineStores.mintJournal.removeByKey(entry.mintId);
30119
- return;
30390
+ return true;
30120
30391
  }
30121
30392
  const engine = this.engine();
30122
30393
  if (!supportsDeterministicMint(engine)) {
@@ -30124,7 +30395,7 @@ var PaymentsFacade = class {
30124
30395
  transferId: entry.mintId,
30125
30396
  code: ATTENTION_MINT_UNRESOLVED
30126
30397
  });
30127
- return;
30398
+ return false;
30128
30399
  }
30129
30400
  const token = await engine.mint(this.mintParams(entry.coinId, BigInt(entry.amount)), {
30130
30401
  transferId: entry.mintId
@@ -30133,6 +30404,7 @@ var PaymentsFacade = class {
30133
30404
  await this.machineStores.mintJournal.upsert({ ...entry, tokenId: token.blob.tokenId });
30134
30405
  }
30135
30406
  await this.finalizeMint(engine, entry.mintId, token, entry.coinId, entry.amount);
30407
+ return true;
30136
30408
  }
30137
30409
  mintParams(coinId, amount) {
30138
30410
  return {
@@ -30158,17 +30430,6 @@ var PaymentsFacade = class {
30158
30430
  newId() {
30159
30431
  return (this.deps.newId ?? randomUUID)();
30160
30432
  }
30161
- async runResume() {
30162
- const report = await resumeAll(this.machineDeps);
30163
- await this.requests.reconcile({
30164
- resumed: report.resumed,
30165
- conflicted: report.conflicted,
30166
- open: report.failed
30167
- });
30168
- if (report.resumed.length > 0 || report.conflicted.length > 0) {
30169
- await this.view.delta().catch(() => void 0);
30170
- }
30171
- }
30172
30433
  async seedHeldStates() {
30173
30434
  let page = await this.deps.storagePort.listInventory();
30174
30435
  for (let i = 0; i < INVENTORY_SCAN_PAGE_LIMIT; i++) {
@@ -30182,7 +30443,7 @@ var PaymentsFacade = class {
30182
30443
  toUiToken(tokenId, coinId, amount) {
30183
30444
  const registry = this.deps.registry;
30184
30445
  const iconUrl = registry.getIconUrl(coinId);
30185
- const now = (this.deps.now ?? Date.now)();
30446
+ const now = this.nowMs();
30186
30447
  return {
30187
30448
  id: tokenId,
30188
30449
  coinId,
@@ -30577,6 +30838,7 @@ var WalletApiSession = class {
30577
30838
  reseedChain = Promise.resolve();
30578
30839
  reseedsPending = 0;
30579
30840
  handlers = /* @__PURE__ */ new Map();
30841
+ statusHandlers = /* @__PURE__ */ new Set();
30580
30842
  socket = null;
30581
30843
  started = false;
30582
30844
  stopped = false;
@@ -30727,10 +30989,16 @@ var WalletApiSession = class {
30727
30989
  status() {
30728
30990
  return this.lastStatus ?? "offline";
30729
30991
  }
30992
+ /** Transition feed off the existing emitStatus point (FacadeSession.subscribeStatus). */
30993
+ subscribeStatus(handler) {
30994
+ this.statusHandlers.add(handler);
30995
+ return () => this.statusHandlers.delete(handler);
30996
+ }
30730
30997
  emitStatus(status) {
30731
30998
  if (this.stopped || this.lastStatus === status) return;
30732
30999
  this.lastStatus = status;
30733
31000
  this.deps.emitStatus(status);
31001
+ for (const handler of this.statusHandlers) handler(status);
30734
31002
  }
30735
31003
  async connect() {
30736
31004
  if (this.stopped) return;