@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.
- package/dist/remote-host/health.d.ts +13 -0
- package/dist/remote-host/index.cjs +11 -30
- package/dist/remote-host/index.d.ts +2 -0
- package/dist/remote-host/index.js +2 -24
- package/dist/remote-host/server/firestoreSafeResult.d.ts +3 -1
- package/dist/remote-host/server/hostRunner.d.ts +4 -1
- package/dist/remote-host/server/index.cjs +333 -39
- package/dist/remote-host/server/index.cjs.map +1 -1
- package/dist/remote-host/server/index.d.ts +6 -1
- package/dist/remote-host/server/index.js +321 -37
- package/dist/remote-host/server/index.js.map +1 -1
- package/dist/remote-host/server/presenceBeat.d.ts +18 -0
- package/dist/remote-host/server/presenceProbe.d.ts +23 -0
- package/dist/remote-host/server/resilientRunner.d.ts +24 -0
- package/dist/remote-host-D_BRFHcI.js +36 -0
- package/dist/remote-host-D_BRFHcI.js.map +1 -0
- package/dist/remote-host-DkDVxNim.cjs +95 -0
- package/dist/remote-host-DkDVxNim.cjs.map +1 -0
- package/package.json +2 -2
- package/dist/remote-host/index.cjs.map +0 -1
- package/dist/remote-host/index.js.map +0 -1
|
@@ -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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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 {
|
|
2
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
14
|
-
var
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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 (
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
})
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
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
|