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