@ghostlytools/cli 1.3.1 → 1.3.3

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.
Files changed (72) hide show
  1. package/README.md +6 -6
  2. package/dist/assets/{api-Dq2QXQDk.js → api-B1fADtqd.js} +65 -20
  3. package/dist/assets/api-B1fADtqd.js.map +1 -0
  4. package/dist/assets/{api-TiB5QSG9.js → api-Bg7bh_6E.js} +2 -2
  5. package/dist/assets/{apiKit-CA2x5GJF.js → apiKit-BsUZPHLn.js} +44 -33
  6. package/dist/assets/apiKit-BsUZPHLn.js.map +1 -0
  7. package/dist/assets/{apps-BxV9i_Y4.js → apps-BaUNt-5c.js} +91 -24
  8. package/dist/assets/apps-BaUNt-5c.js.map +1 -0
  9. package/dist/assets/{apps-C-T8jeji.js → apps-DepomCiX.js} +1 -1
  10. package/dist/assets/{arkade-C_qDmHnm.js → arkade-CNnMaoEQ.js} +4 -4
  11. package/dist/assets/arkade-CNnMaoEQ.js.map +1 -0
  12. package/dist/assets/{backup-Bugnn3Ue.js → backup-B5Q1W-Ey.js} +1 -1
  13. package/dist/assets/{backup-CnMN2oUK.js → backup-Bbz3lyC5.js} +6 -6
  14. package/dist/assets/{backup-CnMN2oUK.js.map → backup-Bbz3lyC5.js.map} +1 -1
  15. package/dist/assets/buttons-CUd7j0ZA.js +2 -0
  16. package/dist/assets/{buttons-CvNBzh2S.js → buttons-DS1iURp8.js} +4 -4
  17. package/dist/assets/{buttons-CvNBzh2S.js.map → buttons-DS1iURp8.js.map} +1 -1
  18. package/dist/assets/{envelope-WjingYSc.js → envelope-fNC941z4.js} +2 -2
  19. package/dist/assets/{envelope-WjingYSc.js.map → envelope-fNC941z4.js.map} +1 -1
  20. package/dist/assets/{errors-DO5z6h_l.js → errors-B-zMxbcd.js} +6 -5
  21. package/dist/assets/{errors-DO5z6h_l.js.map → errors-B-zMxbcd.js.map} +1 -1
  22. package/dist/assets/{ghostly_iroh_web-Dvzh3Y6E.js → ghostly_iroh_web-BsAEFIjl.js} +30 -36
  23. package/dist/assets/ghostly_iroh_web-BsAEFIjl.js.map +1 -0
  24. package/dist/assets/ghostly_iroh_web_bg.wasm +0 -0
  25. package/dist/assets/{groupFiles-IEUW3DQz.js → groupFiles-6qnLCnOm.js} +82 -29
  26. package/dist/assets/groupFiles-6qnLCnOm.js.map +1 -0
  27. package/dist/assets/{host-CWt-jtqz.js → host-Dj3C1fn3.js} +305 -68
  28. package/dist/assets/host-Dj3C1fn3.js.map +1 -0
  29. package/dist/assets/{hyperdhtRelay-CPYwSLLS.js → hyperdhtRelay-CrgYQN-g.js} +2 -2
  30. package/dist/assets/{hyperdhtRelay-CPYwSLLS.js.map → hyperdhtRelay-CrgYQN-g.js.map} +1 -1
  31. package/dist/assets/{irohWeb-4oChIOIp.js → irohWeb-DSyLp5Re.js} +3 -3
  32. package/dist/assets/{irohWeb-4oChIOIp.js.map → irohWeb-DSyLp5Re.js.map} +1 -1
  33. package/dist/assets/{main-DbqcdShN.js → main-BDd11Yd1.js} +159 -54
  34. package/dist/assets/main-BDd11Yd1.js.map +1 -0
  35. package/dist/assets/{mainline-ZkiOnevs.js → mainline-CLTo0nGa.js} +2 -2
  36. package/dist/assets/{mainline-ZkiOnevs.js.map → mainline-CLTo0nGa.js.map} +1 -1
  37. package/dist/assets/{networkReason-CetKkTPo.js → networkReason-DwcadZz5.js} +2 -2
  38. package/dist/assets/{networkReason-CetKkTPo.js.map → networkReason-DwcadZz5.js.map} +1 -1
  39. package/dist/assets/{nwc-CgkqyvsE.js → nwc-BNFyyECH.js} +3 -3
  40. package/dist/assets/{nwc-CgkqyvsE.js.map → nwc-BNFyyECH.js.map} +1 -1
  41. package/dist/assets/{nwcProvider-4eVz_8HP.js → nwcProvider-BbjJOfLD.js} +2 -2
  42. package/dist/assets/{nwcProvider-4eVz_8HP.js.map → nwcProvider-BbjJOfLD.js.map} +1 -1
  43. package/dist/assets/{profiles-BHc9pEYg.js → profiles-gPCjLOQq.js} +2 -2
  44. package/dist/assets/{profiles-BHc9pEYg.js.map → profiles-gPCjLOQq.js.map} +1 -1
  45. package/dist/assets/{registry-DlAKO0Fy.js → registry-CoLHpxkb.js} +17 -32
  46. package/dist/assets/registry-CoLHpxkb.js.map +1 -0
  47. package/dist/assets/{registry-OBJBnHVQ.js → registry-DoTb6uEr.js} +1 -1
  48. package/dist/assets/{resolve-CouHhCpJ.js → resolve-BIDJ-UUl.js} +18 -3
  49. package/dist/assets/resolve-BIDJ-UUl.js.map +1 -0
  50. package/dist/assets/{server-Ss0gJ-nw.js → server-Bx3RGkBC.js} +380 -116
  51. package/dist/assets/server-Bx3RGkBC.js.map +1 -0
  52. package/dist/assets/{src-DDzSERyc.js → src-D7gcJElT.js} +470 -242
  53. package/dist/assets/src-D7gcJElT.js.map +1 -0
  54. package/dist/assets/{turnRefs-B87dtrHM.js → turnRefs-PN644M9o.js} +2 -2
  55. package/dist/assets/{turnRefs-B87dtrHM.js.map → turnRefs-PN644M9o.js.map} +1 -1
  56. package/dist/assets/{usdt-Fj8CQO2z.js → usdt-B2TLRlYa.js} +3 -3
  57. package/dist/assets/{usdt-Fj8CQO2z.js.map → usdt-B2TLRlYa.js.map} +1 -1
  58. package/dist/ghostly.mjs +1 -1
  59. package/package.json +1 -1
  60. package/dist/assets/api-Dq2QXQDk.js.map +0 -1
  61. package/dist/assets/apiKit-CA2x5GJF.js.map +0 -1
  62. package/dist/assets/apps-BxV9i_Y4.js.map +0 -1
  63. package/dist/assets/arkade-C_qDmHnm.js.map +0 -1
  64. package/dist/assets/buttons-Bj7Vf7Pn.js +0 -2
  65. package/dist/assets/ghostly_iroh_web-Dvzh3Y6E.js.map +0 -1
  66. package/dist/assets/groupFiles-IEUW3DQz.js.map +0 -1
  67. package/dist/assets/host-CWt-jtqz.js.map +0 -1
  68. package/dist/assets/main-DbqcdShN.js.map +0 -1
  69. package/dist/assets/registry-DlAKO0Fy.js.map +0 -1
  70. package/dist/assets/resolve-CouHhCpJ.js.map +0 -1
  71. package/dist/assets/server-Ss0gJ-nw.js.map +0 -1
  72. package/dist/assets/src-DDzSERyc.js.map +0 -1
