@lensmcp/cluster 1.0.0 → 1.2.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.
@@ -64,6 +64,17 @@ export interface ClusterProjectDecl {
64
64
  serveTarget?: string;
65
65
  /** Devserver admin endpoint for scaling (default from port: http(s)://localhost:<port>). */
66
66
  adminUrl?: string;
67
+ /**
68
+ * FRONTEND apps only: run this app UNDER the lens. When true, the gateway
69
+ * SPAWNS the app's own Vite dev server with `@lensmcp/vite-plugin`
70
+ * instrumentation + a browser-capture sidecar (via the shared
71
+ * `spawnLensFrontend` helper) and routes THAT as the upstream — instead of
72
+ * assuming an external Vite already runs on `port`/`upstream`. The
73
+ * cluster-wide singletons (dashboard + MCP) are owned by the gateway, so a
74
+ * `lens:true` app never spawns its own. Absent/false ⇒ today's behavior
75
+ * (plain TCP upstream; bring-your-own Vite). Ignored for pod (`service`) decls.
76
+ */
77
+ lens?: boolean;
67
78
  }
68
79
  export interface SockPool {
69
80
  dir: string;
@@ -81,6 +92,22 @@ export interface Route {
81
92
  svc?: ServiceCtl;
82
93
  internal?: boolean;
83
94
  auth?: AuthPolicy;
95
+ /** Frontend `lens:true` route: the gateway spawns this app's Vite + lens
96
+ * instrumentation as the upstream (see `LensFrontend`). */
97
+ lens?: LensFrontend;
98
+ }
99
+ /** A frontend app the gateway runs UNDER the lens (`cluster.lens: true`). The
100
+ * gateway spawns its Vite + browser capture as the upstream, deterministically
101
+ * on `port`, and never spawns the per-app dashboard/MCP (those are singletons
102
+ * the gateway owns). */
103
+ export interface LensFrontend {
104
+ project: string;
105
+ /** Absolute project root (holds vite.config.*). */
106
+ projectRoot: string;
107
+ /** The pinned Vite dev port (also the upstream target's port). */
108
+ port: number;
109
+ /** Set once the Vite child has been spawned (idempotency guard). */
110
+ started?: boolean;
84
111
  }
85
112
  export interface ServiceCtl {
86
113
  project: string;
@@ -113,6 +140,11 @@ export interface GatewayHandle {
113
140
  services: ServiceCtl[];
114
141
  stop: () => Promise<void>;
115
142
  }
143
+ /** The base domain for the lens dashboard host — the longest common dotted
144
+ * suffix of the declared cluster hosts (e.g. ['api.tetros.ai.local',
145
+ * 'tetros.ai.local'] → 'tetros.ai.local'). Falls back to the first host, then
146
+ * to 'lens.local' if no host is declared. */
147
+ export declare function baseDomainOf(hosts: string[]): string;
116
148
  /** Round-robin over the pool's sockets, rescanning the dir when the cache ages out. */
117
149
  export declare function pickSock(pool: SockPool, scanTtlMs?: number): string | undefined;
118
150
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"gateway.lib.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/gateway.lib.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"gateway.lib.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/gateway.lib.ts"],"names":[],"mappings":"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"}
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.hostMatches = void 0;
4
4
  exports.verifyDevJwt = verifyDevJwt;
5
5
  exports.stampIdentity = stampIdentity;
6
+ exports.baseDomainOf = baseDomainOf;
6
7
  exports.pickSock = pickSock;
7
8
  exports.discoverRoutes = discoverRoutes;
8
9
  exports.startGateway = startGateway;
@@ -19,6 +20,7 @@ const http = tslib_1.__importStar(require("node:http"));
19
20
  const os = tslib_1.__importStar(require("node:os"));
20
21
  const path = tslib_1.__importStar(require("node:path"));
21
22
  const manifest_1 = require("./manifest");
23
+ const lens_frontend_1 = require("@lensmcp/nx-plugin/lens-frontend");
22
24
  var manifest_2 = require("./manifest"); // back-compat re-export
23
25
  Object.defineProperty(exports, "hostMatches", { enumerable: true, get: function () { return manifest_2.hostMatches; } });
24
26
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -71,6 +73,30 @@ const devAttrs = (req, claims) => ({
71
73
  path: (req.url ?? '/').split('?')[0],
72
74
  });
73
75
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
76
+ /** Extract the port from an `upstream` URL like `http://localhost:5173`. */
77
+ function lensPortFromUpstream(upstream) {
78
+ if (!upstream)
79
+ return undefined;
80
+ const m = /:(\d+)/.exec(upstream);
81
+ return m ? Number(m[1]) : undefined;
82
+ }
83
+ /** The base domain for the lens dashboard host — the longest common dotted
84
+ * suffix of the declared cluster hosts (e.g. ['api.tetros.ai.local',
85
+ * 'tetros.ai.local'] → 'tetros.ai.local'). Falls back to the first host, then
86
+ * to 'lens.local' if no host is declared. */
87
+ function baseDomainOf(hosts) {
88
+ const clean = hosts.filter((h) => !!h && !h.startsWith('*.'));
89
+ if (clean.length === 0)
90
+ return 'local';
91
+ // Use the SHORTEST host as the candidate base (most likely the apex), then
92
+ // require every other host to end with it.
93
+ const shortest = clean.reduce((a, b) => (b.length < a.length ? b : a));
94
+ if (clean.every((h) => h === shortest || h.endsWith('.' + shortest)))
95
+ return shortest;
96
+ // No clean common apex — derive the trailing 2-3 labels of the shortest host.
97
+ const parts = shortest.split('.');
98
+ return parts.slice(-Math.min(parts.length, 3)).join('.');
99
+ }
74
100
  /** Round-robin over the pool's sockets, rescanning the dir when the cache ages out. */
75
101
  function pickSock(pool, scanTtlMs = 1500) {
76
102
  const now = Date.now();
@@ -113,11 +139,24 @@ function discoverRoutes(rootDir, projects, sockBaseDir = os.tmpdir()) {
113
139
  let pool;
114
140
  let svc;
115
141
  let target;
142
+ let lens;
116
143
  if (decl.service) {
144
+ // `lens` applies to FRONTEND apps only; a pod service is already
145
+ // instrumented by the serve executor's node-instrumentation graft.
146
+ if (decl.lens)
147
+ console.warn(`[gateway] ${name}: cluster.lens is ignored for pod (service) decls — backends are instrumented by serve-hmr.`);
117
148
  pool = { dir: path.join(sockBaseDir, `${decl.service}-devserver`), socks: [], idx: -1, scannedAt: 0 };
118
149
  svc = { project: name, decl, pool, state: 'down', lastUsed: Date.now(), inflight: 0, lastScaleAt: 0 };
119
150
  services.push(svc);
120
151
  }
152
+ else if (decl.lens) {
153
+ // Lens-mode frontend: the gateway SPAWNS this app's Vite + instrumentation
154
+ // (deterministically on `port`) and upstreams to it. One LensFrontend per
155
+ // project is SHARED across its host/default routes (so the spawn fires once).
156
+ const port = decl.port ?? lensPortFromUpstream(decl.upstream) ?? 5173;
157
+ target = `http://localhost:${port}`;
158
+ lens = { project: name, projectRoot: path.join(rootDir, p.root), port };
159
+ }
121
160
  else {
122
161
  target = decl.upstream ?? (decl.port ? `http://localhost:${decl.port}` : undefined);
123
162
  if (!target) {
@@ -125,7 +164,7 @@ function discoverRoutes(rootDir, projects, sockBaseDir = os.tmpdir()) {
125
164
  continue;
126
165
  }
127
166
  }
128
- const base = { project: name, prependPrefix: decl.prependPrefix, target, pool, svc, prefix: decl.path, auth: decl.auth };
167
+ const base = { project: name, prependPrefix: decl.prependPrefix, target, pool, svc, prefix: decl.path, auth: decl.auth, lens };
129
168
  if (decl.host) {
130
169
  routes.push({ ...base, host: decl.host });
131
170
  // internal.<host>: east-west traffic, same pods, NO middleware.
@@ -222,6 +261,104 @@ async function startGateway(options, context) {
222
261
  if (routes.length === 0) {
223
262
  throw new Error('[gateway] no project declares a `cluster` field — nothing to route.');
224
263
  }
264
+ // --- lens-mode children: the dashboard + MCP SINGLETONS, and `lens:true` apps ---
265
+ // The gateway owns ONE dashboard and ONE MCP server cluster-wide (each
266
+ // `lens:true` app runs only its Vite + instrumentation, publishing to the
267
+ // SHARED event bus — never its own dashboard/MCP, which would collide on
268
+ // :4321 / :3000). These children share the pods' lifecycle: best-effort
269
+ // (a crash doesn't tear the gateway down) and reaped on stop().
270
+ const lensChildren = [];
271
+ const lensRespawn = new Map();
272
+ // A `SpawnChild` over the gateway's managed-children array — the SAME shape
273
+ // `agent-dev` passes to `spawnLensFrontend`, so the helper behaves identically.
274
+ const spawnManagedChild = (label, bin, args, env, optional = false, respawn) => {
275
+ const child = (0, node_child_process_1.spawn)(bin, args, { cwd: context.root, env: { ...process.env, ...(env ?? {}) }, stdio: 'inherit' });
276
+ lensChildren.push(child);
277
+ child.on('exit', (code, sig) => {
278
+ if (stopped)
279
+ return;
280
+ if (optional && respawn) {
281
+ const used = lensRespawn.get(label) ?? 0;
282
+ if (used < respawn.max) {
283
+ lensRespawn.set(label, used + 1);
284
+ console.warn(`[gateway] lens child ${label} exited (code=${code} sig=${sig}); respawning in ${Math.round(respawn.delayMs / 1000)}s (${used + 1}/${respawn.max}).`);
285
+ const t = setTimeout(() => { if (!stopped)
286
+ spawnManagedChild(label, bin, args, env, optional, respawn); }, respawn.delayMs);
287
+ t.unref?.();
288
+ return;
289
+ }
290
+ }
291
+ // Singletons (dashboard/MCP) and exhausted-respawn children just log —
292
+ // a dead dashboard must never kill the cluster.
293
+ console.warn(`[gateway] lens child ${label} exited (code=${code} sig=${sig}); continuing.`);
294
+ });
295
+ return child;
296
+ };
297
+ const lensApps = [...new Map(routes.filter((r) => r.lens).map((r) => [r.project, r.lens])).values()];
298
+ const wantDashboard = options.dashboard !== false;
299
+ const wantMcp = options.mcp !== false && lensApps.length > 0;
300
+ if (wantDashboard) {
301
+ const dashboardBundle = (0, lens_frontend_1.findDashboardBundle)(context.root);
302
+ if (!dashboardBundle) {
303
+ console.warn("[gateway] lens dashboard bundle not found — skipping. Install the self-contained CLI: `npm i -D lensmcp` (it ships `bundled/dashboard.js`), or `yarn nx build @lensmcp/lensmcp-mcp` in-repo.");
304
+ }
305
+ else {
306
+ const dashboardPort = options.dashboardPort ?? 4321;
307
+ const declaredHosts = routes.map((r) => r.host).filter((h) => !!h);
308
+ const lensHost = options.lensHost ?? `lens.${baseDomainOf(declaredHosts)}`;
309
+ // Route the dashboard at lens.<baseDomain> (plain TCP upstream — the
310
+ // dashboard serves HTTP same-origin JSON; the gateway terminates TLS).
311
+ // Pushed BEFORE the cert SANs are derived (routes.map(r=>r.host)).
312
+ routes.push({ host: lensHost, project: '(lens-dashboard)', target: `http://localhost:${dashboardPort}` });
313
+ (0, manifest_1.sortBySpecificity)(routes);
314
+ console.log(`[gateway] starting lens dashboard → http://localhost:${dashboardPort} (routed at ${lensHost})`);
315
+ emit('info', `lens dashboard up: ${lensHost} → :${dashboardPort}`, 'cluster-lens-dashboard', { kind: 'lens-dashboard-up', host: lensHost, port: dashboardPort });
316
+ spawnManagedChild('lens-dashboard', process.execPath, [dashboardBundle], {
317
+ LENSMCP_EVENT_FILE: eventFile,
318
+ LENSMCP_DASHBOARD_PORT: String(dashboardPort),
319
+ });
320
+ }
321
+ }
322
+ if (wantMcp) {
323
+ const mcpBundle = (0, lens_frontend_1.findMcpBundle)(context.root);
324
+ if (!mcpBundle) {
325
+ console.warn("[gateway] MCP server bundle not found — skipping (lens apps will still publish to the bus; an external `lensmcp mcp` can read it). Install: `npm i -D lensmcp`.");
326
+ }
327
+ else {
328
+ console.log(`[gateway] starting lens MCP server (${mcpBundle})`);
329
+ spawnManagedChild('lens-mcp', process.execPath, [mcpBundle], {
330
+ LENSMCP_EVENT_FILE: eventFile,
331
+ LENSMCP_TRANSPORT: process.env['LENSMCP_TRANSPORT'] ?? 'http',
332
+ LENSMCP_PORT: process.env['LENSMCP_PORT'] ?? '3000',
333
+ });
334
+ }
335
+ }
336
+ // Spawn each `lens:true` frontend's Vite + browser capture as the upstream,
337
+ // via the SAME shared helper `agent-dev` uses (singletons excluded — owned above).
338
+ for (const app of lensApps) {
339
+ if (app.started)
340
+ continue;
341
+ app.started = true;
342
+ try {
343
+ (0, lens_frontend_1.spawnLensFrontend)({
344
+ projectRoot: app.projectRoot,
345
+ workspaceRoot: context.root,
346
+ project: app.project,
347
+ eventFile,
348
+ port: app.port,
349
+ chrome: true,
350
+ headless: true,
351
+ // Identify this app's events on the shared bus (mirrors the serve
352
+ // executor's LENSMCP_PROJECT stamp for pods).
353
+ viteEnv: { LENSMCP_PROJECT: app.project },
354
+ log: (l) => console.log(l),
355
+ }, spawnManagedChild);
356
+ emit('info', `lens app started: ${app.project} (vite :${app.port})`, `cluster-lens-app:${app.project}`, { kind: 'lens-app-up', project: app.project, port: app.port });
357
+ }
358
+ catch (e) {
359
+ console.error(`[gateway] lens app ${app.project}: ${e.message}`);
360
+ }
361
+ }
225
362
  // --- service lifecycle: scale-from-zero, autoscale, idle kill ----------------
226
363
  const scanNow = (pool) => { pool.scannedAt = 0; return pickSock(pool, scanTtlMs) ? pool.socks.length : 0; };
227
364
  const spawnService = (svc) => {
@@ -253,6 +390,7 @@ async function startGateway(options, context) {
253
390
  const text = d.toString();
254
391
  for (const line of text.split('\n')) {
255
392
  if (/ERROR in |error TS\d+|Found \d+ error|Unhandled 'error' event|EADDRINUSE/.test(line)) {
393
+ // eslint-disable-next-line no-control-regex -- strip ANSI color codes from child output
256
394
  svc.lastErrors.push(line.replace(/\x1b\[[0-9;]*m/g, '').trim());
257
395
  if (svc.lastErrors.length > 12)
258
396
  svc.lastErrors.shift();
@@ -785,6 +923,14 @@ async function startGateway(options, context) {
785
923
  emit('info', 'gateway down', 'cluster-gateway', { kind: 'gateway-down' });
786
924
  for (const svc of services)
787
925
  killSpawned(svc, 'gateway shutdown');
926
+ // Reap the lens-mode managed children (dashboard + MCP singletons + each
927
+ // `lens:true` app's Vite/capture) — same teardown as the pod services.
928
+ for (const c of lensChildren) {
929
+ try {
930
+ c.kill('SIGTERM');
931
+ }
932
+ catch { /* already gone */ }
933
+ }
788
934
  await Promise.all(servers.map((s) => new Promise((r) => s.close(() => r()))));
789
935
  try {
790
936
  proxy.close?.();
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Active upstream health-checking — opt-in. Periodically probes every known
3
+ * upstream URL and tracks healthy/unhealthy per endpoint, so the gateway routes
4
+ * a pooled service AROUND dead endpoints (instead of only discovering death
5
+ * passively, one failed request at a time) and the /statusz rollup reflects true
6
+ * liveness even with zero traffic.
7
+ *
8
+ * Liveness, not readiness: a probe is "healthy" when the endpoint ANSWERS with
9
+ * status < 500 — so a service without the health path still reads up on a 404;
10
+ * only a 5xx or a connection failure/timeout is down. New endpoints start
11
+ * optimistically healthy, so a not-yet-probed endpoint is never black-holed.
12
+ *
13
+ * Fail-open by design (see providers-prod `pick`): only multi-endpoint POOLS are
14
+ * health-filtered, and even then the gateway falls back to an unhealthy endpoint
15
+ * rather than 503 if a whole pool looks down — a checker false-positive must
16
+ * never take a service fully offline.
17
+ */
18
+ export interface HealthCheckerOptions {
19
+ /** Probe path appended to each upstream URL. Default `/healthz`. */
20
+ path?: string;
21
+ /** Probe period (ms). Default 5000. */
22
+ intervalMs?: number;
23
+ /** Per-probe timeout (ms). Default 2000. */
24
+ timeoutMs?: number;
25
+ /** Consecutive failures before an endpoint is marked unhealthy. Default 2. */
26
+ unhealthyThreshold?: number;
27
+ /** Consecutive successes before a down endpoint is marked healthy again. Default 1. */
28
+ healthyThreshold?: number;
29
+ /** Injectable fetch (tests). Defaults to global fetch. */
30
+ fetchImpl?: typeof fetch;
31
+ /** Notified when an endpoint flips health. */
32
+ onChange?: (url: string, healthy: boolean) => void;
33
+ }
34
+ export interface EndpointHealth {
35
+ url: string;
36
+ healthy: boolean;
37
+ fails: number;
38
+ lastCheckedAt: number | null;
39
+ }
40
+ export interface HealthChecker {
41
+ /** Set the current URL set to monitor (called on every route rebuild). */
42
+ track(urls: string[]): void;
43
+ /** Current health (default true for an untracked / not-yet-probed URL). */
44
+ isHealthy(url: string): boolean;
45
+ /** Per-endpoint health for the status page. */
46
+ snapshot(): EndpointHealth[];
47
+ /** Probe every tracked URL once (used by start()'s loop and by tests). */
48
+ probeOnce(): Promise<void>;
49
+ start(): void;
50
+ stop(): void;
51
+ }
52
+ export declare function createHealthChecker(opts?: HealthCheckerOptions): HealthChecker;
53
+ //# sourceMappingURL=health-check.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"health-check.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/health-check.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,oBAAoB;IACnC,oEAAoE;IACpE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,uCAAuC;IACvC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,4CAA4C;IAC5C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,8EAA8E;IAC9E,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,uFAAuF;IACvF,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,0DAA0D;IAC1D,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,8CAA8C;IAC9C,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;CACpD;AAED,MAAM,WAAW,cAAc;IAAG,GAAG,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE;AAE9G,MAAM,WAAW,aAAa;IAC5B,0EAA0E;IAC1E,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;IAC5B,2EAA2E;IAC3E,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;IAChC,+CAA+C;IAC/C,QAAQ,IAAI,cAAc,EAAE,CAAC;IAC7B,0EAA0E;IAC1E,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3B,KAAK,IAAI,IAAI,CAAC;IACd,IAAI,IAAI,IAAI,CAAC;CACd;AAID,wBAAgB,mBAAmB,CAAC,IAAI,GAAE,oBAAyB,GAAG,aAAa,CAyClF"}
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createHealthChecker = createHealthChecker;
4
+ function createHealthChecker(opts = {}) {
5
+ const path = opts.path ?? '/healthz';
6
+ const intervalMs = opts.intervalMs ?? 5000;
7
+ const timeoutMs = opts.timeoutMs ?? 2000;
8
+ const downAt = Math.max(1, opts.unhealthyThreshold ?? 2);
9
+ const upAt = Math.max(1, opts.healthyThreshold ?? 1);
10
+ const doFetch = opts.fetchImpl ?? globalThis.fetch;
11
+ const state = new Map();
12
+ let timer;
13
+ const probeUrl = (url) => url.replace(/\/+$/, '') + (path.startsWith('/') ? path : '/' + path);
14
+ const probe = async (url) => {
15
+ const st = state.get(url);
16
+ if (!st)
17
+ return;
18
+ let ok = false;
19
+ const ctl = new AbortController();
20
+ const t = setTimeout(() => ctl.abort(), timeoutMs);
21
+ try {
22
+ const res = await doFetch(probeUrl(url), { method: 'GET', signal: ctl.signal, redirect: 'manual' });
23
+ ok = res.status < 500; // answered → up (even 404); only 5xx counts as down
24
+ }
25
+ catch { /* network error / timeout → unhealthy (ok stays false) */ }
26
+ finally {
27
+ clearTimeout(t);
28
+ }
29
+ st.lastCheckedAt = Date.now();
30
+ if (ok) {
31
+ st.oks += 1;
32
+ st.fails = 0;
33
+ if (!st.healthy && st.oks >= upAt) {
34
+ st.healthy = true;
35
+ opts.onChange?.(url, true);
36
+ }
37
+ }
38
+ else {
39
+ st.fails += 1;
40
+ st.oks = 0;
41
+ if (st.healthy && st.fails >= downAt) {
42
+ st.healthy = false;
43
+ opts.onChange?.(url, false);
44
+ }
45
+ }
46
+ };
47
+ const probeOnce = () => Promise.allSettled([...state.keys()].map(probe)).then(() => undefined);
48
+ return {
49
+ track(urls) {
50
+ const next = new Set(urls);
51
+ for (const u of urls)
52
+ if (!state.has(u))
53
+ state.set(u, { healthy: true, fails: 0, oks: 0, lastCheckedAt: null });
54
+ for (const u of [...state.keys()])
55
+ if (!next.has(u))
56
+ state.delete(u);
57
+ },
58
+ isHealthy: (url) => state.get(url)?.healthy ?? true,
59
+ snapshot: () => [...state.entries()].map(([url, s]) => ({ url, healthy: s.healthy, fails: s.fails, lastCheckedAt: s.lastCheckedAt })),
60
+ probeOnce,
61
+ start() { if (timer)
62
+ return; timer = setInterval(() => void probeOnce(), intervalMs); timer.unref?.(); void probeOnce(); },
63
+ stop() { if (timer)
64
+ clearInterval(timer); timer = undefined; },
65
+ };
66
+ }