@tyvm/knowhow 0.0.132 → 0.0.135

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,540 @@
1
+ import { Command } from "commander";
2
+ import { KNOWHOW_API_URL, loadKnowhowJwt } from "../services/KnowhowClient";
3
+ import http from "../utils/http";
4
+
5
+ // ─── API client helper ────────────────────────────────────────────────────────
6
+
7
+ function getBaseUrl(): string {
8
+ return `${process.env.KNOWHOW_API_URL || KNOWHOW_API_URL}/api`;
9
+ }
10
+
11
+ function getAuthHeaders(): Record<string, string> {
12
+ const token =
13
+ process.env.KNOWHOW_API_TOKEN ||
14
+ (() => {
15
+ try {
16
+ return loadKnowhowJwt();
17
+ } catch {
18
+ throw new Error(
19
+ "No API token found. Set KNOWHOW_API_TOKEN env var or run `knowhow login`."
20
+ );
21
+ }
22
+ })();
23
+ return { Authorization: `Bearer ${token}` };
24
+ }
25
+
26
+ async function apiGet<T>(path: string): Promise<T> {
27
+ const res = await http.get<T>(`${getBaseUrl()}${path}`, {
28
+ headers: getAuthHeaders(),
29
+ });
30
+ return res.data;
31
+ }
32
+
33
+ async function apiPost<T>(path: string, body?: unknown): Promise<T> {
34
+ const res = await http.post<T>(`${getBaseUrl()}${path}`, body, {
35
+ headers: getAuthHeaders(),
36
+ });
37
+ return res.data;
38
+ }
39
+
40
+ async function apiDelete(path: string): Promise<void> {
41
+ await http.delete(`${getBaseUrl()}${path}`, {
42
+ headers: getAuthHeaders(),
43
+ });
44
+ }
45
+
46
+ // ─── Domain types (minimal shapes we care about) ──────────────────────────────
47
+
48
+ interface Sandbox {
49
+ id: string;
50
+ name?: string | null;
51
+ status: string;
52
+ vmStatus?: string | null;
53
+ }
54
+
55
+ interface SandboxSnapshot {
56
+ id: string;
57
+ sandboxId: string;
58
+ label?: string | null;
59
+ description?: string | null;
60
+ status: string;
61
+ errorMsg?: string | null;
62
+ statusMessage?: string | null;
63
+ snapshotContent?: string;
64
+ setupScript?: string | null;
65
+ }
66
+
67
+ // ─── Resolve helpers ──────────────────────────────────────────────────────────
68
+
69
+ async function resolveSandboxId(nameOrId: string): Promise<string> {
70
+ // Looks like a cuid/nanoid — use directly
71
+ if (/^[a-z0-9]{15,}$/i.test(nameOrId)) {
72
+ return nameOrId;
73
+ }
74
+ const sandboxes = await apiGet<Sandbox[]>("/sandboxes");
75
+ const match = sandboxes.find(
76
+ (s) => s.name === nameOrId || s.id === nameOrId
77
+ );
78
+ if (!match) {
79
+ const names = sandboxes.map((s) => s.name || s.id).join(", ");
80
+ throw new Error(
81
+ `Sandbox not found: "${nameOrId}". Available: ${names || "(none)"}`
82
+ );
83
+ }
84
+ return match.id;
85
+ }
86
+
87
+ async function resolveSnapshot(
88
+ sandboxId: string,
89
+ nameOrId: string
90
+ ): Promise<SandboxSnapshot> {
91
+ // Try direct ID
92
+ if (/^[a-z0-9]{15,}$/i.test(nameOrId)) {
93
+ try {
94
+ return await apiGet<SandboxSnapshot>(
95
+ `/sandboxes/${sandboxId}/snapshots/${nameOrId}`
96
+ );
97
+ } catch {
98
+ // fall through to name search
99
+ }
100
+ }
101
+ // Search by label
102
+ const snapshots = await apiGet<SandboxSnapshot[]>(
103
+ `/sandboxes/${sandboxId}/snapshots`
104
+ );
105
+ let match = snapshots.find(
106
+ (s) => s.label === nameOrId || s.id === nameOrId
107
+ );
108
+ if (!match) {
109
+ // Org-wide search
110
+ const orgSnaps = await apiGet<SandboxSnapshot[]>("/org-snapshots");
111
+ match = orgSnaps.find((s) => s.label === nameOrId || s.id === nameOrId);
112
+ }
113
+ if (!match) {
114
+ const labels = snapshots.map((s) => s.label || s.id).join(", ");
115
+ throw new Error(
116
+ `Snapshot not found: "${nameOrId}". Available for sandbox: ${labels || "(none)"}`
117
+ );
118
+ }
119
+ return match;
120
+ }
121
+
122
+ // ─── Polling helpers ──────────────────────────────────────────────────────────
123
+
124
+ async function pollSandboxStatus(
125
+ sandboxId: string,
126
+ targetStatuses: string[],
127
+ timeoutMs: number,
128
+ label = sandboxId
129
+ ): Promise<Sandbox> {
130
+ const deadline = Date.now() + timeoutMs;
131
+ let lastStatus = "";
132
+ while (Date.now() < deadline) {
133
+ const sandbox = await apiGet<Sandbox>(`/sandboxes/${sandboxId}`);
134
+ if (sandbox.status !== lastStatus) {
135
+ console.log(` [${label}] status: ${sandbox.status}`);
136
+ lastStatus = sandbox.status;
137
+ }
138
+ if (targetStatuses.includes(sandbox.status)) return sandbox;
139
+ if (["error", "destroyed", "terminated"].includes(sandbox.status)) {
140
+ throw new Error(
141
+ `Sandbox ${label} entered terminal status: ${sandbox.status}`
142
+ );
143
+ }
144
+ await new Promise((r) => setTimeout(r, 5000));
145
+ }
146
+ throw new Error(
147
+ `Timeout waiting for sandbox ${label} to reach ${targetStatuses.join("/")} (${timeoutMs}ms). Last: ${lastStatus}`
148
+ );
149
+ }
150
+
151
+ async function pollSnapshotStatus(
152
+ sandboxId: string,
153
+ snapshotId: string,
154
+ targetStatuses: string[],
155
+ timeoutMs: number
156
+ ): Promise<SandboxSnapshot> {
157
+ const deadline = Date.now() + timeoutMs;
158
+ let lastStatus = "";
159
+ while (Date.now() < deadline) {
160
+ const snap = await apiGet<SandboxSnapshot>(
161
+ `/sandboxes/${sandboxId}/snapshots/${snapshotId}`
162
+ );
163
+ if (snap.status !== lastStatus) {
164
+ console.log(` [snapshot] status: ${snap.status}`);
165
+ if (snap.statusMessage) console.log(` message: ${snap.statusMessage}`);
166
+ lastStatus = snap.status;
167
+ }
168
+ if (targetStatuses.includes(snap.status)) return snap;
169
+ if (["error", "failed"].includes(snap.status)) {
170
+ throw new Error(
171
+ `Snapshot failed: ${snap.errorMsg || snap.statusMessage || "(no details)"}`
172
+ );
173
+ }
174
+ await new Promise((r) => setTimeout(r, 5000));
175
+ }
176
+ throw new Error(
177
+ `Timeout waiting for snapshot to reach ${targetStatuses.join("/")} (${timeoutMs}ms). Last: ${lastStatus}`
178
+ );
179
+ }
180
+
181
+ // ─── Command registration ─────────────────────────────────────────────────────
182
+
183
+ export function addSandboxCommand(program: Command): void {
184
+ const sandboxCmd = program
185
+ .command("sandbox")
186
+ .description("Manage Knowhow sandboxes and snapshots");
187
+
188
+ // ── sandbox list ────────────────────────────────────────────────────────────
189
+ sandboxCmd
190
+ .command("list")
191
+ .description("List all sandboxes in your org")
192
+ .option("--status <status>", "Filter by status (running|stopped|all)", "all")
193
+ .option("--json", "Output raw JSON")
194
+ .action(async (opts: { status: string; json?: boolean }) => {
195
+ const sandboxes = await apiGet<Sandbox[]>("/sandboxes");
196
+ const filtered =
197
+ opts.status === "all"
198
+ ? sandboxes
199
+ : sandboxes.filter((s) => s.status === opts.status);
200
+ if (opts.json) {
201
+ console.log(JSON.stringify(filtered, null, 2));
202
+ } else {
203
+ console.log(`Found ${filtered.length} sandbox(es):\n`);
204
+ for (const s of filtered) {
205
+ console.log(
206
+ ` ${s.id} name=${s.name || "(none)"} status=${s.status} vmStatus=${s.vmStatus || "?"}`
207
+ );
208
+ }
209
+ }
210
+ });
211
+
212
+ // ── sandbox list-snapshots ──────────────────────────────────────────────────
213
+ sandboxCmd
214
+ .command("list-snapshots")
215
+ .description("List snapshots (org-wide or for a specific sandbox)")
216
+ .option("--sandbox-id <id>", "Filter by sandbox ID")
217
+ .option("--sandbox-name <name>", "Filter by sandbox name")
218
+ .option("--json", "Output raw JSON")
219
+ .action(
220
+ async (opts: {
221
+ sandboxId?: string;
222
+ sandboxName?: string;
223
+ json?: boolean;
224
+ }) => {
225
+ let snapshots: SandboxSnapshot[];
226
+ if (opts.sandboxId || opts.sandboxName) {
227
+ const sbId = opts.sandboxId || (await resolveSandboxId(opts.sandboxName!));
228
+ snapshots = await apiGet<SandboxSnapshot[]>(
229
+ `/sandboxes/${sbId}/snapshots`
230
+ );
231
+ } else {
232
+ snapshots = await apiGet<SandboxSnapshot[]>("/org-snapshots");
233
+ }
234
+ if (opts.json) {
235
+ console.log(JSON.stringify(snapshots, null, 2));
236
+ } else {
237
+ console.log(`Found ${snapshots.length} snapshot(s):\n`);
238
+ for (const s of snapshots) {
239
+ console.log(
240
+ ` ${s.id} label=${s.label || "(none)"} status=${s.status} sandboxId=${s.sandboxId}`
241
+ );
242
+ }
243
+ }
244
+ }
245
+ );
246
+
247
+ // ── sandbox exec ────────────────────────────────────────────────────────────
248
+ sandboxCmd
249
+ .command("exec")
250
+ .description("Execute a command in a running sandbox")
251
+ .option("--sandbox-id <id>", "Sandbox ID")
252
+ .option("--sandbox-name <name>", "Sandbox name")
253
+ .requiredOption("--cmd <command>", "Shell command to run (passed to /bin/bash -c)")
254
+ .option("--timeout-ms <ms>", "Timeout in ms (max 300000)", "60000")
255
+ .action(
256
+ async (opts: {
257
+ sandboxId?: string;
258
+ sandboxName?: string;
259
+ cmd: string;
260
+ timeoutMs: string;
261
+ }) => {
262
+ const sbId =
263
+ opts.sandboxId || (opts.sandboxName ? await resolveSandboxId(opts.sandboxName) : null);
264
+ if (!sbId) throw new Error("Provide --sandbox-id or --sandbox-name");
265
+
266
+ console.log(`Executing in sandbox ${sbId}: ${opts.cmd}`);
267
+ const result = await apiPost<{
268
+ stdout?: string;
269
+ stderr?: string;
270
+ exitCode?: number;
271
+ }>(`/sandboxes/${sbId}/exec`, {
272
+ command: "/bin/bash",
273
+ args: ["-c", opts.cmd],
274
+ timeoutMs: Math.min(Number(opts.timeoutMs), 300000),
275
+ });
276
+ if (result.stdout) process.stdout.write(result.stdout);
277
+ if (result.stderr) process.stderr.write(result.stderr);
278
+ if (result.exitCode != null && result.exitCode !== 0) {
279
+ process.exit(result.exitCode);
280
+ }
281
+ }
282
+ );
283
+
284
+ // ── sandbox start ───────────────────────────────────────────────────────────
285
+ sandboxCmd
286
+ .command("start")
287
+ .description("Start a stopped sandbox")
288
+ .option("--sandbox-id <id>", "Sandbox ID")
289
+ .option("--sandbox-name <name>", "Sandbox name")
290
+ .option("--wait", "Wait until sandbox is running")
291
+ .option("--timeout-minutes <min>", "Max wait time in minutes", "10")
292
+ .action(
293
+ async (opts: {
294
+ sandboxId?: string;
295
+ sandboxName?: string;
296
+ wait?: boolean;
297
+ timeoutMinutes: string;
298
+ }) => {
299
+ const sbId =
300
+ opts.sandboxId || (opts.sandboxName ? await resolveSandboxId(opts.sandboxName) : null);
301
+ if (!sbId) throw new Error("Provide --sandbox-id or --sandbox-name");
302
+ console.log(`Starting sandbox ${sbId}...`);
303
+ await apiPost(`/sandboxes/${sbId}/start`);
304
+ if (opts.wait) {
305
+ await pollSandboxStatus(sbId, ["running"], Number(opts.timeoutMinutes) * 60000);
306
+ console.log("✓ Sandbox is running");
307
+ }
308
+ }
309
+ );
310
+
311
+ // ── sandbox stop ────────────────────────────────────────────────────────────
312
+ sandboxCmd
313
+ .command("stop")
314
+ .description("Stop a running sandbox")
315
+ .option("--sandbox-id <id>", "Sandbox ID")
316
+ .option("--sandbox-name <name>", "Sandbox name")
317
+ .option("--wait", "Wait until sandbox is stopped")
318
+ .option("--timeout-minutes <min>", "Max wait time in minutes", "10")
319
+ .action(
320
+ async (opts: {
321
+ sandboxId?: string;
322
+ sandboxName?: string;
323
+ wait?: boolean;
324
+ timeoutMinutes: string;
325
+ }) => {
326
+ const sbId =
327
+ opts.sandboxId || (opts.sandboxName ? await resolveSandboxId(opts.sandboxName) : null);
328
+ if (!sbId) throw new Error("Provide --sandbox-id or --sandbox-name");
329
+ console.log(`Stopping sandbox ${sbId}...`);
330
+ await apiPost(`/sandboxes/${sbId}/stop`);
331
+ if (opts.wait) {
332
+ await pollSandboxStatus(sbId, ["stopped"], Number(opts.timeoutMinutes) * 60000);
333
+ console.log("✓ Sandbox is stopped");
334
+ }
335
+ }
336
+ );
337
+
338
+ // ── sandbox delete-snapshot ─────────────────────────────────────────────────
339
+ sandboxCmd
340
+ .command("delete-snapshot")
341
+ .description("Delete a snapshot by ID")
342
+ .option("--sandbox-id <id>", "Sandbox ID")
343
+ .option("--sandbox-name <name>", "Sandbox name")
344
+ .requiredOption("--snapshot-id <id>", "Snapshot ID to delete")
345
+ .action(
346
+ async (opts: {
347
+ sandboxId?: string;
348
+ sandboxName?: string;
349
+ snapshotId: string;
350
+ }) => {
351
+ const sbId =
352
+ opts.sandboxId || (opts.sandboxName ? await resolveSandboxId(opts.sandboxName) : null);
353
+ if (!sbId) throw new Error("Provide --sandbox-id or --sandbox-name");
354
+ await apiDelete(`/sandboxes/${sbId}/snapshots/${opts.snapshotId}`);
355
+ console.log(`✓ Deleted snapshot ${opts.snapshotId}`);
356
+ }
357
+ );
358
+
359
+ // ── sandbox regenerate-snapshot ─────────────────────────────────────────────
360
+ sandboxCmd
361
+ .command("regenerate-snapshot")
362
+ .description(
363
+ "Regenerate a snapshot: forks an ephemeral sandbox from it, runs the\n" +
364
+ "snapshot's setupScript on a fresh base image, captures a new snapshot\n" +
365
+ "with the same label, then destroys the ephemeral sandbox.\n\n" +
366
+ "Typical CI usage (run before tests, only when inputs changed):\n" +
367
+ " knowhow hash --name prisma-gen --input 'prisma/schema/**,package-lock.json' \\\\\n" +
368
+ " || knowhow sandbox regenerate-snapshot \\\\\n" +
369
+ " --sandbox-name knowhow-web --snapshot-name tests-ready"
370
+ )
371
+ .option("--sandbox-id <id>", "Source sandbox ID")
372
+ .option("--sandbox-name <name>", "Source sandbox name (alternative to --sandbox-id)")
373
+ .option("--snapshot-id <id>", "Snapshot ID to regenerate")
374
+ .option("--snapshot-name <name>", "Snapshot label to regenerate")
375
+ .option("--no-wait", "Return immediately without waiting for snapshot to be ready")
376
+ .option("--timeout-minutes <min>", "Max time for the full regen cycle", "60")
377
+ .action(
378
+ async (opts: {
379
+ sandboxId?: string;
380
+ sandboxName?: string;
381
+ snapshotId?: string;
382
+ snapshotName?: string;
383
+ wait: boolean; // commander flips --no-wait → wait=false
384
+ timeoutMinutes: string;
385
+ }) => {
386
+ const timeoutMs = Number(opts.timeoutMinutes) * 60 * 1000;
387
+ const deadline = Date.now() + timeoutMs;
388
+
389
+ // ── 1. Resolve sandbox ──────────────────────────────────────────────
390
+ if (!opts.sandboxId && !opts.sandboxName) {
391
+ throw new Error("Provide --sandbox-id or --sandbox-name");
392
+ }
393
+ const sandboxId =
394
+ opts.sandboxId || (await resolveSandboxId(opts.sandboxName!));
395
+
396
+ // ── 2. Resolve snapshot ─────────────────────────────────────────────
397
+ if (!opts.snapshotId && !opts.snapshotName) {
398
+ throw new Error("Provide --snapshot-id or --snapshot-name");
399
+ }
400
+ const snapshot = opts.snapshotId
401
+ ? await apiGet<SandboxSnapshot>(
402
+ `/sandboxes/${sandboxId}/snapshots/${opts.snapshotId}`
403
+ )
404
+ : await resolveSnapshot(sandboxId, opts.snapshotName!);
405
+ const snapshotId = snapshot.id;
406
+
407
+ console.log(
408
+ `\n🔄 Regenerating snapshot "${snapshot.label || snapshotId}" (${snapshotId})`
409
+ );
410
+ console.log(` Source sandbox: ${sandboxId}`);
411
+
412
+ if (snapshot.status !== "ready") {
413
+ throw new Error(
414
+ `Snapshot must be in 'ready' status to regenerate (current: ${snapshot.status})`
415
+ );
416
+ }
417
+ if (!snapshot.setupScript) {
418
+ throw new Error(
419
+ "Snapshot has no setupScript. Add one via the UI before regenerating."
420
+ );
421
+ }
422
+
423
+ // ── 3. Fork ephemeral sandbox from the snapshot ─────────────────────
424
+ console.log("\n📦 Step 1/4: Forking ephemeral sandbox from snapshot...");
425
+ const ephemeralName = `regen-${snapshotId.slice(0, 8)}-${Date.now()}`;
426
+ const forkResult = await apiPost<{ id?: string; sandboxId?: string }>(
427
+ "/sandboxes/fork",
428
+ { snapshotId, name: ephemeralName }
429
+ );
430
+ const ephemeralId = forkResult.id || forkResult.sandboxId;
431
+ if (!ephemeralId) {
432
+ throw new Error(
433
+ `Fork did not return a sandbox ID: ${JSON.stringify(forkResult)}`
434
+ );
435
+ }
436
+ console.log(` Ephemeral sandbox: ${ephemeralId} (${ephemeralName})`);
437
+
438
+ // ── 4. Wait for ephemeral sandbox to be running ─────────────────────
439
+ console.log("\n⏳ Step 2/4: Waiting for sandbox to start...");
440
+ await pollSandboxStatus(
441
+ ephemeralId,
442
+ ["running"],
443
+ Math.min(deadline - Date.now(), 10 * 60 * 1000),
444
+ ephemeralName
445
+ );
446
+ console.log(" ✓ Running");
447
+
448
+ // ── 5. Execute the setupScript ──────────────────────────────────────
449
+ console.log("\n🔧 Step 3/4: Executing setupScript...");
450
+ console.log(` Script length: ${snapshot.setupScript.length} chars`);
451
+ let execResult: { stdout?: string; stderr?: string; exitCode?: number };
452
+ try {
453
+ execResult = await apiPost<{
454
+ stdout?: string;
455
+ stderr?: string;
456
+ exitCode?: number;
457
+ }>(`/sandboxes/${ephemeralId}/exec`, {
458
+ command: "/bin/bash",
459
+ args: ["-c", snapshot.setupScript],
460
+ timeoutMs: Math.min(deadline - Date.now(), 25 * 60 * 1000),
461
+ });
462
+ } catch (err: any) {
463
+ console.error(`\n❌ setupScript exec failed: ${err.message}`);
464
+ await safeDestroy(ephemeralId);
465
+ throw err;
466
+ }
467
+
468
+ if (execResult.stdout) {
469
+ console.log("--- stdout ---");
470
+ process.stdout.write(execResult.stdout);
471
+ }
472
+ if (execResult.stderr) {
473
+ console.error("--- stderr ---");
474
+ process.stderr.write(execResult.stderr);
475
+ }
476
+ if (execResult.exitCode != null && execResult.exitCode !== 0) {
477
+ await safeDestroy(ephemeralId);
478
+ throw new Error(`setupScript exited with code ${execResult.exitCode}`);
479
+ }
480
+ console.log(" ✓ setupScript completed");
481
+
482
+ // ── 6. Capture new snapshot with the same label ─────────────────────
483
+ console.log("\n📸 Step 4/4: Capturing new snapshot...");
484
+ const newLabel = snapshot.label || `regen-${snapshotId.slice(0, 8)}`;
485
+ let createResult: { snapshot?: SandboxSnapshot; id?: string };
486
+ try {
487
+ createResult = await apiPost<{ snapshot?: SandboxSnapshot; id?: string }>(
488
+ `/sandboxes/${ephemeralId}/snapshots`,
489
+ {
490
+ label: newLabel,
491
+ description: snapshot.description,
492
+ snapshotContent: snapshot.snapshotContent || "full",
493
+ snapshotType: "full",
494
+ }
495
+ );
496
+ } catch (err: any) {
497
+ console.error(`\n❌ Snapshot creation failed: ${err.message}`);
498
+ await safeDestroy(ephemeralId);
499
+ throw err;
500
+ }
501
+
502
+ const newSnapshot = createResult.snapshot || (createResult as SandboxSnapshot);
503
+ const newSnapshotId = newSnapshot.id;
504
+ console.log(` New snapshot ID: ${newSnapshotId}`);
505
+
506
+ if (opts.wait) {
507
+ console.log(" Waiting for snapshot upload...");
508
+ await pollSnapshotStatus(
509
+ ephemeralId,
510
+ newSnapshotId,
511
+ ["ready"],
512
+ Math.min(deadline - Date.now(), 20 * 60 * 1000)
513
+ );
514
+ console.log(" ✓ Snapshot ready");
515
+ }
516
+
517
+ // ── 7. Destroy ephemeral sandbox ────────────────────────────────────
518
+ console.log("\n🗑 Destroying ephemeral sandbox...");
519
+ await safeDestroy(ephemeralId);
520
+ console.log(" ✓ Destroyed");
521
+
522
+ console.log(
523
+ `\n✅ Snapshot regeneration complete!\n` +
524
+ ` Old snapshot ID : ${snapshotId}\n` +
525
+ ` New snapshot ID : ${newSnapshotId}\n` +
526
+ ` Label : ${newLabel}\n\n` +
527
+ `ℹ The old snapshot still exists. Clean it up with:\n` +
528
+ ` knowhow sandbox delete-snapshot --sandbox-id ${sandboxId} --snapshot-id ${snapshotId}`
529
+ );
530
+ }
531
+ );
532
+ }
533
+
534
+ async function safeDestroy(sandboxId: string): Promise<void> {
535
+ try {
536
+ await apiDelete(`/sandboxes/${sandboxId}`);
537
+ } catch (err: any) {
538
+ console.warn(`⚠ Could not destroy sandbox ${sandboxId}: ${err.message}`);
539
+ }
540
+ }
package/src/tunnel.ts CHANGED
@@ -114,6 +114,8 @@ export interface TunnelWebSocketOptions {
114
114
  onClose?: (code: number, reason: string) => void;
115
115
  /** Called on error */
116
116
  onError?: (error: Error) => void;
117
+ /** Called when a pong frame is received (liveness heartbeat) */
118
+ onPong?: () => void;
117
119
  /** Optional passkey auth service — if provided, applies WS middleware to gate tunnel traffic */
118
120
  authService?: WorkerPasskeyAuthService | null;
119
121
  }
@@ -138,6 +140,7 @@ export function connectTunnelWebSocket(
138
140
  onOpen,
139
141
  onClose,
140
142
  onError,
143
+ onPong,
141
144
  authService,
142
145
  } = options;
143
146
 
@@ -198,6 +201,14 @@ export function connectTunnelWebSocket(
198
201
  onError?.(error);
199
202
  });
200
203
 
204
+ // Pong watchdog: proves the tunnel socket is genuinely alive (not a
205
+ // snapshot-resume zombie). The caller sends ws.ping() and requires a pong.
206
+ if (onPong) {
207
+ tunnelConnection.on("pong", () => {
208
+ onPong();
209
+ });
210
+ }
211
+
201
212
  return tunnelConnection;
202
213
  }
203
214