@@ -1,12 +1,12 @@
1
- import { Go as answersOffer, Jo as callSignalHeardAt, Ko as buildSdpFromSignal, Oi as isPublicAddress, Uo as CALL_SIGNAL_MAX_AGE_MS, Wo as RTC_CONFIG, Xo as parseCallSignal, Yo as extractParamsFromSdp, Zc as setLinkTraceSink, Zo as signalHasVideo, gl as fromBase64Url, ii as checkPushEndpoint, qo as callRtcConfig } from "./src-DDzSERyc.js";
2
- import { a as fedimintSdkOn, r as irohRelayProblem } from "./irohWeb-4oChIOIp.js";
3
- import { r as asCliError, t as CliError } from "./errors-DO5z6h_l.js";
4
- import { a as findChat } from "./apiKit-CA2x5GJF.js";
5
- import { _ as flushCardUpdates, c as paymentJson, d as nodeLocalFetch, f as chatJson, g as resumeHolds, h as groupMessageJson, l as reportAppFrames, m as groupJson, p as chatMessageJson, r as callApi, u as servedApps } from "./api-Dq2QXQDk.js";
6
- import { n as RESTORED_MARK, r as acquireLock } from "./profiles-BHc9pEYg.js";
1
+ import { $o as extractParamsFromSdp, Ai as isPublicAddress, Jo as RTC_CONFIG, Qo as callSignalHeardAt, Xo as buildSdpFromSignal, Yo as answersOffer, Zo as callRtcConfig, es as parseCallSignal, oi as checkPushEndpoint, qo as CALL_SIGNAL_MAX_AGE_MS, tl as setLinkTraceSink, ts as signalHasVideo, xl as fromBase64Url } from "./src-D7gcJElT.js";
2
+ import { a as fedimintSdkOn, r as irohRelayProblem } from "./irohWeb-DSyLp5Re.js";
3
+ import { r as asCliError, t as CliError } from "./errors-B-zMxbcd.js";
4
+ import { a as findChat } from "./apiKit-BsUZPHLn.js";
5
+ import { _ as flushCardUpdates, c as paymentJson, d as nodeLocalFetch, f as chatJson, g as resumeHolds, h as groupMessageJson, l as reportAppFrames, m as groupJson, p as chatMessageJson, r as callApi, u as servedApps } from "./api-B1fADtqd.js";
6
+ import { n as RESTORED_MARK, r as acquireLock } from "./profiles-gPCjLOQq.js";
7
7
  import { r as privateFolder, t as ownFolder } from "./privateFolder-Bk9C9oFv.js";
8
8
  import { createRequire } from "node:module";
