@mulmoclaude/core 1.8.0 → 1.10.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.
@@ -1,6 +1,6 @@
1
1
  import { t as errorMessage } from "../../dist-Cwk0e12G.js";
2
- import { buildHostPresence, byCreatedAt, commandsCollection, hostDoc, isExpired } from "../index.js";
3
- import { deleteDoc, getFirestore, onSnapshot, query, runTransaction, serverTimestamp, setDoc, updateDoc, where } from "firebase/firestore";
2
+ import { a as hostDoc, i as commandsCollection, n as buildHostPresence, o as isExpired, r as byCreatedAt } from "../../remote-host-D_BRFHcI.js";
3
+ import { deleteDoc, getDocFromServer, getFirestore, onSnapshot, query, runTransaction, serverTimestamp, setDoc, updateDoc, where } from "firebase/firestore";
4
4
  import { GoogleAuthProvider, getAuth, initializeAuth, signInWithCredential, signOut } from "firebase/auth";
5
5
  import { deleteApp, initializeApp } from "firebase/app";
6
6
  import { getStorage } from "firebase/storage";
@@ -9,25 +9,36 @@ var isPlainObject = (value) => {
9
9
  return proto === Object.prototype || proto === null;
10
10
  };
11
11
  var isWalkable = (value) => typeof value === "object" && value !== null && (Array.isArray(value) || isPlainObject(value));
