fedipod 1.32.0 → 1.36.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.
@@ -34286,9 +34286,11 @@ async function provisionPublic(pod, base) {
34286
34286
  await pod.setAcl(base, ["Read"]);
34287
34287
  }
34288
34288
  async function provisionPrivate(pod, urls) {
34289
+ if (await exists(pod, urls.state)) return false;
34289
34290
  await pod.putJson(keepUrl(urls.state), KEEP, KEEP_CT);
34290
34291
  await pod.setAcl(urls.state, []);
34291
34292
  await pod.setAcl(urls.home, []);
34293
+ return true;
34292
34294
  }
34293
34295
  async function repairPrivateAcls(pod, trees, { isPublic } = {}) {
34294
34296
  const findings = [];
@@ -34367,6 +34369,8 @@ function dropFollower(contacts, actor, why) {
34367
34369
  contacts.removedFollowers = gone.slice(-500);
34368
34370
  return contacts;
34369
34371
  }
34372
+ var serialise = (obj) => JSON.stringify(obj, null, 2) + "\n";
34373
+ var sameButWhen = (a, b) => JSON.stringify({ ...a, fetchedAt: null }) === JSON.stringify({ ...b, fetchedAt: null });
34370
34374
  var PodStore = class {
34371
34375
  constructor({ storage = null, log: log2 = console.log } = {}) {
34372
34376
  this.lastSkipped = [];
@@ -34374,6 +34378,7 @@ var PodStore = class {
34374
34378
  this.log = log2;
34375
34379
  this.cache = /* @__PURE__ */ new Map();
34376
34380
  this.etags = /* @__PURE__ */ new Map();
34381
+ this.lastText = /* @__PURE__ */ new Map();
34377
34382
  this.timers = /* @__PURE__ */ new Map();
34378
34383
  this.dirty = /* @__PURE__ */ new Set();
34379
34384
  this._held = 0;
@@ -34387,6 +34392,7 @@ var PodStore = class {
34387
34392
  if (this.storage && this.storage.base !== storage.base) {
34388
34393
  this.cache.clear();
34389
34394
  this.etags.clear();
34395
+ this.lastText.clear();
34390
34396
  }
34391
34397
  this.storage = storage;
34392
34398
  }
@@ -34428,6 +34434,7 @@ var PodStore = class {
34428
34434
  this.etags.set(name, r.etag);
34429
34435
  try {
34430
34436
  this.cache.set(name, JSON.parse(r.body));
34437
+ this.lastText.set(name, r.body);
34431
34438
  } catch (e) {
34432
34439
  this.log(`state load ${name}: unparsable (${e.message})`);
34433
34440
  }
@@ -34450,6 +34457,12 @@ var PodStore = class {
34450
34457
  write(name, obj) {
34451
34458
  this.cache.set(name, structuredClone(obj));
34452
34459
  if (!this.storage) return;
34460
+ if (this.lastText.get(name) === serialise(obj)) {
34461
+ clearTimeout(this.timers.get(name));
34462
+ this.timers.delete(name);
34463
+ this.dirty.delete(name);
34464
+ return;
34465
+ }
34453
34466
  if (this._held) {
34454
34467
  this.dirty.add(name);
34455
34468
  return;
@@ -34493,10 +34506,13 @@ var PodStore = class {
34493
34506
  // the returned promise so one failure cannot poison the queue.
34494
34507
  _put(name) {
34495
34508
  const done = this.chain.then(async () => {
34496
- const body = JSON.stringify(this.cache.get(name), null, 2) + "\n";
34509
+ const body = serialise(this.cache.get(name));
34497
34510
  for (let attempt = 1; attempt <= PUT_RETRIES; attempt++) {
34498
34511
  const r = await this.storage.write(name, body, "application/json").catch((e) => ({ ok: false, retry: false, why: e.message }));
34499
- if (r.ok) return true;
34512
+ if (r.ok) {
34513
+ this.lastText.set(name, body);
34514
+ return true;
34515
+ }
34500
34516
  if (!r.retry) {
34501
34517
  this.log(`state write ${name} refused (${r.why}) \u2014 not retrying`);
34502
34518
  return false;
@@ -34523,6 +34539,7 @@ var PodStore = class {
34523
34539
  // migrates to the local machine — leaving the copy behind would defeat it).
34524
34540
  async remove(name) {
34525
34541
  this.cache.delete(name);
34542
+ this.lastText.delete(name);
34526
34543
  clearTimeout(this.timers.get(name));
34527
34544
  this.timers.delete(name);
34528
34545
  if (!this.storage) return true;
@@ -34773,6 +34790,7 @@ var PodStore = class {
34773
34790
  }
34774
34791
  cacheActor(url, doc) {
34775
34792
  const a = this.getActors();
34793
+ const known2 = a[url];
34776
34794
  a[url] = {
34777
34795
  name: clamp(plainText(doc.name || doc.preferredUsername || ""), MAX_NAME),
34778
34796
  preferredUsername: clamp(plainText(doc.preferredUsername || ""), MAX_NAME),
@@ -34785,9 +34803,13 @@ var PodStore = class {
34785
34803
  type: doc.type || "Person",
34786
34804
  followers: doc.followers || null,
34787
34805
  following: doc.following || null,
34788
- ...a[url]?.counts ? { counts: a[url].counts } : {},
34806
+ ...known2?.counts ? { counts: known2.counts } : {},
34789
34807
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
34790
34808
  };
34809
+ if (known2 && sameButWhen(known2, a[url])) {
34810
+ a[url] = known2;
34811
+ return;
34812
+ }
34791
34813
  this.write("actors.json", prune(a, ACTOR_CACHE_MAX, this.getContacts()));
34792
34814
  }
34793
34815
  // @user@host for an actor we have cached. Null when we have not, rather than
@@ -45487,11 +45509,11 @@ async function writeKeep(pod, urls) {
45487
45509
  await pod.putJson(urls.inbox + ".keep", { keep: true }, "application/json");
45488
45510
  }
45489
45511
  async function setPosture(pod, urls, posture) {
45490
- if (posture === "open") return pod.setAcl(urls.inbox, ["Append"]);
45491
- if (posture === "closed") return pod.setAcl(urls.inbox, []);
45512
+ if (posture === "open") return pod.setAcl(urls.inbox, ["Append"], { ifChanged: true });
45513
+ if (posture === "closed") return pod.setAcl(urls.inbox, [], { ifChanged: true });
45492
45514
  const webId = posture?.gatewayWebId;
45493
45515
  if (!webId) throw new Error(`inbox.setPosture: unknown posture ${JSON.stringify(posture)}`);
45494
- return pod.setAcl(urls.inbox, [], { appendAgents: [webId] });
45516
+ return pod.setAcl(urls.inbox, [], { appendAgents: [webId], ifChanged: true });
45495
45517
  }
45496
45518
  async function list(pod, urls) {
45497
45519
  const children = await pod.listContainer(urls.inbox);
@@ -45508,6 +45530,9 @@ async function readDeliveryReceipt(pod, itemUrl, { maxBytes, readCapped: readCap
45508
45530
  return JSON.parse(await readCapped3(res, maxBytes));
45509
45531
  }
45510
45532
  var dropHandledItem = (pod, url) => pod.delete(url);
45533
+ var dropReceiptBeside = (pod, url) => pod.delete(url + ".receipt.json").catch(() => false);
45534
+ var orphanReceipts = (pod, urls) => pod.orphanReceipts?.(urls.inbox) ?? [];
45535
+ var dropStrayReceipt = (pod, url) => pod.delete(url).catch(() => false);
45511
45536
 
45512
45537
  // lib/pod/collection.mjs
45513
45538
  var PUBLIC_READ3 = ["Read"];
@@ -63870,6 +63895,7 @@ var DRAIN_COOLDOWN_MAX_MS = 30 * 6e4;
63870
63895
  var DELETE_GAP_MS = 150;
63871
63896
  var CHAIN_GAP_MS = 5e3;
63872
63897
  var DELETE_BATCH = 10;
63898
+ var ORPHAN_RECEIPTS_PER_SWEEP = 20;
63873
63899
  var ATTEMPTS_DOC = "intake-attempts.json";
63874
63900
  var ATTEMPTS_TTL_MS = 7 * 24 * 60 * 6e4;
63875
63901
  var MAX_ITEM_ATTEMPTS = 5;
@@ -64140,6 +64166,7 @@ var Intake = class {
64140
64166
  if (all.length > items.length) this.log(`inbox has ${all.length} items \u2014 processing ${items.length} this sweep`);
64141
64167
  let handled = 0;
64142
64168
  const pending = [];
64169
+ const withReceipt = /* @__PURE__ */ new Set();
64143
64170
  const flush = async () => {
64144
64171
  if (!pending.length) return true;
64145
64172
  if (!await this._persisted()) {
@@ -64154,12 +64181,14 @@ var Intake = class {
64154
64181
  }
64155
64182
  this._clearAttempt(url);
64156
64183
  handled++;
64184
+ if (withReceipt.has(url)) await dropReceiptBeside(this.remote, url);
64157
64185
  await new Promise((r) => setTimeout(r, DELETE_GAP_MS));
64158
64186
  }
64159
64187
  return true;
64160
64188
  };
64161
- for (const { url, size } of items) {
64189
+ for (const { url, size, receipt: hasReceipt } of items) {
64162
64190
  if (url.endsWith(".keep")) continue;
64191
+ if (hasReceipt) withReceipt.add(url);
64163
64192
  if (size > MAX_ITEM_BYTES) {
64164
64193
  this.store.addDeadLetter({ inboxUrl: url, reason: `oversized (${size} bytes)`, activity: null });
64165
64194
  pending.push(url);
@@ -64185,7 +64214,7 @@ var Intake = class {
64185
64214
  });
64186
64215
  }
64187
64216
  }
64188
- const receipt = activity ? await this._readReceipt(url) : null;
64217
+ const receipt = activity && hasReceipt !== false ? await this._readReceipt(url) : null;
64189
64218
  if (activity && this.gatewaySecret()) this._bumpGatewayStat(!!receipt?.verified);
64190
64219
  const owned = activity && this.isOwnerPost(receipt);
64191
64220
  const rejection = !activity ? "unparsable JSON" : owned ? await this.ownerPostFrom(activity, raw, receipt) : await this.handle(activity, receipt);
@@ -64212,6 +64241,10 @@ var Intake = class {
64212
64241
  if (pending.length >= DELETE_BATCH && !await flush()) return;
64213
64242
  }
64214
64243
  if (!await this._finishSweep(flush)) return;
64244
+ for (const stray of orphanReceipts(this.remote, this.urls).slice(0, ORPHAN_RECEIPTS_PER_SWEEP)) {
64245
+ if (!await dropStrayReceipt(this.remote, stray)) break;
64246
+ await new Promise((r) => setTimeout(r, DELETE_GAP_MS));
64247
+ }
64215
64248
  if (handled > 0 && all.length > items.length && !this.stopped) this._drainAgain = true;
64216
64249
  }
64217
64250
  // The end of a sweep: publish whatever the follow graph did ONCE, then flush.
@@ -65418,8 +65451,8 @@ var C2S = class {
65418
65451
 
65419
65452
  // lib/core/lease.mjs
65420
65453
  init_node_crypto();
65421
- var TTL_MS = 3e5;
65422
- var RENEW_MS = 9e4;
65454
+ var TTL_MS = 9e5;
65455
+ var RENEW_MS = 3e5;
65423
65456
  var JITTER = () => 0.85 + Math.random() * 0.3;
65424
65457
  var UNREADABLE = /* @__PURE__ */ Symbol("lease-unreadable");
65425
65458
  var Lease = class {
@@ -66495,6 +66528,7 @@ var BskyFeed = class {
66495
66528
  this.lastSweep = (/* @__PURE__ */ new Date()).toISOString();
66496
66529
  const self2 = this.atproto.read()?.did;
66497
66530
  let added = 0;
66531
+ this.store.hold?.();
66498
66532
  try {
66499
66533
  const ownMirrors = new Set(this.store.getStatuses().map((s) => s.atproto?.uri).filter(Boolean));
66500
66534
  const tl = await this.atproto.xrpc("app.bsky.feed.getTimeline", { params: { limit: PER_SWEEP } });
@@ -66543,6 +66577,8 @@ var BskyFeed = class {
66543
66577
  } catch (e) {
66544
66578
  this._backOff(e.status || 0, null);
66545
66579
  return;
66580
+ } finally {
66581
+ this.store.release?.();
66546
66582
  }
66547
66583
  const all = this.store.getStatuses();
66548
66584
  const mirrored = all.filter((s) => s.kind === "bsky");
@@ -68840,7 +68876,29 @@ var PodTransport = class {
68840
68876
  const podTarget = this.toPod ? this.toPod(targetUrl) : targetUrl;
68841
68877
  const url = await this.aclUrlFor(podTarget);
68842
68878
  if (!await this.aclWritable(url)) return null;
68843
- return this.put(url, this.aclDoc(podTarget, publicModes, { ...opts, aclUrl: url }), "text/turtle");
68879
+ const doc = this.aclDoc(podTarget, publicModes, { ...opts, aclUrl: url });
68880
+ if (opts.ifChanged && await this.aclSame(url, doc)) return { status: 304, unchanged: true };
68881
+ return this.put(url, doc, "text/turtle");
68882
+ }
68883
+ // Whether the pod's rule at `aclUrl` states exactly what `doc` states.
68884
+ // Compared as graphs, not bytes: the pod serialises what it holds its own
68885
+ // way. Every rule this file writes names its subjects, so triple sets are
68886
+ // enough; anything unreadable or with blank nodes reads as different.
68887
+ async aclSame(aclUrl, doc) {
68888
+ try {
68889
+ const res = await this.fetch(aclUrl, { headers: { accept: "text/turtle" } });
68890
+ if (res.status !== 200) return false;
68891
+ const triples = (text) => {
68892
+ const g = graph();
68893
+ parse2(text, g, aclUrl, "text/turtle");
68894
+ if (g.statements.some((st2) => st2.subject.termType === "BlankNode" || st2.object.termType === "BlankNode")) return null;
68895
+ return g.statements.map((st2) => `${st2.subject.value} ${st2.predicate.value} ${st2.object.value}`).sort().join("\n");
68896
+ };
68897
+ const theirs = triples(await res.text());
68898
+ return theirs !== null && theirs === triples(doc);
68899
+ } catch {
68900
+ return false;
68901
+ }
68844
68902
  }
68845
68903
  // Child documents of an LDP container (URLs under it, excluding aux docs).
68846
68904
  // Revalidated: the inbox is polled every couple of minutes and is usually
@@ -68864,10 +68922,15 @@ var PodTransport = class {
68864
68922
  parse2(body, g, url, "text/turtle");
68865
68923
  const here = namedNode2(url);
68866
68924
  const seen = /* @__PURE__ */ new Set();
68925
+ const receipts = /* @__PURE__ */ new Set();
68867
68926
  const list3 = [];
68868
68927
  for (const child of g.each(here, LDP2("contains"), null, here)) {
68869
68928
  const u = child.value;
68870
- if (!u.startsWith(url) || u === url || /\.(acl|meta|receipt\.json)$/.test(u) || seen.has(u)) continue;
68929
+ if (u.endsWith(".receipt.json")) {
68930
+ receipts.add(u);
68931
+ continue;
68932
+ }
68933
+ if (!u.startsWith(url) || u === url || /\.(acl|meta)$/.test(u) || seen.has(u)) continue;
68871
68934
  seen.add(u);
68872
68935
  list3.push({
68873
68936
  url: u,
@@ -68875,10 +68938,16 @@ var PodTransport = class {
68875
68938
  modified: g.any(child, DC("modified"), null, here)?.value || null
68876
68939
  });
68877
68940
  }
68941
+ for (const item of list3) item.receipt = receipts.has(item.url + ".receipt.json");
68942
+ const orphans = [...receipts].filter((r) => !seen.has(r.slice(0, -".receipt.json".length)));
68878
68943
  list3.sort((a, b) => String(a.modified || "").localeCompare(String(b.modified || "")));
68879
- this._listCache.set(url, { etag: res.headers.get("etag"), children: list3 });
68944
+ this._listCache.set(url, { etag: res.headers.get("etag"), children: list3, orphans });
68880
68945
  return list3;
68881
68946
  }
68947
+ /** Receipts in the last listing of `url` whose item is gone. */
68948
+ orphanReceipts(url) {
68949
+ return this._listCache?.get(url)?.orphans ?? [];
68950
+ }
68882
68951
  /**
68883
68952
  * The WebID profile advertises the actor as an account:
68884
68953
  * <webId> foaf:account <actor> .
@@ -69208,6 +69277,7 @@ var Deliverer = class {
69208
69277
  this.edPrivate = edPrivate;
69209
69278
  this.proofKeyId = proofKeyId;
69210
69279
  this.log = log2;
69280
+ this.batchSize = 1;
69211
69281
  if (!passive) this.startQueue();
69212
69282
  }
69213
69283
  startQueue() {
@@ -69284,31 +69354,56 @@ var Deliverer = class {
69284
69354
  return activity;
69285
69355
  }
69286
69356
  }
69357
+ // One attempt per target, answered in order: `{ ok: true }` or `{ error }`
69358
+ // with the error deliverNow would have thrown. Here one at a time; the
69359
+ // relay deliverer sends the whole list in one call.
69360
+ async deliverManyNow(targets) {
69361
+ const out = [];
69362
+ for (const t of targets) {
69363
+ try {
69364
+ await this.deliverNow(t.inbox, t.activity);
69365
+ out.push({ ok: true });
69366
+ } catch (error2) {
69367
+ out.push({ error: error2 });
69368
+ }
69369
+ }
69370
+ return out;
69371
+ }
69287
69372
  async deliver(inbox, activity) {
69288
69373
  const signed = await this.proofed(activity);
69374
+ if (this._queueIfCooling(inbox, signed)) return;
69375
+ const [result] = await this.deliverManyNow([{ inbox, activity: signed }]);
69376
+ await this._settle(inbox, signed, result);
69377
+ }
69378
+ // A host we already know is refusing: queue without asking again. This is
69379
+ // the path a FRESH activity takes, so without it a fan-out to a struggling
69380
+ // server opened one socket per follower before any of this applied.
69381
+ _queueIfCooling(inbox, signed) {
69289
69382
  const host = hostOf(inbox);
69290
69383
  const until = this._cooling?.get(host);
69291
- if (until && until > Date.now()) {
69292
- this.log(`${host} is cooling \u2014 queueing ${signed.type} rather than asking again`);
69293
- this._enqueue({ inbox, activity: signed, attempts: 1, nextAt: until });
69384
+ if (!until || until <= Date.now()) return false;
69385
+ this.log(`${host} is cooling \u2014 queueing ${signed.type} rather than asking again`);
69386
+ this._enqueue({ inbox, activity: signed, attempts: 1, nextAt: until });
69387
+ return true;
69388
+ }
69389
+ // What one attempt's outcome means for the queue.
69390
+ async _settle(inbox, signed, result) {
69391
+ if (result.ok) {
69392
+ this.log(`delivered ${signed.type} \u2192 ${inbox}`);
69294
69393
  return;
69295
69394
  }
69296
- try {
69297
- await this.deliverNow(inbox, signed);
69298
- this.log(`delivered ${signed.type} \u2192 ${inbox}`);
69299
- } catch (e) {
69300
- if (unsalvageable(e)) {
69301
- await this._unsalvageable(inbox, e);
69302
- return;
69303
- }
69304
- this.log(`delivery failed (${e.message}) \u2014 queued`);
69305
- const wait = e.retryAfterMs || 6e4;
69306
- if (aboutTheHost(e)) {
69307
- this._cooling ||= /* @__PURE__ */ new Map();
69308
- this._cooling.set(host, Date.now() + wait);
69309
- }
69310
- this._enqueue({ inbox, activity: signed, attempts: 1, nextAt: Date.now() + wait });
69395
+ const e = result.error;
69396
+ if (unsalvageable(e)) {
69397
+ await this._unsalvageable(inbox, e);
69398
+ return;
69399
+ }
69400
+ this.log(`delivery failed (${e.message}) \u2014 queued`);
69401
+ const wait = e.retryAfterMs || 6e4;
69402
+ if (aboutTheHost(e)) {
69403
+ this._cooling ||= /* @__PURE__ */ new Map();
69404
+ this._cooling.set(hostOf(inbox), Date.now() + wait);
69311
69405
  }
69406
+ this._enqueue({ inbox, activity: signed, attempts: 1, nextAt: Date.now() + wait });
69312
69407
  }
69313
69408
  // Not retried, and on a 410 the followers who received there are dropped,
69314
69409
  // so the next fan-out stops asking; the followers collection is republished
@@ -69344,7 +69439,13 @@ var Deliverer = class {
69344
69439
  }
69345
69440
  async deliverToAll(inboxes, activity) {
69346
69441
  const signed = await this.proofed(activity);
69347
- for (const inbox of [...new Set(inboxes)]) await this.deliver(inbox, signed);
69442
+ const targets = [...new Set(inboxes)].map((inbox) => ({ inbox, activity: signed }));
69443
+ for (let i = 0; i < targets.length; i += this.batchSize) {
69444
+ const chunk = targets.slice(i, i + this.batchSize).filter((t) => !this._queueIfCooling(t.inbox, signed));
69445
+ if (!chunk.length) continue;
69446
+ const results = await this.deliverManyNow(chunk);
69447
+ for (let k = 0; k < chunk.length; k++) await this._settle(chunk[k].inbox, signed, results[k]);
69448
+ }
69348
69449
  }
69349
69450
  // Serialized, for the same reason Intake.drain is: the tick is 60s and a
69350
69451
  // drain over slow peers outlasts it, so a second run started on top of the
@@ -69444,6 +69545,7 @@ var Deliverer = class {
69444
69545
 
69445
69546
  // web/app/deliver-relay.mjs
69446
69547
  init_fedify_sig();
69548
+ var RELAY_MAX_REQUESTS = 20;
69447
69549
  function doorKeyOf(doorInboxUrl) {
69448
69550
  try {
69449
69551
  const seg2 = new URL(doorInboxUrl).pathname.split("/");
@@ -69458,6 +69560,7 @@ var RelayDeliverer = class extends Deliverer {
69458
69560
  this.relayUrl = opts.relayUrl;
69459
69561
  this.handle = opts.handle;
69460
69562
  this.sessionFetch = opts.sessionFetch;
69563
+ this.batchSize = RELAY_MAX_REQUESTS;
69461
69564
  }
69462
69565
  // Same contract as Deliverer.signedFetch, DEFAULT INCLUDED: an init with no
69463
69566
  // method is a read. The Node one builds a `Request`, whose default is GET, and
@@ -69467,9 +69570,41 @@ var RelayDeliverer = class extends Deliverer {
69467
69570
  // never saw the document it asked for: a Follow from anyone new was rejected
69468
69571
  // with "actor fetch failed", and nothing needing a lookup could be ingested.
69469
69572
  async signedFetch(url, init = {}) {
69573
+ const req = await this._signedRequest(url, init);
69574
+ const [r0] = await this._relay([req]);
69575
+ return this._outcome(r0, url, init.method || "GET");
69576
+ }
69577
+ // A fan-out in one call: the relay takes a list, so a post to twenty
69578
+ // followers is one call, not twenty (Deliverer.deliverToAll, batchSize).
69579
+ async deliverManyNow(targets) {
69580
+ const reqs = await Promise.all(targets.map((t) => this._signedRequest(t.inbox, {
69581
+ method: "POST",
69582
+ headers: { "content-type": "application/activity+json" },
69583
+ body: JSON.stringify(t.activity)
69584
+ })));
69585
+ let results;
69586
+ try {
69587
+ results = await this._relay(reqs);
69588
+ } catch (error2) {
69589
+ return targets.map(() => ({ error: error2 }));
69590
+ }
69591
+ return targets.map((t, i) => {
69592
+ try {
69593
+ this._outcome(results[i] || {}, t.inbox, "POST");
69594
+ return { ok: true };
69595
+ } catch (error2) {
69596
+ return { error: error2 };
69597
+ }
69598
+ });
69599
+ }
69600
+ // Signed here, sent verbatim by the relay. Every signed header goes along,
69601
+ // `accept` included: the signature covers it, so a relay request missing it
69602
+ // carries an invalid signature — and a read without it gets the HTML page
69603
+ // instead of the document.
69604
+ async _signedRequest(url, init = {}) {
69470
69605
  const body = typeof init.body === "string" ? init.body : init.body ? new TextDecoder().decode(init.body) : "";
69471
69606
  const s = await sign({ url, method: init.method || "GET", headers: init.headers || {}, body }, this.rsaPrivate, this.keyId);
69472
- const relayReq = {
69607
+ return {
69473
69608
  url: s.url,
69474
69609
  method: s.method,
69475
69610
  body,
@@ -69481,25 +69616,42 @@ var RelayDeliverer = class extends Deliverer {
69481
69616
  signature: s.headers.signature
69482
69617
  }
69483
69618
  };
69619
+ }
69620
+ // One relay call for a list of requests; the results in the same order.
69621
+ // The relay's OWN answer, apart from the recipients': unreachable and a
69622
+ // refusal are hiccups the queue retries. Its 404 is not — it says this
69623
+ // account has no row here, and no retry changes that. It used to be read as
69624
+ // a hiccup too, and a tab whose account the site did not know retried its
69625
+ // deliveries every minute for three days.
69626
+ async _relay(requests) {
69484
69627
  let res;
69485
69628
  try {
69486
69629
  res = await this.sessionFetch(this.relayUrl, {
69487
69630
  method: "POST",
69488
69631
  headers: { "content-type": "application/json" },
69489
- body: JSON.stringify({ handle: this.handle, requests: [relayReq] })
69632
+ body: JSON.stringify({ handle: this.handle, requests })
69490
69633
  });
69491
69634
  } catch (e) {
69492
69635
  const err = new Error(`relay unreachable: ${e.message}`);
69493
69636
  err.status = 0;
69494
69637
  throw err;
69495
69638
  }
69639
+ if (res.status === 404) {
69640
+ const err = new Error("relay: no such account here");
69641
+ err.status = 404;
69642
+ throw err;
69643
+ }
69496
69644
  if (res.status >= 400) {
69497
69645
  const err = new Error(`relay ${res.status}`);
69498
69646
  err.status = 502;
69499
69647
  throw err;
69500
69648
  }
69501
69649
  const out = await res.json().catch(() => ({}));
69502
- const r0 = out.results && out.results[0] || {};
69650
+ return Array.isArray(out.results) ? out.results : [];
69651
+ }
69652
+ // What the far server answered, as the Node deliverer would have seen it:
69653
+ // a Response for a read, a thrown error carrying the status for a refusal.
69654
+ _outcome(r0, url, method) {
69503
69655
  const status2 = r0.status || 0;
69504
69656
  if (status2 === 0) {
69505
69657
  const err = new Error(r0.error || "relay could not send");
@@ -69507,7 +69659,7 @@ var RelayDeliverer = class extends Deliverer {
69507
69659
  throw err;
69508
69660
  }
69509
69661
  if (status2 >= 400) {
69510
- const err = new Error(`${init.method || "POST"} ${url} \u2192 ${status2}`);
69662
+ const err = new Error(`${method} ${url} \u2192 ${status2}`);
69511
69663
  err.status = status2;
69512
69664
  if (r0.retryAfter) {
69513
69665
  const secs = Number(r0.retryAfter);
@@ -71775,7 +71927,8 @@ var BrowserAgent = class _BrowserAgent {
71775
71927
  this.lease.onLost = () => this.demote();
71776
71928
  this.lease.startRenewal();
71777
71929
  try {
71778
- await this.store.load({ force: true }).catch((e) => this.log(`re-reading state: ${e.message}`));
71930
+ await this.store.load({ force: !!this._watched }).catch((e) => this.log(`re-reading state: ${e.message}`));
71931
+ this._watched = false;
71779
71932
  await this.publisher.healStatuses().catch((e) => this.log(`healing the timeline index: ${e.message}`));
71780
71933
  await this.publisher.publishProfilePage().catch((e) => this.log(`profile page: ${e.message}`));
71781
71934
  this.deliverer?.startQueue?.();
@@ -71796,6 +71949,7 @@ var BrowserAgent = class _BrowserAgent {
71796
71949
  demote() {
71797
71950
  if (this.viewer) return;
71798
71951
  this.viewer = true;
71952
+ this._watched = true;
71799
71953
  this.log("another device took over \u2014 read-only here");
71800
71954
  this.lease.stopRenewal();
71801
71955
  clearInterval(this._openTimer);
@@ -71913,12 +72067,7 @@ var BrowserAgent = class _BrowserAgent {
71913
72067
  sessionFetch: session.fetch,
71914
72068
  onGone: () => this.publisher.publishCollections({ followers: true })
71915
72069
  });
71916
- this.gatewayApi = null;
71917
- try {
71918
- if (config.gateway?.url) this.gatewayApi = `${new URL(config.gateway.url).origin}/api`;
71919
- } catch {
71920
- this.gatewayApi = null;
71921
- }
72070
+ this.gatewayApi = config.gateway?.url ? `${frontOrigin.replace(/\/$/, "")}/api` : null;
71922
72071
  this.doorKey = doorKeyOf(config.gateway?.url) || config.handle;
71923
72072
  this.gatewayStanding = null;
71924
72073
  const standing = await this.openAtGateway();
@@ -71996,6 +72145,7 @@ var BrowserAgent = class _BrowserAgent {
71996
72145
  await this.fediaccts.load();
71997
72146
  this.viewer = !await this.lease.acquire();
71998
72147
  if (this.viewer) {
72148
+ this._watched = true;
71999
72149
  this.log(`read-only viewer: another device is active on @${config.handle}`);
72000
72150
  this.startViewerPoll();
72001
72151
  return;