9
- import { appendFileSync, chmodSync, closeSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
9
+ import { appendFileSync, chmodSync, closeSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
10
10
  import { readFile } from "node:fs/promises";
11
11
  import { dirname, join } from "node:path";
12
12
  import { createHash, randomBytes } from "node:crypto";
@@ -418,11 +418,13 @@ var CallAudio = class {
418
418
  this.schedule();
419
419
  }
420
420
  stop() {
421
- this.stopped = true;
422
421
  if (this.timer) clearTimeout(this.timer);
423
422
  this.timer = null;
424
- this.options.encoder.delete?.();
425
- this.options.decoder.delete?.();
423
+ if (this.stopped) return;
424
+ this.stopped = true;
425
+ for (const codec of [this.options.encoder, this.options.decoder]) try {
426
+ codec.delete?.();
427
+ } catch {}
426
428
  }
427
429
  /** Sends the frame that is due now. Exposed for tests, which drive the clock themselves. */
428
430
  tick() {
@@ -551,6 +553,57 @@ var CallAudio = class {
551
553
  //#endregion
552
554
  //#region src/calls/media.ts
553
555
  var loading = null;
556
+ var OPUS_APPLICATION_VOIP = 2048;
557
+ var OPUS_SET_BITRATE = 4002;
558
+ /** The largest packet a codec reads or writes (opusscript's MAX_PACKET_SIZE). */
559
+ var MAX_PACKET_BYTES = 3828;
560
+ /**
561
+ * Room for a frame of PCM as the module reads and writes it, one byte of PCM in each 16-bit element: a concealment (an
562
+ * empty packet) writes 120 ms at 48 kHz, 5,760 samples, 23,040 bytes, at any rate.
563
+ */
564
+ var PCM_ROOM_BYTES = 23040;
565
+ /** The most PCM a decode hands back, as opusscript's: its largest frame, 60 ms at 48 kHz (CallAudio's `conceal`). */
566
+ var MAX_PCM_OUT = 5760;
567
+ /**
568
+ * An Opus encoder or decoder on opusscript's libopus, with buffers that hold what libopus writes. opusscript's own
569
+ * wrapper (0.1.1) keeps 5,760 bytes for its PCM and reaches it through views at twice the buffer's address, past it: a
570
+ * 20 ms frame at 48 kHz takes 3,840 bytes there and a concealment 23,040. With three calls or more, as calls ended and
571
+ * others started, one codec's audio landed on another's state: noise, then "memory access out of bounds" in every
572
+ * call until the daemon restarted. The bytes in and out are the same as opusscript's.
573
+ */
574
+ function opusCodec(native, rate) {
575
+ const handler = new native.OpusScriptHandler(rate, 1, OPUS_APPLICATION_VOIP);
576
+ const pcm = native._malloc(PCM_ROOM_BYTES), packet = native._malloc(MAX_PACKET_BYTES);
577
+ let deleted = false;
578
+ const codec = {
579
+ encode(frame, frameSize) {
580
+ if (deleted || frame.length * 2 > PCM_ROOM_BYTES) throw new Error("Encode error: Bad argument");
581
+ native.HEAPU16.set(frame, pcm / 2);
582
+ const length = handler._encode(pcm, frame.length, packet, frameSize);
583
+ if (length < 0) throw new Error(`Encode error: ${length}`);
584
+ return Buffer.from(native.HEAPU8.subarray(packet, packet + length));
585
+ },
586
+ decode(data) {
587
+ if (deleted || data.length > MAX_PACKET_BYTES) throw new Error("Decode error: Bad argument");
588
+ native.HEAPU8.set(data, packet);
589
+ const samples = handler._decode(packet, data.length, pcm);
590
+ if (samples < 0) throw new Error(`Decode error: ${samples}`);
591
+ return Buffer.from(native.HEAPU16.subarray(pcm / 2, pcm / 2 + Math.min(samples * 2, MAX_PCM_OUT)));
592
+ },
593
+ delete() {
594
+ if (deleted) return;
595
+ deleted = true;
596
+ native.OpusScriptHandler.destroy_handler(handler);
597
+ native._free(pcm);
598
+ native._free(packet);
599
+ }
600
+ };
601
+ if (handler._encoder_ctl(OPUS_SET_BITRATE, 32e3) < 0) {
602
+ codec.delete?.();
603
+ throw new Error("Encoder CTL error");
604
+ }
605
+ return codec;
606
+ }
554
607
  /** The media stack, or why calls are not available here (the reason the call buttons of the contact's app show). */
555
608
  function loadCallStack() {
556
609
  return loading ??= (async () => {
@@ -558,12 +611,8 @@ function loadCallStack() {
558
611
  try {
559
612
  const ndc = await import("node-datachannel");
560
613
  const lib = ndc.PeerConnection ? ndc : ndc.default;
561
- const OpusScript = (await import("opusscript")).default;
562
- const opus = (rate) => {
563
- const codec = new OpusScript(rate, 1, OpusScript.Application.VOIP);
564
- codec.setBitrate(32e3);
565
- return codec;
566
- };
614
+ const native = createRequire(import.meta.url)("opusscript/build/opusscript_native_wasm.js")();
615
+ const opus = (rate) => opusCodec(native, rate);
567
616
  opus(48e3).delete?.();
568
617
  return {
569
618
  ndc: lib,
@@ -612,7 +661,8 @@ function describePair(pair) {
612
661
  var refuseAnswers = Number(process.env.GHOSTLY_CALL_REFUSE_ANSWERS) || 0;
613
662
  /** How long a description waits for its candidates: a server reflexive one and a little after it, or this long. */
614
663
  var GATHER_MS = 5e3;
615
- var SETTLE_MS = 400;
664
+ /** The signal keeps the first server reflexive candidate only: the apps' calls and the chats' data link settle as long. */
665
+ var SETTLE_MS = 150;
616
666
  /**
617
667
  * With a candidate in (a host one comes in milliseconds), the server reflexive one is waited for this long at most,
618
668
  * not the whole of `GATHER_MS`: a STUN server that answers does in tens of milliseconds, and one that never does (UDP
@@ -629,8 +679,9 @@ var HOST_GATHER_MS = 2e3;
629
679
  async function openConnection(stack, options, offer, held = false) {
630
680
  const { ndc } = stack;
631
681
  const bind = process.env.GHOSTLY_CALL_BIND;
682
+ const iceServers = callIceServers(options.iceServers);
632
683
  const pc = new ndc.PeerConnection("ghostly-call", {
633
- iceServers: callIceServers(options.iceServers),
684
+ iceServers,
634
685
  ...bind ? { bindAddress: bind } : {}
635
686
  });
636
687
  const born = Date.now();
@@ -674,7 +725,7 @@ async function openConnection(stack, options, offer, held = false) {
674
725
  videoTrack = pc.addTrack(video);
675
726
  videoTrack.onMessage(() => {});
676
727
  }
677
- const gathered = gathering(pc);
728
+ const gathered = gathering(pc, { turn: callUsesTurn(iceServers) });
678
729
  if (offer) pc.setRemoteDescription(buildSdpFromSignal(held ? {
679
730
  ...offer,
680
731
  c: []
@@ -864,8 +915,18 @@ function mdnsNote(signal, log) {
864
915
  lookup(host, { all: true }).then((found) => log(`${host} resolves here to ${found.map((a) => a.address).join(", ")}`), () => log(`${host} does not resolve here: the call connects only if the contact's checks reach this side`));
865
916
  }
866
917
  }
867
- /** Resolves once the local description has its candidates (see GATHER_MS and HOST_GATHER_MS). */
868
- function gathering(pc) {
918
+ /** Whether a call's ICE servers (`callIceServers`) have a TURN server: its relay candidate is then the one waited for. */
919
+ function callUsesTurn(servers) {
920
+ return servers.some((server) => typeof server !== "string");
921
+ }
922
+ /**
923
+ * Resolves once the local description has its candidates (see GATHER_MS and HOST_GATHER_MS). `turn`: the connection
924
+ * has a TURN server, and its relay candidate is waited for rather than the reflexive one, as the apps' calls do
925
+ * (`waitForIceGathering`): an allocation takes two round trips, and a TURN server 100 ms away gave its relay after the
926
+ * description had gone without it, on the networks where only the relay connects.
927
+ */
928
+ function gathering(pc, { turn = false } = {}) {
929
+ const wanted = turn ? / typ relay/ : / typ srflx/;
869
930
  return new Promise((resolve) => {
870
931
  let settle = null;
871
932
  let any = false;
@@ -884,7 +945,7 @@ function gathering(pc) {
884
945
  });
885
946
  pc.onLocalCandidate((candidate) => {
886
947
  any = true;
887
- if (!settle && / typ srflx/.test(candidate)) settle = setTimeout(done, SETTLE_MS);
948
+ if (!settle && wanted.test(candidate)) settle = setTimeout(done, SETTLE_MS);
888
949
  });
889
950
  });
890
951
  }
@@ -915,6 +976,8 @@ var CallManager = class {
915
976
  clearing = /* @__PURE__ */ new Map();
916
977
  /** Per chat: when this side's last call there ended before it was answered (`CALL_PACE_MS`). */
917
978
  unansweredAt = /* @__PURE__ */ new Map();
979
+ /** The early answers being made, those of ended calls too (MAX_EARLY_ANSWERS). */
980
+ makings = /* @__PURE__ */ new Set();
918
981
  auto;
919
982
  configPath;
920
983
  stopping = false;
@@ -1087,11 +1150,15 @@ var CallManager = class {
1087
1150
  */
1088
1151
  prepare(call) {
1089
1152
  if (this.host.answers === false || call.ended || call.state !== "ringing") return;
1153
+ this.stopEarly(call);
1090
1154
  call.earlyTimer = setTimeout(() => this.prepare(call), this.host.earlyAnswerMs ?? 3e4);
1091
1155
  call.earlyTimer.unref?.();
1092
1156
  if (call.making) return;
1157
+ const held = [...this.calls.values()].filter((c) => c !== call && c.early).length;
1158
+ if (this.makings.size + held >= 4 || [...this.makings].some((m) => m.chat === call.chat)) return;
1093
1159
  const offer = call.offer;
1094
1160
  const making = {
1161
+ chat: call.chat,
1095
1162
  done: this.stack().then((stack) => EarlyAnswer.open(stack, offer, {
1096
1163
  iceServers: this.host.engine.getState().settings?.iceServers ?? [],
1097
1164
  log: (line) => process.stderr.write(`ghostly: call ${call.id}: ${line}\n`)
@@ -1103,15 +1170,19 @@ var CallManager = class {
1103
1170
  claimed: false
1104
1171
  };
1105
1172
  call.making = making;
1173
+ this.makings.add(making);
1106
1174
  making.done.then((early) => {
1175
+ this.makings.delete(making);
1107
1176
  if (call.making === making) call.making = null;
1108
- if (making.claimed || !early) return;
1109
- if (call.ended || call.state !== "ringing") {
1110
- early.close();
1111
- return;
1177
+ if (!making.claimed && early) {
1178
+ if (call.ended || call.state !== "ringing") early.close();
1179
+ else {
1180
+ call.early?.close();
1181
+ call.early = early;
1182
+ }
1112
1183
  }
1113
- call.early?.close();
1114
- call.early = early;
1184
+ const next = this.byChat(making.chat);
1185
+ if (next && next !== call && next.direction === "in" && !next.making && !next.early) this.prepare(next);
1115
1186
  });
1116
1187
  }
1117
1188
  /** The answered call's early answer: the one made, if still good, or the one being made; none from then on. */
@@ -1403,7 +1474,11 @@ var CallManager = class {
1403
1474
  this.calls.delete(call.id);
1404
1475
  if (call.direction === "out" && call.connectedAt === null && call.state === "ringing" && (reason === "hangup" || reason === "unanswered")) this.unansweredAt.set(call.chat, this.now);
1405
1476
  if (report) process.stderr.write(`ghostly: call ${call.id}: ended (${reason})${call.media ? `, ice ${call.media.ice.state}` : ""}\n`);
1406
- call.media?.close();
1477
+ try {
1478
+ call.media?.close();
1479
+ } catch (error) {
1480
+ process.stderr.write(`ghostly: call ${call.id}: closing its media failed: ${error instanceof Error ? error.message : String(error)}\n`);
1481
+ }
1407
1482
  this.stopEarly(call);
1408
1483
  call.early?.close();
1409
1484
  call.early = null;
@@ -1562,6 +1637,8 @@ function readAuto(path) {
1562
1637
  return off;
1563
1638
  }
1564
1639
  }
1640
+ /** A journal past this is trimmed, and only this much of its end is ever read. */
1641
+ var JOURNAL_MAX_BYTES = 33554432;
1565
1642
  var SEEN_DB = "ghostly-cli";
1566
1643
  var SEEN = "seen";
1567
1644
  /**
@@ -1587,6 +1664,7 @@ var EventHub = class {
1587
1664
  seenDb;
1588
1665
  seq = 0;
1589
1666
  journalLines = 0;
1667
+ journalBytes = 0;
1590
1668
  listeners = /* @__PURE__ */ new Set();
1591
1669
  stateListeners = /* @__PURE__ */ new Set();
1592
1670
  callListeners = /* @__PURE__ */ new Set();
@@ -1623,13 +1701,24 @@ var EventHub = class {
1623
1701
  }
1624
1702
  /** Loads the journal's last seq and the seen set; call before `sink` is attached. */
1625
1703
  async open() {
1626
- if (existsSync(this.journalPath)) {
1627
- const lines = readFileSync(this.journalPath, "utf8").split("\n").filter(Boolean);
1628
- this.journalLines = lines.length;
1629
- for (let i = lines.length - 1; i >= 0; i--) try {
1630
- this.seq = JSON.parse(lines[i]).seq;
1631
- break;
1704
+ try {
1705
+ if (existsSync(this.journalPath)) {
1706
+ const { lines, size } = journalTail(this.journalPath);
1707
+ this.journalLines = lines.length;
1708
+ this.journalBytes = size;
1709
+ for (let i = lines.length - 1; i >= 0; i--) try {
1710
+ this.seq = JSON.parse(lines[i]).seq;
1711
+ break;
1712
+ } catch {}
1713
+ if (size > 33554432 || lines.length > 2e4) this.trimJournal();
1714
+ }
1715
+ } catch (error) {
1716
+ process.stderr.write(`ghostly: the event journal could not be read, a new one begins: ${error instanceof Error ? error.message : String(error)}\n`);
1717
+ try {
1718
+ renameSync(this.journalPath, this.journalPath + ".unreadable");
1632
1719
  } catch {}
1720
+ this.journalLines = 0;
1721
+ this.journalBytes = 0;
1633
1722
  }
1634
1723
  this.db = await new Promise((resolve, reject) => {
1635
1724
  const request = indexedDB.open(this.seenDb, 1);
@@ -1754,13 +1843,10 @@ var EventHub = class {
1754
1843
  replay(since) {
1755
1844
  if (!existsSync(this.journalPath) || since >= this.seq) return [];
1756
1845
  const out = [];
1757
- for (const line of readFileSync(this.journalPath, "utf8").split("\n")) {
1758
- if (!line) continue;
1759
- try {
1760
- const event = JSON.parse(line);
1761
- if (event.seq > since) out.push(event);
1762
- } catch {}
1763
- }
1846
+ for (const line of journalTail(this.journalPath).lines) try {
1847
+ const event = JSON.parse(line);
1848
+ if (event.seq > since) out.push(event);
1849
+ } catch {}
1764
1850
  if (out.length && out[0].seq > since + 1) out.unshift({
1765
1851
  seq: since,
1766
1852
  id: `events.gap:${since}:${out[0].seq}`,
@@ -1783,18 +1869,57 @@ var EventHub = class {
1783
1869
  at: this.now(),
1784
1870
  ...fields
1785
1871
  };
1786
- appendFileSync(this.journalPath, JSON.stringify(event) + "\n", { mode: 384 });
1787
- if (++this.journalLines > 2e4) this.trimJournal();
1872
+ try {
1873
+ const line = JSON.stringify(event) + "\n";
1874
+ appendFileSync(this.journalPath, line, { mode: 384 });
1875
+ this.journalBytes += Buffer.byteLength(line);
1876
+ if (++this.journalLines > 2e4 || this.journalBytes > 33554432) this.trimJournal();
1877
+ } catch (error) {
1878
+ process.stderr.write(`ghostly: event journal error: ${error instanceof Error ? error.message : String(error)}\n`);
1879
+ }
1788
1880
  for (const listener of this.listeners) try {
1789
1881
  listener(event);
1790
1882
  } catch {}
1791
1883
  return event;
1792
1884
  }
1885
+ /** Keeps the last `JOURNAL_KEEP` events, and of them no more than `JOURNAL_KEEP_BYTES`. */
1793
1886
  trimJournal() {
1794
- const lines = readFileSync(this.journalPath, "utf8").split("\n").filter(Boolean).slice(-1e4);
1795
- writeFileSync(this.journalPath + ".tmp", lines.join("\n") + "\n", { mode: 384 });
1887
+ const lines = journalTail(this.journalPath).lines.slice(-1e4);
1888
+ let start = lines.length, bytes = 0;
1889
+ while (start > 0 && bytes + Buffer.byteLength(lines[start - 1]) + 1 <= 16777216) bytes += Buffer.byteLength(lines[--start]) + 1;
1890
+ const kept = lines.slice(start);
1891
+ writeFileSync(this.journalPath + ".tmp", kept.length ? kept.join("\n") + "\n" : "", { mode: 384 });
1796
1892
  renameSync(this.journalPath + ".tmp", this.journalPath);
1797
- this.journalLines = lines.length;
1893
+ this.journalLines = kept.length;
1894
+ this.journalBytes = bytes;
1895
+ }
1896
+ /**
1897
+ * What was removed here leaves the journal too: the journal is written again without the events that name it (a
1898
+ * line that names it and cannot be read goes as well), and a journal set aside as unreadable, which cannot be sorted,
1899
+ * is deleted. `ids`: what was removed (a message's id, a chat's or a group's); `gone`: whether an event is of it.
1900
+ */
1901
+ forget(ids, gone) {
1902
+ if (!ids.length) return;
1903
+ try {
1904
+ rmSync(this.journalPath + ".unreadable", { force: true });
1905
+ if (!existsSync(this.journalPath)) return;
1906
+ const lines = journalTail(this.journalPath).lines;
1907
+ const kept = lines.filter((line) => {
1908
+ if (!ids.some((id) => line.includes(JSON.stringify(id)))) return true;
1909
+ try {
1910
+ return !gone(JSON.parse(line));
1911
+ } catch {
1912
+ return false;
1913
+ }
1914
+ });
1915
+ if (kept.length === lines.length) return;
1916
+ writeFileSync(this.journalPath + ".tmp", kept.length ? kept.join("\n") + "\n" : "", { mode: 384 });
1917
+ renameSync(this.journalPath + ".tmp", this.journalPath);
1918
+ this.journalLines = kept.length;
1919
+ this.journalBytes = kept.reduce((bytes, line) => bytes + Buffer.byteLength(line) + 1, 0);
1920
+ } catch (error) {
1921
+ process.stderr.write(`ghostly: event journal error: ${error instanceof Error ? error.message : String(error)}\n`);
1922
+ }
1798
1923
  }
1799
1924
  handle(message) {
1800
1925
  switch (message.kind) {
@@ -1871,11 +1996,12 @@ var EventHub = class {
1871
1996
  ...typingFields(shape)
1872
1997
  });
1873
1998
  }
1874
- for (const id of [...this.chats.keys()]) {
1875
- if (links.has(id)) continue;
1999
+ const removedChats = [...this.chats.keys()].filter((id) => !links.has(id));
2000
+ for (const id of removedChats) {
1876
2001
  this.chats.delete(id);
1877
2002
  if (!quiet) this.emit("chat.removed", `chat.removed:${id}`, { chat: id });
1878
2003
  }
2004
+ this.forget(removedChats, (event) => removedChats.includes(event.chat) && event.type !== "chat.removed");
1879
2005
  const groups = new Map(state.groups.map((group) => [group.id, group]));
1880
2006
  for (const [id, group] of groups) {
1881
2007
  const shape = groupShape(group), before = this.groups.get(id);
@@ -1914,11 +2040,12 @@ var EventHub = class {
1914
2040
  member
1915
2041
  });
1916
2042
  }
1917
- for (const id of [...this.groups.keys()]) {
1918
- if (groups.has(id)) continue;
2043
+ const removedGroups = [...this.groups.keys()].filter((id) => !groups.has(id));
2044
+ for (const id of removedGroups) {
1919
2045
  this.groups.delete(id);
1920
2046
  if (!quiet) this.emit("group.removed", `group.removed:${id}`, { group: id });
1921
2047
  }
2048
+ this.forget(removedGroups, (event) => removedGroups.includes(event.group) && event.type !== "group.removed");
1922
2049
  for (const [id, payment] of Object.entries(state.payments ?? {})) {
1923
2050
  const shape = `${payment.state}|${payment.error ?? ""}|${payment.closed ? 1 : 0}`, before = this.payments.get(id);
1924
2051
  this.payments.set(id, shape);
@@ -2173,10 +2300,12 @@ var EventHub = class {
2173
2300
  });
2174
2301
  }
2175
2302
  }
2303
+ const gone = [];
2176
2304
  for (const id of deleted ?? [...known.keys()]) {
2177
2305
  if (present.has(id) || !known.has(id)) continue;
2178
2306
  known.delete(id);
2179
2307
  changes.push([id, null]);
2308
+ gone.push(id);
2180
2309
  if (!quiet) this.emit(group ? "group.deleted" : "message.deleted", `${group ? "group" : "message"}.deleted:${group ?? chat}:${id}`, group ? {
2181
2310
  group,
2182
2311
  messageId: id
@@ -2186,6 +2315,8 @@ var EventHub = class {
2186
2315
  });
2187
2316
  }
2188
2317
  if (changes.length) this.persist(chat, changes);
2318
+ const whole = !!group && !deleted && messages.length === 0 && gone.length > 0;
2319
+ this.forget(whole ? [group] : gone, (event) => (group ? event.group === group : event.chat === chat) && !event.type.endsWith(".deleted") && (whole ? "message" in event || "messageId" in event || "replyId" in event : gone.includes(event.messageId) || gone.includes(event.message?.id) || gone.includes(event.replyId)));
2189
2320
  for (const [fileId, messageId] of files) this.flushFile(fileId, messageId);
2190
2321
  }
2191
2322
  /**
@@ -2352,6 +2483,71 @@ function groupShape(group) {
2352
2483
  pin: group.pin ?? null
2353
2484
  };
2354
2485
  }
2486
+ /**
2487
+ * The journal's whole lines within its last `JOURNAL_MAX_BYTES`, and its size: never the whole file as one string, so
2488
+ * a journal grown past what a string can hold (or a trim's bound) is still read, and in bounded time.
2489
+ */
2490
+ function journalTail(path) {
2491
+ const fd = openSync(path, "r");
2492
+ try {
2493
+ const size = fstatSync(fd).size;
2494
+ const from = Math.max(0, size - JOURNAL_MAX_BYTES - 1);
2495
+ const buffer = Buffer.alloc(size - from);
2496
+ let read = 0;
2497
+ while (read < buffer.length) {
2498
+ const n = readSync(fd, buffer, read, buffer.length - read, from + read);
2499
+ if (!n) break;
2500
+ read += n;
2501
+ }
2502
+ const lines = buffer.toString("utf8", 0, read).split("\n");
2503
+ if (from > 0) lines.shift();
2504
+ return {
2505
+ lines: lines.filter(Boolean),
2506
+ size
2507
+ };
2508
+ } finally {
2509
+ closeSync(fd);
2510
+ }
2511
+ }
2512
+ //#endregion
2513
+ //#region src/joinLog.ts
2514
+ /**
2515
+ * A join through a group's link, as a daemon says it in its log (stderr): the steps of the knock and of the entry
2516
+ * session, one short line each, from the engine's join trace (packages/browser/src/engine/joinTrace.ts). Without them a
2517
+ * join that stalled left nothing in the admin's log to tell a knock never seen from a session that never opened
2518
+ * (2026-10-16). The reads of the knocks, every few seconds, and an edge's own steps are not said.
2519
+ */
2520
+ var SAID = {
2521
+ "join.start": "joining through its link",
2522
+ "knock.published": "knock left",
2523
+ "knock.seen": "a knock seen: opening an entry session",
2524
+ "link.start": "entry session starting",
2525
+ "link.presence": "entry session: the other side's packet seen",
2526
+ "link.offering": "entry session: offering a connection",
2527
+ "link.answering": "entry session: answering a connection",
2528
+ "link.connecting": "entry session: connecting",
2529
+ "link.open": "entry session open",
2530
+ "link.idle": "entry session not open",
2531
+ "link.native": "entry session: WebRTC connected nothing, going on over a native transport",
2532
+ "invite.sent": "invitation sent on the entry session",
2533
+ "invite.received": "invitation received on the entry session: accepting",
2534
+ "welcome.sent": "welcome sent",
2535
+ "welcome.received": "welcome received: in the group"
2536
+ };
2537
+ /** The log line for one join-trace line, or null when it is not said. */
2538
+ function joinLogLine(traced) {
2539
+ let step;
2540
+ try {
2541
+ step = JSON.parse(traced);
2542
+ } catch {
2543
+ return null;
2544
+ }
2545
+ if (typeof step.step !== "string" || typeof step.g !== "string" || !Object.hasOwn(SAID, step.step)) return null;
2546
+ const role = step.role === "host" || step.role === "guest" ? step.role : void 0;
2547
+ if (step.step.startsWith("link.") && !role) return null;
2548
+ const at = typeof step.t === "number" ? ` at ${new Date(step.t).toISOString()}` : "";
2549
+ return `ghostly: group ${step.g}: ${SAID[step.step]}${role ? ` (${role === "host" ? "letting in" : "joining"})` : ""}${at}`;
2550
+ }
2355
2551
  //#endregion
2356
2552
  //#region src/runtime/fedimint.ts
2357
2553
  /**
@@ -4354,10 +4550,12 @@ async function startRuntime(paths, options = {}) {
4354
4550
  const pinned = pkarrRelays();
4355
4551
  const pinnedIroh = irohRelays();
4356
4552
  const trace = process.env.GHOSTLY_LINK_TRACE;
4357
- if (trace) {
4358
- setLinkTraceSink((line) => appendFileSync(trace, line + "\n", { mode: 384 }));
4359
- (await import("./joinTrace-D-fnt2p3.js")).setJoinTraceSink((line) => appendFileSync(trace, line + "\n", { mode: 384 }));
4360
- }
4553
+ if (trace) setLinkTraceSink((line) => appendFileSync(trace, line + "\n", { mode: 384 }));
4554
+ if (trace || options.logJoins) (await import("./joinTrace-D-fnt2p3.js")).setJoinTraceSink((line) => {
4555
+ if (trace) appendFileSync(trace, line + "\n", { mode: 384 });
4556
+ const said = options.logJoins ? joinLogLine(line) : null;
4557
+ if (said) process.stderr.write(said + "\n");
4558
+ });
4361
4559
  const store = await openPersistentIndexedDb(paths.db);
4362
4560
  const restored = join(paths.dir, RESTORED_MARK);
4363
4561
  if (existsSync(restored)) {
@@ -4380,17 +4578,17 @@ async function startRuntime(paths, options = {}) {
4380
4578
  if (testMints) (await import("./mints-CqTdA4Gk.js")).declareTestMints(testMints.split(","));
4381
4579
  await installEventSource();
4382
4580
  await preloadWalletModules();
4383
- (await import("./ghostly_iroh_web-Dvzh3Y6E.js")).initSync({ module: irohWasmBytes() });
4581
+ (await import("./ghostly_iroh_web-BsAEFIjl.js")).initSync({ module: irohWasmBytes() });
4384
4582
  const [{ registerFileBytes }, { NodeFileBytes }] = await Promise.all([import("./fileBytes-CgbibpbV.js"), import("./fileBytes-CfSXjTFa.js")]);
4385
4583
  registerFileBytes("native", async () => new NodeFileBytes(paths.files), true);
4386
- const [{ EngineServer }, { createHyperEndpoint }] = await Promise.all([import("./server-Ss0gJ-nw.js"), import("./endpoint-BOTFMpfS.js")]);
4584
+ const [{ EngineServer }, { createHyperEndpoint }] = await Promise.all([import("./server-Bx3RGkBC.js"), import("./endpoint-BOTFMpfS.js")]);
4387
4585
  const network = hyperdhtNetwork();
4388
4586
  const dht = mainlineNetwork();
4389
- const mainline = dht.off ? null : new (await (import("./mainline-ZkiOnevs.js"))).Mainline({
4587
+ const mainline = dht.off ? null : new (await (import("./mainline-CLTo0nGa.js"))).Mainline({
4390
4588
  bootstrap: dht.bootstrap,
4391
4589
  host: dht.host
4392
4590
  });
4393
- const transport = mainline || pinned ? new (await (import("./mainline-ZkiOnevs.js"))).RelaysAndDht(mainline, pinned ? { relays: pinned } : {}, !!pinned) : null;
4591
+ const transport = mainline || pinned ? new (await (import("./mainline-CLTo0nGa.js"))).RelaysAndDht(mainline, pinned ? { relays: pinned } : {}, !!pinned) : null;
4394
4592
  const apps = servedApps(paths.dir).length > 0;
4395
4593
  const server = new EngineServer({
4396
4594
  ...transport ? { transport } : {},
@@ -4438,6 +4636,13 @@ async function startRuntime(paths, options = {}) {
4438
4636
  /** The longest request line the daemon reads (WISP 1100 § Framing). */
4439
4637
  var MAX_LINE = 16777216;
4440
4638
  /**
4639
+ * The most a connection may leave unread (answers and events the daemon wrote that its client has not taken): past
4640
+ * it, the connection ends. A `listen` stopped with Ctrl+Z, or a client that stops reading, kept every later event in
4641
+ * the daemon's memory until it read again (155 MB after 6,000 long texts); `listen` reconnects after the last seq it
4642
+ * took, and the journal still has the rest. A replay is the journal's size at most and does not count against it.
4643
+ */
4644
+ var MAX_UNREAD = 8388608;
4645
+ /**
4441
4646
  * How many of a chat's newest messages the start reads to see whether the events are in step with its history: the
4442
4647
  * whole history of every chat and group, read at every start, took a daemon's start and its memory with every message
4443
4648
  * the profile ever stored (1 s and 70 MB at 100,000).
@@ -4452,7 +4657,10 @@ async function openHost(paths, mode, version, options = {}) {
4452
4657
  const release = acquireLock(paths);
4453
4658
  let runtime;
4454
4659
  try {
4455
- runtime = await startRuntime(paths, options);
4660
+ runtime = await startRuntime(paths, mode === "daemon" ? {
4661
+ ...options,
4662
+ logJoins: true
4663
+ } : options);
4456
4664
  } catch (error) {
4457
4665
  release();
4458
4666
  throw error;
@@ -4596,12 +4804,23 @@ function ownerOnlyUmask() {
4596
4804
  }
4597
4805
  function connection(ctx, socket) {
4598
4806
  const unsubscribes = [];
4599
- const write = (value) => {
4600
- if (!socket.destroyed) socket.write(JSON.stringify(value) + "\n");
4807
+ let limit = MAX_UNREAD;
4808
+ const unsubscribe = () => {
4809
+ for (const off of unsubscribes.splice(0)) off();
4810
+ };
4811
+ const write = (value, replayed = false) => {
4812
+ if (socket.destroyed || socket.writableEnded) return;
4813
+ if (!replayed && socket.writableLength > limit) {
4814
+ unsubscribe();
4815
+ socket.end();
4816
+ return;
4817
+ }
4818
+ socket.write(JSON.stringify(value) + "\n");
4601
4819
  };
4602
- socket.on("close", () => {
4603
- for (const off of unsubscribes) off();
4820
+ socket.on("drain", () => {
4821
+ limit = MAX_UNREAD;
4604
4822
  });
4823
+ socket.on("close", unsubscribe);
4605
4824
  socket.on("error", () => {});
4606
4825
  const lines = createInterface({
4607
4826
  input: socket,
@@ -4634,8 +4853,25 @@ function connection(ctx, socket) {
4634
4853
  return;
4635
4854
  }
4636
4855
  if (request.method === "events.subscribe") {
4856
+ if (unsubscribes.length) {
4857
+ write({
4858
+ id,
4859
+ error: new CliError("bad_request", "This connection is subscribed already: open another one for a new stream").toJSON()
4860
+ });
4861
+ return;
4862
+ }
4637
4863
  const params = request.params ?? {};
4638
4864
  const since = typeof params.since === "number" ? params.since : ctx.hub.lastSeq;
4865
+ let replay;
4866
+ try {
4867
+ replay = ctx.hub.replay(since);
4868
+ } catch (error) {
4869
+ write({
4870
+ id,
4871
+ error: asCliError(error).toJSON()
4872
+ });
4873
+ return;
4874
+ }
4639
4875
  const queued = [];
4640
4876
  let replaying = true;
4641
4877
  unsubscribes.push(ctx.hub.onEvent((event) => {
@@ -4650,10 +4886,11 @@ function connection(ctx, socket) {
4650
4886
  }
4651
4887
  });
4652
4888
  let last = since;
4653
- for (const event of ctx.hub.replay(since)) {
4654
- write({ event });
4889
+ for (const event of replay) {
4890
+ write({ event }, true);
4655
4891
  last = event.seq;
4656
4892
  }
4893
+ limit = Math.max(limit, socket.writableLength + MAX_UNREAD);
4657
4894
  replaying = false;
4658
4895
  for (const event of queued) if (event.seq > last) write({ event });
4659
4896
  return;
@@ -4668,6 +4905,6 @@ function connection(ctx, socket) {
4668
4905
  });
4669
4906
  }
4670
4907
  //#endregion
4671
- export { BASELINE_NEWEST, MAX_LINE, keepServing, openHost, serve };
4908
+ export { BASELINE_NEWEST, MAX_LINE, MAX_UNREAD, keepServing, openHost, serve };
4672
4909
 
4673
- //# sourceMappingURL=host-CWt-jtqz.js.map
4910
+ //# sourceMappingURL=host-Dj3C1fn3.js.map