12
- /** Every path holding `undefined`, in `a.b.0.c` form. Empty when the value is safe to write. */
13
- var undefinedPaths = (value, prefix = "") => {
12
+ var childPath = (prefix, key) => prefix ? `${prefix}.${key}` : String(key);
13
+ var withCycleGuard = (value, ancestors, path, walk) => {
14
+ if (ancestors.has(value)) throw new Error(`circular reference at ${path || "(root)"} — Firestore cannot serialise it`);
15
+ ancestors.add(value);
16
+ try {
17
+ return walk();
18
+ } finally {
19
+ ancestors.delete(value);
20
+ }
21
+ };
22
+ var pathsIn = (value, prefix, ancestors) => {
14
23
  if (value === void 0) return [prefix || "(root)"];
15
24
  if (!isWalkable(value)) return [];
16
- const child = (key) => prefix ? `${prefix}.${key}` : String(key);
17
- if (Array.isArray(value)) return Array.from(value).flatMap((item, index) => undefinedPaths(item, child(index)));
18
- return Object.entries(value).flatMap(([key, item]) => undefinedPaths(item, child(key)));
25
+ return withCycleGuard(value, ancestors, prefix, () => Array.isArray(value) ? Array.from(value).flatMap((item, index) => pathsIn(item, childPath(prefix, index), ancestors)) : Object.entries(value).flatMap(([key, item]) => pathsIn(item, childPath(prefix, key), ancestors)));
26
+ };
27
+ /** Every path holding `undefined`, in `a.b.0.c` form. Empty when the value is safe to write.
28
+ * Throws on a circular reply, naming the path it closed the loop at. */
29
+ var undefinedPaths = (value, prefix = "") => pathsIn(value, prefix, /* @__PURE__ */ new WeakSet());
30
+ var strippedIn = (value, prefix, ancestors) => {
31
+ if (value === void 0) return null;
32
+ if (!isWalkable(value)) return value;
33
+ return withCycleGuard(value, ancestors, prefix, () => Array.isArray(value) ? Array.from(value, (item, index) => strippedIn(item, childPath(prefix, index), ancestors)) : Object.fromEntries(Object.entries(value).flatMap(([key, item]) => item === void 0 ? [] : [[key, strippedIn(item, childPath(prefix, key), ancestors)]])));
19
34
  };
20
35
  /**
21
36
  * The same value with every `undefined` removed — object keys dropped, array holes
22
37
  * turned into `null` so the surrounding indexes still line up with what the sender
23
38
  * meant. Returns `unknown` because a stripped object is no longer the input type.
39
+ * Throws on a circular reply, same as `undefinedPaths`.
24
40
  */
25
- var stripUndefined = (value) => {
26
- if (value === void 0) return null;
27
- if (!isWalkable(value)) return value;
28
- if (Array.isArray(value)) return Array.from(value, (item) => stripUndefined(item));
29
- return Object.fromEntries(Object.entries(value).flatMap(([key, item]) => item === void 0 ? [] : [[key, stripUndefined(item)]]));
30
- };
41
+ var stripUndefined = (value) => strippedIn(value, "", /* @__PURE__ */ new WeakSet());
31
42
  /** Does `path` match `pattern`, where `*` stands for exactly one segment?
32
43
  * Segment-wise rather than by regex: no escaping of user-supplied dots, and no
33
44
  * pattern can turn into a catastrophic backtrack. */
@@ -49,6 +60,42 @@ var matchesPathPattern = (path, pattern) => {
49
60
  */
50
61
  var unexpectedPaths = (paths, expected = []) => paths.filter((path) => !expected.some((pattern) => matchesPathPattern(path, pattern)));
51
62
  //#endregion
63
+ //#region src/remote-host/server/presenceBeat.ts
64
+ var PRESENCE_STALE_BEATS = 3;
65
+ var errorText$1 = (error) => error instanceof Error ? error.message : String(error);
66
+ var notify = (report, onThrow) => {
67
+ try {
68
+ report();
69
+ } catch (error) {
70
+ onThrow?.(error);
71
+ }
72
+ };
73
+ var createPresenceBeat = (deps) => {
74
+ const now = deps.now ?? Date.now;
75
+ const state = { lastAckMs: now() };
76
+ const announce = (online) => {
77
+ const fail = (error) => notify(() => deps.onError(errorText$1(error)));
78
+ const ack = () => notify(() => {
79
+ state.lastAckMs = now();
80
+ });
81
+ notify(() => {
82
+ deps.write(online).then(ack, fail);
83
+ }, fail);
84
+ };
85
+ const beat = () => {
86
+ const silentMs = now() - state.lastAckMs;
87
+ if (silentMs >= deps.staleAfterMs) {
88
+ notify(() => deps.onStale(silentMs));
89
+ return;
90
+ }
91
+ announce(true);
92
+ };
93
+ return {
94
+ announce,
95
+ beat
96
+ };
97
+ };
98
+ //#endregion
52
99
  //#region src/remote-host/server/hostRunner.ts
53
100
  var DEFAULT_HEARTBEAT_MS = 6e4;
54
101
  var TRANSIENT_LISTEN_ERROR_CODES = /* @__PURE__ */ new Set([
@@ -57,6 +104,7 @@ var TRANSIENT_LISTEN_ERROR_CODES = /* @__PURE__ */ new Set([
57
104
  "deadline-exceeded",
58
105
  "internal",
59
106
  "resource-exhausted",
107
+ "unauthenticated",
60
108
  "unavailable"
61
109
  ]);
62
110
  var isRecord$1 = (value) => typeof value === "object" && value !== null;
@@ -64,6 +112,8 @@ var listenErrorCode = (error) => isRecord$1(error) && typeof error.code === "str
64
112
  var classifyListenerError = (error) => TRANSIENT_LISTEN_ERROR_CODES.has(listenErrorCode(error)) ? "transient" : "fatal";
65
113
  var BASE_LISTEN_RETRY_MS = 1e3;
66
114
  var MAX_LISTEN_RETRY_MS = 3e4;
115
+ var LISTEN_RETRY_WINDOW_MS = 5 * 6e4;
116
+ var shouldGiveUpListening = (downSinceMs, now, windowMs = LISTEN_RETRY_WINDOW_MS) => now - downSinceMs >= windowMs;
67
117
  var backoffDelayMs = (attempt) => Math.min(MAX_LISTEN_RETRY_MS, BASE_LISTEN_RETRY_MS * 2 ** attempt);
68
118
  var noop$2 = () => void 0;
69
119
  var writeError = (ref, code, message) => updateDoc(ref, {
@@ -187,7 +237,9 @@ function handleListenError(run, error) {
187
237
  message: error.message
188
238
  });
189
239
  if (run.stopped) return;
190
- if (classifyListenerError(error) === "fatal" || run.attempt >= 5) {
240
+ const now = Date.now();
241
+ run.downSinceMs ??= now;
242
+ if (classifyListenerError(error) === "fatal" || shouldGiveUpListening(run.downSinceMs, now)) {
191
243
  run.goOffline();
192
244
  return;
193
245
  }
@@ -198,6 +250,7 @@ function subscribeCommands(run) {
198
250
  if (run.stopped) return;
199
251
  run.unsubscribe = onSnapshot(run.queuedCommands, (snapshot) => {
200
252
  run.attempt = 0;
253
+ run.downSinceMs = null;
201
254
  dispatchAddedCommands(run.ctx, snapshot);
202
255
  }, (error) => handleListenError(run, error));
203
256
  }
@@ -209,7 +262,8 @@ var listenForCommands = (queuedCommands, ctx, goOffline) => {
209
262
  stopped: false,
210
263
  unsubscribe: noop$2,
211
264
  retryTimer: null,
212
- attempt: 0
265
+ attempt: 0,
266
+ downSinceMs: null
213
267
  };
214
268
  subscribeCommands(run);
215
269
  return () => {
@@ -218,36 +272,265 @@ var listenForCommands = (queuedCommands, ctx, goOffline) => {
218
272
  run.unsubscribe();
219
273
  };
220
274
  };
221
- var startHostRunner = (firestore, channel, handlers, options = {}) => {
275
+ var heartbeatMs = (options) => options.heartbeatMs ?? 6e4;
276
+ var presenceStaleAfterMs = (options = {}) => heartbeatMs(options) * 3;
277
+ var buildPresenceBeat = (firestore, channel, handlers, options, onStale) => {
222
278
  const presence = hostDoc(firestore, channel);
223
- const writePresence = (online) => {
224
- setDoc(presence, {
279
+ const report = (message) => options.onEvent?.({
280
+ phase: "error",
281
+ method: "presence",
282
+ message
283
+ });
284
+ return createPresenceBeat({
285
+ write: (online) => setDoc(presence, {
225
286
  ...buildHostPresence(channel, handlers, online),
226
287
  updatedAt: serverTimestamp()
227
- }).catch(noop$2);
228
- };
229
- const announce = () => {
230
- writePresence(true);
288
+ }),
289
+ onError: (message) => report(`presence write failed: ${message}`),
290
+ onStale: (silentMs) => {
291
+ report(`no presence write acknowledged for ${Math.round(silentMs / 1e3)}s — the remote cannot see this host`);
292
+ onStale();
293
+ },
294
+ staleAfterMs: presenceStaleAfterMs(options)
295
+ });
296
+ };
297
+ var shutDown = (run, presence) => {
298
+ if (run.closed) return;
299
+ run.closed = true;
300
+ if (run.beat) clearInterval(run.beat);
301
+ run.beat = null;
302
+ presence.announce(false);
303
+ run.stopListening();
304
+ };
305
+ var startHostRunner = (firestore, channel, handlers, options = {}) => {
306
+ const run = {
307
+ beat: null,
308
+ stopListening: noop$2,
309
+ closed: false
231
310
  };
232
- announce();
233
- const beat = setInterval(announce, options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS);
234
- const queuedCommands = query(commandsCollection(firestore, channel), where("status", "==", "queued"));
235
- const ctx = {
311
+ const presence = buildPresenceBeat(firestore, channel, handlers, options, goOffline);
312
+ function goOffline() {
313
+ if (run.closed) return;
314
+ shutDown(run, presence);
315
+ options.onClosed?.();
316
+ }
317
+ presence.announce(true);
318
+ run.beat = setInterval(presence.beat, heartbeatMs(options));
319
+ run.stopListening = listenForCommands(query(commandsCollection(firestore, channel), where("status", "==", "queued")), {
236
320
  firestore,
237
321
  handlers,
238
322
  options,
239
323
  uid: channel.uid
324
+ }, goOffline);
325
+ return () => shutDown(run, presence);
326
+ };
327
+ //#endregion
328
+ //#region src/remote-host/server/resilientRunner.ts
329
+ var ONE_SECOND_MS$1 = 1e3;
330
+ var ONE_MINUTE_MS = 60 * ONE_SECOND_MS$1;
331
+ var RECONNECT_BASE_MS = ONE_SECOND_MS$1;
332
+ var RECONNECT_MAX_MS = ONE_MINUTE_MS;
333
+ var SETTLE_MS = ONE_MINUTE_MS;
334
+ var GIVE_UP_MS = 5 * ONE_MINUTE_MS;
335
+ var PROBE_INTERVAL_MS = 90 * ONE_SECOND_MS$1;
336
+ var reconnectDelayMs = (attempt) => Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** attempt);
337
+ var errorText = (error) => error instanceof Error ? error.message : String(error);
338
+ var scheduleWithTimeout = (task, delayMs) => {
339
+ const timer = setTimeout(task, delayMs);
340
+ return () => clearTimeout(timer);
341
+ };
342
+ var setState = (ctx, next) => {
343
+ ctx.state = next;
344
+ try {
345
+ ctx.deps.onHealth?.({
346
+ state: next,
347
+ lastError: ctx.lastError,
348
+ changedAt: ctx.now()
349
+ });
350
+ } catch (error) {
351
+ ctx.deps.log.warn(`host runner health observer threw: ${errorText(error)}`);
352
+ }
353
+ };
354
+ var clearTimer = (ctx) => {
355
+ ctx.cancelTimer?.();
356
+ ctx.cancelTimer = null;
357
+ };
358
+ var clearProbe = (ctx) => {
359
+ ctx.cancelProbe?.();
360
+ ctx.cancelProbe = null;
361
+ };
362
+ function scheduleProbe(ctx) {
363
+ if (ctx.stopped || !ctx.deps.checkAlive) return;
364
+ clearProbe(ctx);
365
+ ctx.cancelProbe = ctx.schedule(() => void runProbe(ctx), PROBE_INTERVAL_MS);
366
+ }
367
+ var STALE_REASON = "presence went stale — the phone can no longer see this host";
368
+ async function askAlive(ctx) {
369
+ try {
370
+ return {
371
+ alive: await ctx.deps.checkAlive?.() ?? null,
372
+ reason: STALE_REASON
373
+ };
374
+ } catch (error) {
375
+ return {
376
+ alive: false,
377
+ reason: `presence probe failed: ${errorText(error)}`
378
+ };
379
+ }
380
+ }
381
+ async function runProbe(ctx) {
382
+ ctx.cancelProbe = null;
383
+ if (ctx.stopped || ctx.state !== "online") return;
384
+ const answer = await askAlive(ctx);
385
+ if (ctx.stopped || ctx.state !== "online") return;
386
+ if (answer.alive === false) {
387
+ ctx.lastError = answer.reason;
388
+ ctx.deps.log.warn(`host runner ${answer.reason}`);
389
+ onUnderlyingClosed(ctx);
390
+ return;
391
+ }
392
+ scheduleProbe(ctx);
393
+ }
394
+ var releaseUnderlying = (ctx) => {
395
+ const stop = ctx.stopUnderlying;
396
+ ctx.stopUnderlying = null;
397
+ try {
398
+ stop?.();
399
+ } catch (error) {
400
+ ctx.deps.log.warn(`host runner teardown failed: ${errorText(error)}`);
401
+ }
402
+ };
403
+ var markRecovered = (ctx) => {
404
+ ctx.downSinceMs = null;
405
+ ctx.attempt = 0;
406
+ ctx.lastError = null;
407
+ if (ctx.state === "online") return;
408
+ ctx.deps.log.info("host runner re-subscribed");
409
+ setState(ctx, "online");
410
+ scheduleProbe(ctx);
411
+ };
412
+ async function settle(ctx) {
413
+ ctx.cancelTimer = null;
414
+ if (ctx.stopped) return;
415
+ const answer = await askAlive(ctx);
416
+ if (ctx.stopped) return;
417
+ if (answer.alive === false) {
418
+ ctx.lastError = answer.reason;
419
+ ctx.deps.log.warn(`host runner is up but still unreachable — ${answer.reason}`);
420
+ onUnderlyingClosed(ctx);
421
+ return;
422
+ }
423
+ markRecovered(ctx);
424
+ }
425
+ var giveUp = (ctx) => {
426
+ ctx.stopped = true;
427
+ clearProbe(ctx);
428
+ ctx.deps.log.warn(`host runner stayed down for ${Math.round(GIVE_UP_MS / ONE_SECOND_MS$1)}s, giving up (${ctx.lastError ?? "no error reported"})`);
429
+ setState(ctx, "offline");
430
+ ctx.deps.options.onClosed?.();
431
+ };
432
+ function scheduleRelaunch(ctx) {
433
+ const delayMs = reconnectDelayMs(ctx.attempt);
434
+ ctx.attempt += 1;
435
+ ctx.deps.log.warn(`host runner closed (${ctx.lastError ?? "no error reported"}); re-subscribing in ${Math.round(delayMs / ONE_SECOND_MS$1)}s`);
436
+ if (ctx.state !== "reconnecting") setState(ctx, "reconnecting");
437
+ ctx.cancelTimer = ctx.schedule(() => launch(ctx), delayMs);
438
+ }
439
+ function onUnderlyingClosed(ctx) {
440
+ if (ctx.stopped) return;
441
+ releaseUnderlying(ctx);
442
+ clearTimer(ctx);
443
+ clearProbe(ctx);
444
+ ctx.downSinceMs ??= ctx.now();
445
+ if (ctx.now() - ctx.downSinceMs >= GIVE_UP_MS) giveUp(ctx);
446
+ else scheduleRelaunch(ctx);
447
+ }
448
+ var CHANNEL_METHODS = /* @__PURE__ */ new Set(["listen", "presence"]);
449
+ var runnerOptions = (ctx) => ({
450
+ ...ctx.deps.options,
451
+ onEvent: (event) => {
452
+ if (event.phase === "error") {
453
+ const text = `${event.method}: ${event.message ?? "no detail"}`;
454
+ if (CHANNEL_METHODS.has(event.method)) ctx.lastError = text;
455
+ ctx.deps.log.warn(`host runner event error — ${text}`);
456
+ }
457
+ ctx.deps.options.onEvent?.(event);
458
+ },
459
+ onClosed: () => onUnderlyingClosed(ctx)
460
+ });
461
+ function launch(ctx) {
462
+ ctx.cancelTimer = null;
463
+ if (ctx.stopped) return;
464
+ try {
465
+ ctx.stopUnderlying = ctx.deps.start(runnerOptions(ctx));
466
+ } catch (error) {
467
+ ctx.lastError = errorText(error);
468
+ onUnderlyingClosed(ctx);
469
+ return;
470
+ }
471
+ ctx.cancelTimer = ctx.schedule(() => void settle(ctx), SETTLE_MS);
472
+ }
473
+ function startResilientHostRunner(deps) {
474
+ const ctx = {
475
+ deps,
476
+ schedule: deps.schedule ?? scheduleWithTimeout,
477
+ now: deps.now ?? Date.now,
478
+ stopUnderlying: null,
479
+ cancelTimer: null,
480
+ attempt: 0,
481
+ downSinceMs: null,
482
+ lastError: null,
483
+ state: "online",
484
+ stopped: false,
485
+ cancelProbe: null
240
486
  };
241
- const goOffline = () => {
242
- clearInterval(beat);
243
- writePresence(false);
244
- options.onClosed?.();
245
- };
246
- const stopListening = listenForCommands(queuedCommands, ctx, goOffline);
487
+ setState(ctx, "online");
488
+ launch(ctx);
489
+ scheduleProbe(ctx);
247
490
  return () => {
248
- clearInterval(beat);
249
- writePresence(false);
250
- stopListening();
491
+ ctx.stopped = true;
492
+ clearTimer(ctx);
493
+ clearProbe(ctx);
494
+ releaseUnderlying(ctx);
495
+ };
496
+ }
497
+ //#endregion
498
+ //#region src/remote-host/server/presenceProbe.ts
499
+ var ONE_SECOND_MS = 1e3;
500
+ var PRESENCE_STALE_MS = presenceStaleAfterMs();
501
+ var PROBE_TIMEOUT_MS = 30 * ONE_SECOND_MS;
502
+ var hasToMillis = (value) => "toMillis" in value && typeof Reflect.get(value, "toMillis") === "function";
503
+ var asMillis = (value) => {
504
+ if (typeof value === "number") return value;
505
+ if (typeof value === "object" && value !== null && hasToMillis(value)) return value.toMillis();
506
+ return null;
507
+ };
508
+ /** Judge a presence document that was read successfully. Exported for its own test:
509
+ * the freshness rule is the part worth pinning, and it needs no Firestore. */
510
+ var presenceIsFresh = (data, now, staleAfterMs = PRESENCE_STALE_MS) => {
511
+ if (!data) return null;
512
+ const updatedAt = asMillis(data.updatedAt);
513
+ if (updatedAt === null) return null;
514
+ if (data.online === false) return null;
515
+ return now - updatedAt < staleAfterMs;
516
+ };
517
+ /** Reject once `timeoutMs` has passed, so a stalled read answers instead of hanging. */
518
+ var withTimeout = async (work, timeoutMs) => {
519
+ const timer = { handle: null };
520
+ const deadline = new Promise((_, reject) => {
521
+ timer.handle = setTimeout(() => reject(/* @__PURE__ */ new Error(`presence read did not answer within ${Math.round(timeoutMs / ONE_SECOND_MS)}s`)), timeoutMs);
522
+ });
523
+ try {
524
+ return await Promise.race([work, deadline]);
525
+ } finally {
526
+ if (timer.handle) clearTimeout(timer.handle);
527
+ }
528
+ };
529
+ /** Reads the host's own presence document from the server and reports whether it is fresh. */
530
+ var createPresenceProbe = (deps) => {
531
+ const now = deps.now ?? Date.now;
532
+ return async () => {
533
+ return presenceIsFresh((await withTimeout(getDocFromServer(hostDoc(deps.firestore(), deps.channel)), deps.timeoutMs ?? PROBE_TIMEOUT_MS)).data(), now(), deps.staleAfterMs ?? PRESENCE_STALE_MS);
251
534
  };
252
535
  };
253
536
  //#endregion
@@ -258,6 +541,7 @@ var silentLogger = {
258
541
  warn: noop$1,
259
542
  debug: noop$1
260
543
  };
544
+ var eventText = (event) => event.message ? `${event.phase} ${event.method} — ${event.message}` : `${event.phase} ${event.method}`;
261
545
  var createRemoteHost = (deps) => {
262
546
  const log = deps.log ?? silentLogger;
263
547
  let stopRunner = null;
@@ -283,7 +567,7 @@ var createRemoteHost = (deps) => {
283
567
  uid,
284
568
  hostId: deps.hostId
285
569
  }, deps.handlers, {
286
- onEvent: (event) => log.debug(`host event: ${event.phase} ${event.method}`),
570
+ onEvent: (event) => log.debug(`host event: ${eventText(event)}`),
287
571
  onExpire: deps.onExpire,
288
572
  onClosed: () => {
289
573
  if (stopRunner === runner) {
@@ -480,6 +764,6 @@ var createRemoteHostSession = (config) => {
480
764
  };
481
765
  };
482
766
  //#endregion
483
- export { createHostSessionPersistence, createRemoteHost, createRemoteHostAuth, createRemoteHostFirebase, createRemoteHostSession, isSeedableBlob, startHostRunner, stripUndefined, undefinedPaths, unexpectedPaths };
767
+ export { DEFAULT_HEARTBEAT_MS, LISTEN_RETRY_WINDOW_MS, PRESENCE_STALE_BEATS, PRESENCE_STALE_MS, createHostSessionPersistence, createPresenceProbe, createRemoteHost, createRemoteHostAuth, createRemoteHostFirebase, createRemoteHostSession, isSeedableBlob, presenceIsFresh, presenceStaleAfterMs, reconnectDelayMs, startHostRunner, startResilientHostRunner, stripUndefined, undefinedPaths, unexpectedPaths, withTimeout };
484
768
 
485
769
  //# sourceMappingURL=index.js.map