@join-pantheon/cli 0.28.0 → 0.29.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/network.js CHANGED
@@ -44,9 +44,12 @@ export function redactProxy(url) {
44
44
  return u.toString().replace(/\/$/, "");
45
45
  }
46
46
  catch {
47
- return "(unparseable)";
47
+ return redactText(url);
48
48
  }
49
49
  }
50
+ export function redactText(raw) {
51
+ return raw.replace(/^(\s*(?:[a-z][a-z0-9+.-]*:\/\/)?)[^@/\s]*@/i, "$1***@");
52
+ }
50
53
  export function splitNoProxy(raw) {
51
54
  return [...new Set([...(raw ?? "").split(/[,\s]+/).map((s) => s.trim().toLowerCase()).filter(Boolean), ...LOOPBACK])];
52
55
  }
@@ -133,6 +136,8 @@ const run = (cmd, args) => {
133
136
  return null;
134
137
  }
135
138
  };
139
+ export const chosenByPerson = (s) => !!s && (s.proxy === "" || /^pantheon network/.test(s.source ?? ""));
140
+ export const windowsRegExe = (env = process.env) => `${env.SystemRoot ?? env.SYSTEMROOT ?? "C:\\Windows"}\\System32\\reg.exe`;
136
141
  function savedFromConfig(env) {
137
142
  try {
138
143
  const home = env.PANTHEON_HOME ?? join(env.HOME ?? env.USERPROFILE ?? "", ".pantheon");
@@ -150,18 +155,30 @@ export const defaultNetworkDeps = () => ({
150
155
  os: platform(),
151
156
  saved: () => savedFromConfig(process.env),
152
157
  scutil: () => run("/usr/sbin/scutil", ["--proxy"]),
153
- winReg: () => run("reg", ["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings"]),
158
+ winReg: () => run(windowsRegExe(), ["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings"]),
154
159
  });
155
160
  export function resolveProxyPlan(d = defaultNetworkDeps()) {
156
161
  const noProxy = splitNoProxy(pick(d.env, "no_proxy", "NO_PROXY"));
157
162
  const forced = d.env.PANTHEON_PROXY?.trim();
158
163
  if (forced) {
159
164
  if (/^(none|direct|off|false|0)$/i.test(forced))
160
- return { ...NONE, noProxy };
165
+ return { ...NONE, noProxy, directChosen: true };
161
166
  const p = normalizeProxy(forced);
162
167
  if (p)
163
168
  return { https: p, http: p, noProxy, source: "PANTHEON_PROXY", fallbackOnly: false };
164
169
  }
170
+ const saved = d.saved();
171
+ const fromSaved = (retryDirect) => {
172
+ const sp = saved?.proxy ? normalizeProxy(saved.proxy) : null;
173
+ return sp ? { https: sp, http: sp, noProxy: splitNoProxy([saved?.no_proxy, ...noProxy].filter(Boolean).join(",")), source: "config", fallbackOnly: false, ...(retryDirect ? { retryDirect: true } : {}) } : null;
174
+ };
175
+ if (chosenByPerson(saved)) {
176
+ if (saved.proxy === "")
177
+ return { ...NONE, noProxy, directChosen: true };
178
+ const p = fromSaved(false);
179
+ if (p)
180
+ return p;
181
+ }
165
182
  const envHttps = pick(d.env, "https_proxy", "HTTPS_PROXY", "all_proxy", "ALL_PROXY");
166
183
  const envHttp = pick(d.env, "http_proxy", "HTTP_PROXY", "all_proxy", "ALL_PROXY");
167
184
  if (envHttps || envHttp) {
@@ -170,12 +187,9 @@ export function resolveProxyPlan(d = defaultNetworkDeps()) {
170
187
  if (hs || h)
171
188
  return { https: hs, http: h, noProxy, source: "env", fallbackOnly: false };
172
189
  }
173
- const saved = d.saved();
174
- if (saved && saved.proxy === "")
175
- return { ...NONE, noProxy };
176
- const sp = saved?.proxy ? normalizeProxy(saved.proxy) : null;
177
- if (sp)
178
- return { https: sp, http: sp, noProxy: splitNoProxy([saved?.no_proxy, ...noProxy].filter(Boolean).join(",")), source: "config", fallbackOnly: false };
190
+ const auto = fromSaved(true);
191
+ if (auto)
192
+ return auto;
179
193
  const sys = d.os === "darwin" ? (() => { const o = d.scutil(); return o ? parseScutil(o) : null; })()
180
194
  : d.os === "win32" ? (() => { const o = d.winReg(); return o ? parseWindowsReg(o) : null; })()
181
195
  : null;
@@ -249,12 +263,51 @@ export function tunnelAgent(proxyUrl, secure) {
249
263
  return new Tunnel({ keepAlive: false });
250
264
  }
251
265
  let installed = null;
252
- let fallbackUsed = false;
266
+ const ALTERNATE_TTL = 10 * 60_000;
267
+ const TRIAL_TTL = 60_000;
268
+ const RETRY_TIMEOUT = 15_000;
269
+ const CONNECT_TIMEOUT = 5_000;
270
+ let clock = () => Date.now();
271
+ const hosts = new Map();
272
+ let lastRelayRoute = null;
273
+ const hostKey = (target) => {
274
+ try {
275
+ const u = new URL(target);
276
+ const secure = u.protocol === "https:" || u.protocol === "wss:";
277
+ return `${u.hostname.toLowerCase()}:${u.port || (secure ? "443" : "80")}`;
278
+ }
279
+ catch {
280
+ return target;
281
+ }
282
+ };
283
+ const stateOf = (target) => {
284
+ const k = hostKey(target);
285
+ let s = hosts.get(k);
286
+ if (!s) {
287
+ s = { alternateUntil: 0, trialUntil: 0 };
288
+ hosts.set(k, s);
289
+ }
290
+ return s;
291
+ };
292
+ const alternateActive = (target) => clock() < (hosts.get(hostKey(target))?.alternateUntil ?? 0);
293
+ const trialArmed = (target) => clock() < (hosts.get(hostKey(target))?.trialUntil ?? 0);
294
+ const anyAlternate = () => [...hosts.values()].some((s) => clock() < s.alternateUntil || clock() < s.trialUntil);
295
+ const hasAlternate = (plan) => !!(plan.https || plan.http) && (plan.fallbackOnly || !!plan.retryDirect);
296
+ const onAlternate = (target) => alternateActive(target) || trialArmed(target);
297
+ export function routeFor(target, plan = currentPlan()) {
298
+ const p = proxyFor(target, plan);
299
+ if (!p || !hasAlternate(plan))
300
+ return p;
301
+ return plan.fallbackOnly ? (onAlternate(target) ? p : null) : (onAlternate(target) ? null : p);
302
+ }
303
+ function planRoute(plan) {
304
+ const p = plan.https ?? plan.http;
305
+ if (!p || !hasAlternate(plan))
306
+ return p;
307
+ return plan.fallbackOnly ? (anyAlternate() ? p : null) : (anyAlternate() ? null : p);
308
+ }
253
309
  export function wsOptions(address) {
254
- const plan = currentPlan();
255
- if (plan.fallbackOnly && !fallbackUsed)
256
- return {};
257
- const p = proxyFor(address, plan);
310
+ const p = routeFor(address, currentPlan());
258
311
  if (!p)
259
312
  return {};
260
313
  return { agent: tunnelAgent(p, /^wss:/.test(address)) };
@@ -268,64 +321,193 @@ export async function installNetwork() {
268
321
  const plan = currentPlan();
269
322
  if (plan.https || plan.http) {
270
323
  if (!plan.fallbackOnly)
271
- await useProxyForFetch(plan);
272
- else
273
- wrapFetchWithFallback(plan);
324
+ await useRouterForFetch(plan);
325
+ if (hasAlternate(plan))
326
+ wrapFetch(plan);
274
327
  }
275
328
  return plan;
276
329
  }
277
- async function useProxyForFetch(plan) {
330
+ function makeRouter(u, plan) {
331
+ const bound = hasAlternate(plan) ? { connect: { timeout: CONNECT_TIMEOUT } } : {};
332
+ const direct = new u.Agent(bound);
333
+ const proxies = new Map();
334
+ const pick = (origin) => {
335
+ const p = proxyFor(origin, plan);
336
+ if (!p)
337
+ return direct;
338
+ let a = proxies.get(p);
339
+ if (!a) {
340
+ a = new u.ProxyAgent({ uri: p, ...(hasAlternate(plan) ? { proxyTls: { timeout: CONNECT_TIMEOUT } } : {}) });
341
+ proxies.set(p, a);
342
+ }
343
+ return a;
344
+ };
345
+ const all = () => [direct, ...proxies.values()];
346
+ class Router extends u.Dispatcher {
347
+ dispatch(opts, handler) {
348
+ return pick(String(opts.origin ?? "")).dispatch(opts, handler);
349
+ }
350
+ }
351
+ const r = new Router();
352
+ Object.assign(r, {
353
+ close: () => Promise.all(all().map((a) => a.close())).then(() => undefined),
354
+ destroy: () => Promise.all(all().map((a) => a.destroy())).then(() => undefined),
355
+ });
356
+ return r;
357
+ }
358
+ const DISPATCHER = Symbol.for("undici.globalDispatcher.1");
359
+ const DISPATCHER2 = Symbol.for("undici.globalDispatcher.2");
360
+ let previousDispatcher = null;
361
+ let originalFetch = null;
362
+ async function useRouterForFetch(plan) {
278
363
  if (installed?.active)
279
364
  return;
280
- const { EnvHttpProxyAgent, setGlobalDispatcher } = await import("undici");
281
- setGlobalDispatcher(new EnvHttpProxyAgent({
282
- ...(plan.https ? { httpsProxy: plan.https } : {}),
283
- ...(plan.http ? { httpProxy: plan.http } : {}),
284
- noProxy: plan.noProxy.join(","),
285
- }));
365
+ const u = await import("undici");
366
+ const g = globalThis;
367
+ previousDispatcher ??= { v1: g[DISPATCHER], v2: g[DISPATCHER2] };
368
+ u.setGlobalDispatcher(makeRouter(u, plan));
286
369
  if (installed)
287
370
  installed.active = plan.https ?? plan.http;
288
371
  }
289
372
  const BLOCKED = /ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|EPROTO|wrong version number|UND_ERR_CONNECT_TIMEOUT|UND_ERR_SOCKET|handshake has timed out|socket hang up/i;
290
- export const isBlockedPath = (e) => {
291
- const err = e;
292
- return BLOCKED.test(`${err?.code ?? ""} ${err?.message ?? ""} ${err?.cause?.code ?? ""} ${err?.cause?.message ?? ""}`);
293
- };
294
- function wrapFetchWithFallback(plan) {
373
+ const NOTHING_SENT = /ENOTFOUND|EAI_AGAIN|ECONNREFUSED|EHOSTUNREACH|ENETUNREACH|UND_ERR_CONNECT_TIMEOUT|EPROTO|wrong version number|packet length too long|unknown protocol|ERR_SSL_|before secure TLS connection was established/i;
374
+ function errText(e, depth = 0) {
375
+ if (!e || depth > 4)
376
+ return "";
377
+ const x = e;
378
+ return [typeof x.code === "string" ? x.code : "", typeof x.message === "string" ? x.message : typeof e === "string" ? e : "",
379
+ errText(x.cause, depth + 1), ...(Array.isArray(x.errors) ? x.errors.map((y) => errText(y, depth + 1)) : [])].filter(Boolean).join(" ");
380
+ }
381
+ export const isBlockedPath = (e) => BLOCKED.test(errText(e));
382
+ const isCallerTimeout = (e) => e?.name === "TimeoutError";
383
+ export function safeToRetry(e, method) {
384
+ const m = method.toUpperCase();
385
+ return m === "GET" || m === "HEAD" ? isBlockedPath(e) || isCallerTimeout(e) : NOTHING_SENT.test(errText(e));
386
+ }
387
+ export const relayAnsweredFetch = (res) => /application\/json/i.test(res.headers.get("content-type") ?? "") && ![407, 502, 503, 504, 511].includes(res.status);
388
+ function retrySignal(caller) {
389
+ const own = AbortSignal.timeout(RETRY_TIMEOUT);
390
+ if (!caller)
391
+ return own;
392
+ const ctl = new AbortController();
393
+ const follow = () => { if (!isCallerTimeout(caller.reason))
394
+ ctl.abort(caller.reason); };
395
+ if (caller.aborted)
396
+ follow();
397
+ else
398
+ caller.addEventListener("abort", follow, { once: true });
399
+ return AbortSignal.any([own, ctl.signal]);
400
+ }
401
+ function wrapFetch(plan) {
295
402
  const g = globalThis;
296
- if (g.__pantheonFetch)
403
+ if (originalFetch)
297
404
  return;
298
- g.__pantheonFetch = true;
299
- const direct = g.fetch.bind(globalThis);
405
+ const original = g.fetch;
406
+ originalFetch = original;
407
+ let routes = null;
408
+ const dispatchers = () => (routes ??= import("undici").then((u) => {
409
+ const proxied = makeRouter(u, plan), direct = new u.Agent({ connect: { timeout: CONNECT_TIMEOUT } });
410
+ return plan.fallbackOnly ? { primary: direct, alternate: proxied } : { primary: proxied, alternate: direct };
411
+ }));
412
+ const send = async (input, init, which, signal) => original(input, { ...init, ...(signal ? { signal } : {}), dispatcher: (await dispatchers())[which] });
300
413
  g.fetch = (async (input, init) => {
301
- if (fallbackUsed)
302
- return direct(input, init);
414
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
415
+ const method = init?.method ?? (typeof input === "object" && !(input instanceof URL) ? input.method : "GET");
416
+ if (!proxyFor(url, plan))
417
+ return original(input, init);
418
+ if (alternateActive(url)) {
419
+ try {
420
+ const res = await send(input, init, "alternate");
421
+ if (relayAnsweredFetch(res))
422
+ noteRelayAnswered(url);
423
+ return res;
424
+ }
425
+ catch (e) {
426
+ if (isBlockedPath(e))
427
+ stateOf(url).alternateUntil = 0;
428
+ throw e;
429
+ }
430
+ }
431
+ let res;
303
432
  try {
304
- return await direct(input, init);
433
+ res = await send(input, init, "primary");
305
434
  }
306
435
  catch (e) {
307
- const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
308
- if (!isBlockedPath(e) || !proxyFor(url, plan))
436
+ if (!safeToRetry(e, method))
437
+ throw e;
438
+ if (init?.signal?.aborted && !isCallerTimeout(init.signal.reason))
309
439
  throw e;
310
- await useProxyForFetch(plan);
311
- fallbackUsed = true;
312
- return direct(input, init);
440
+ let alt;
441
+ try {
442
+ alt = await send(input, init, "alternate", retrySignal(init?.signal));
443
+ }
444
+ catch {
445
+ throw e;
446
+ }
447
+ if (!relayAnsweredFetch(alt)) {
448
+ void alt.body?.cancel().catch(() => { });
449
+ throw e;
450
+ }
451
+ stateOf(url).alternateUntil = clock() + ALTERNATE_TTL;
452
+ noteRelayAnswered(url);
453
+ return alt;
313
454
  }
455
+ if (relayAnsweredFetch(res))
456
+ noteRelayAnswered(url);
457
+ return res;
314
458
  });
315
459
  }
316
460
  export function trySystemFallback(address, e) {
317
461
  const plan = currentPlan();
318
- if (!plan.fallbackOnly || fallbackUsed || !isBlockedPath(e) || !proxyFor(address, plan))
462
+ if (!hasAlternate(plan) || !proxyFor(address, plan) || !isBlockedPath(e) || trialArmed(address))
463
+ return false;
464
+ const s = stateOf(address);
465
+ if (alternateActive(address)) {
466
+ s.alternateUntil = 0;
319
467
  return false;
320
- fallbackUsed = true;
321
- void useProxyForFetch(plan);
468
+ }
469
+ s.trialUntil = clock() + TRIAL_TTL;
322
470
  return true;
323
471
  }
324
- export const usedSystemFallback = () => fallbackUsed;
472
+ export const systemFallbackPending = (address) => trialArmed(address);
473
+ export function noteRelayAnswered(address) {
474
+ if (proxyFor(address, currentPlan()))
475
+ lastRelayRoute = { proxy: routeFor(address) };
476
+ const s = hosts.get(hostKey(address));
477
+ if (s && clock() < s.trialUntil) {
478
+ s.trialUntil = 0;
479
+ s.alternateUntil = clock() + ALTERNATE_TTL;
480
+ }
481
+ }
482
+ export function settleSystemFallback(address, relayAnswered) {
483
+ if (relayAnswered)
484
+ return noteRelayAnswered(address);
485
+ const s = hosts.get(hostKey(address));
486
+ if (s)
487
+ s.trialUntil = 0;
488
+ }
489
+ export const usedSystemFallback = (address) => currentPlan().fallbackOnly && (address ? alternateActive(address) : [...hosts.values()].some((s) => clock() < s.alternateUntil));
490
+ export function proxyToRemember(relay, plan, saved) {
491
+ if (chosenByPerson(saved))
492
+ return null;
493
+ if (plan.source !== "env" && !(plan.fallbackOnly && usedSystemFallback(relay)))
494
+ return null;
495
+ const via = routeFor(relay, plan);
496
+ if (!via || saved?.proxy === via)
497
+ return null;
498
+ const custom = plan.noProxy.filter((h) => !LOOPBACK.includes(h));
499
+ return { proxy: via, ...(custom.length ? { no_proxy: custom.join(",") } : {}), source: plan.source };
500
+ }
501
+ const DIRECT_ENV = {
502
+ HTTPS_PROXY: "", https_proxy: "", HTTP_PROXY: "", http_proxy: "", ALL_PROXY: "", all_proxy: "", NO_PROXY: "*", no_proxy: "*",
503
+ };
325
504
  export function proxyEnvFor(plan = currentPlan()) {
505
+ if (plan.directChosen)
506
+ return { ...DIRECT_ENV };
326
507
  if (!(plan.https || plan.http) || plan.source === "env")
327
508
  return {};
328
- if (plan.fallbackOnly && !fallbackUsed)
509
+ const viaProxy = hasAlternate(plan) ? (lastRelayRoute ? lastRelayRoute.proxy !== null : !plan.fallbackOnly) : true;
510
+ if (!viaProxy)
329
511
  return {};
330
512
  return {
331
513
  ...(plan.https ? { HTTPS_PROXY: plan.https, https_proxy: plan.https } : {}),
@@ -333,23 +515,42 @@ export function proxyEnvFor(plan = currentPlan()) {
333
515
  NO_PROXY: plan.noProxy.join(","), no_proxy: plan.noProxy.join(","),
334
516
  };
335
517
  }
336
- export function resetNetworkForTests(plan) {
518
+ export function resetNetworkForTests(plan, opts = {}) {
337
519
  installed = plan ? { plan, active: null } : null;
338
- fallbackUsed = false;
339
- const g = globalThis;
340
- delete g.__pantheonFetch;
520
+ hosts.clear();
521
+ lastRelayRoute = null;
522
+ clock = opts.now ?? (() => Date.now());
523
+ if (originalFetch) {
524
+ globalThis.fetch = originalFetch;
525
+ originalFetch = null;
526
+ }
527
+ if (previousDispatcher) {
528
+ const g = globalThis;
529
+ if (previousDispatcher.v1 !== undefined)
530
+ g[DISPATCHER] = previousDispatcher.v1;
531
+ if (previousDispatcher.v2 !== undefined)
532
+ g[DISPATCHER2] = previousDispatcher.v2;
533
+ previousDispatcher = null;
534
+ }
341
535
  }
342
- export function explainNetError(e, plan = currentPlan()) {
343
- const err = e;
344
- const text = `${err?.code ?? ""} ${err?.message ?? ""} ${err?.cause?.code ?? ""} ${err?.cause?.message ?? ""}`;
345
- const proxy = plan.https ?? plan.http;
536
+ export function explainNetError(e, target, planArg) {
537
+ const plan = (typeof target === "object" ? target : planArg) ?? currentPlan();
538
+ const url = typeof target === "string" ? target : undefined;
539
+ const text = errText(e);
540
+ const proxy = url ? routeFor(url, plan) : planRoute(plan);
346
541
  const shown = proxy ? redactProxy(proxy) : "";
542
+ const host = (() => { try {
543
+ return url ? new URL(url).hostname || null : null;
544
+ }
545
+ catch {
546
+ return null;
547
+ } })() ?? "the relay's address";
347
548
  const orProxy = "Behind a company proxy? pantheon network --proxy http://host:port";
348
549
  if (/PROXY_AUTH_REQUIRED|\b407\b/.test(text)) {
349
550
  return { why: `the proxy ${shown} asked for a login`, fix: "put it in the proxy address: http://user:password@host:port", share: "proxy requires authentication" };
350
551
  }
351
552
  if (/PROXY_REFUSED/.test(text)) {
352
- return { why: `the proxy ${shown} would not open a connection to the relay`, fix: "ask whoever runs it to allow HTTPS to relay.joinpantheon.network", share: "proxy refused the tunnel" };
553
+ return { why: `the proxy ${shown} would not open a connection to the relay`, fix: `ask whoever runs it to allow HTTPS to ${host}`, share: "proxy refused the tunnel" };
353
554
  }
354
555
  if (/wrong version number|EPROTO|packet length too long|unknown protocol/i.test(text)) {
355
556
  return proxy
@@ -379,14 +580,14 @@ export function explainNetError(e, plan = currentPlan()) {
379
580
  }
380
581
  if (/ECONNRESET|socket hang up/i.test(text)) {
381
582
  return proxy
382
- ? { why: `the connection through the proxy ${shown} was cut`, fix: "the proxy may not allow WebSockets; ask for relay.joinpantheon.network to be allowed", share: "connection reset" }
583
+ ? { why: `the connection through the proxy ${shown} was cut`, fix: `the proxy may not allow WebSockets; ask for ${host} to be allowed`, share: "connection reset" }
383
584
  : { why: "the connection dropped before the relay answered", fix: "try again", share: "connection reset" };
384
585
  }
385
586
  return null;
386
587
  }
387
- export function describeNetError(e, plan) {
588
+ export function describeNetError(e, target, plan) {
388
589
  const raw = e?.message ?? String(e);
389
- const x = explainNetError(e, plan);
590
+ const x = explainNetError(e, target, plan);
390
591
  const first = (raw.split("\n")[0] ?? "").slice(0, 120);
391
592
  const fix = x ? x.fix.charAt(0).toUpperCase() + x.fix.slice(1) : "";
392
593
  return x ? `${x.why}. ${fix}. (${first})`.replace(/\.\. \(/, ". (").replace(/\?\. \(/, "? (") : raw;
package/dist/pending.js CHANGED
@@ -44,24 +44,30 @@ export function cursorSeq(session, cfg, home) {
44
44
  const entry = readCursors(cursorPath(home))[keyFor(c, session)];
45
45
  return entry?.seq;
46
46
  }
47
+ let clockSkewMs = 0;
47
48
  export async function reportActivity(opts) {
48
49
  const cfg = opts.cfg ?? load();
49
50
  if (!cfg)
50
51
  return false;
51
52
  const base = cfg.relay.replace(/^ws/, "http").replace(/\/+$/, "");
52
53
  const seen = opts.seen !== undefined ? String(opts.seen) : "";
53
- const ts = Date.now();
54
- const sig = signActivity(cfg.privateKey, cfg.space, cfg.member, opts.session, opts.state, seen, ts);
55
- const q = new URLSearchParams({
54
+ const query = (ts) => new URLSearchParams({
56
55
  space: cfg.space, token: cfg.token, member: cfg.member,
57
56
  session: opts.session, state: opts.state,
58
57
  ...(opts.seen !== undefined ? { seen } : {}),
59
- ts: String(ts), sig,
58
+ ts: String(ts), sig: signActivity(cfg.privateKey, cfg.space, cfg.member, opts.session, opts.state, seen, ts),
60
59
  });
61
60
  const ac = new AbortController();
62
61
  const timer = setTimeout(() => ac.abort(), LIMITS.pendingTimeoutMs);
63
62
  try {
64
- const res = await fetch(`${base}/activity?${q}`, { method: "POST", signal: ac.signal });
63
+ let res = await fetch(`${base}/activity?${query(Date.now() + clockSkewMs)}`, { method: "POST", signal: ac.signal });
64
+ if (res.status === 403) {
65
+ const there = Date.parse(res.headers.get("date") ?? "");
66
+ if (Number.isFinite(there) && Math.abs(there - Date.now() - clockSkewMs) > 60_000) {
67
+ clockSkewMs = there - Date.now();
68
+ res = await fetch(`${base}/activity?${query(Date.now() + clockSkewMs)}`, { method: "POST", signal: ac.signal });
69
+ }
70
+ }
65
71
  return res.ok;
66
72
  }
67
73
  catch {
@@ -108,7 +114,8 @@ export async function pending(opts) {
108
114
  .filter((r) => r.act !== "JOIN" && r.act !== "LEAVE")
109
115
  .filter((r) => !r.to || r.to.length === 0 || r.to.includes(cfg.member))
110
116
  .filter((r) => !mine.some((m) => m.seq > r.seq && (m.ref === r.id || (!!r.goal && m.goal === r.goal))));
111
- const seq = first
117
+ const rolledBack = !first && typeof head === "number" && head < known.seq;
118
+ const seq = first || rolledBack
112
119
  ? head
113
120
  : historyBody === null
114
121
  ? known.seq
@@ -118,7 +125,7 @@ export async function pending(opts) {
118
125
  if (!opts.peek && seq !== undefined) {
119
126
  const now = Date.now();
120
127
  const next = {};
121
- for (const [k, v] of Object.entries(cursors)) {
128
+ for (const [k, v] of Object.entries(readCursors(file))) {
122
129
  if (now - v.at < LIMITS.cursorTtlMs)
123
130
  next[k] = v;
124
131
  }