@shogo-ai/worker 1.7.4

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,664 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * Worker Tunnel Client.
5
+ *
6
+ * Maintains presence with Shogo Cloud over outbound HTTPS:
7
+ * - HTTP heartbeat polling (default 60s) reports status and discovers
8
+ * when the cloud wants an interactive session (`wsRequested`).
9
+ * - On `wsRequested`, opens a single on-demand WebSocket for
10
+ * bidirectional command proxying. The cloud sends `request` frames
11
+ * containing arbitrary HTTP method/path/headers/body; the worker
12
+ * forwards them to a local agent-runtime (resolved via the
13
+ * `RuntimeResolver`) and streams responses back as `stream-chunk` /
14
+ * `stream-end` / `response` frames.
15
+ * - WS auto-closes after `WS_IDLE_TIMEOUT_MS`; tunnel falls back to
16
+ * polling and reopens on the next `wsRequested`.
17
+ *
18
+ * MIT port of apps/api/src/lib/instance-tunnel.ts, restructured as a
19
+ * class so multiple consumers (worker, future tests) can hold their
20
+ * own state. The desktop AGPL copy keeps its module-global form.
21
+ */
22
+ import { hostname as osHostname, platform, arch as osArch } from 'node:os';
23
+
24
+ /**
25
+ * Pluggable resolver for the tunnel — provided by whoever owns the local
26
+ * services that the cloud's tunneled requests should be forwarded to.
27
+ *
28
+ * Two known implementations:
29
+ * - `WorkerRuntimeManager` (this package, MIT) — only resolves /agent/*
30
+ * to per-project agent-runtime processes; non-agent paths return null.
31
+ * - apps/api desktop (AGPL) — resolves /agent/* to the desktop's
32
+ * existing per-project agent-runtime, AND forwards anything else to
33
+ * the desktop's local apps/api on its `API_PORT`.
34
+ *
35
+ * Both consumers share the WorkerTunnel transport this way without
36
+ * duplicating heartbeat / WS / framing / backoff code.
37
+ */
38
+ export interface RuntimeResolver {
39
+ /**
40
+ * Resolve a tunneled path to a local URL the worker should forward
41
+ * the cloud's request to. Return null to make the tunnel reply 502
42
+ * (no local service available for this path).
43
+ *
44
+ * The resolver may start the agent-runtime on demand for /agent/*
45
+ * paths; cold-start latency surfaces to the cloud as request
46
+ * latency, which is the right knob for per-project runtimes.
47
+ */
48
+ resolveLocalUrl(pathWithQuery: string, projectId?: string): Promise<string | null>;
49
+
50
+ /** Mint a per-project runtime token for `x-runtime-token`. */
51
+ deriveRuntimeToken(projectId: string): string | null;
52
+
53
+ /** Return the project ids the worker currently has runtimes for. */
54
+ getActiveProjects(): string[];
55
+
56
+ /** Status snapshot for a single project — used in metadata payloads. */
57
+ status(projectId: string): { status: string; agentPort?: number } | null;
58
+ }
59
+
60
+ interface TunnelRequest {
61
+ type: 'request';
62
+ requestId: string;
63
+ method: string;
64
+ path: string;
65
+ headers?: Record<string, string>;
66
+ body?: string;
67
+ stream?: boolean;
68
+ projectId?: string;
69
+ }
70
+
71
+ interface CancelMessage {
72
+ type: 'cancel';
73
+ requestId: string;
74
+ }
75
+
76
+ type IncomingMessage = TunnelRequest | CancelMessage | { type: 'ping' } | { type: string };
77
+
78
+ type TunnelWebSocketInit = { headers: Record<string, string> };
79
+ type TunnelWebSocketConstructor = new (url: string, init: TunnelWebSocketInit) => WebSocket;
80
+
81
+ type RuntimeWithBunWebSocketHeaders = typeof globalThis & {
82
+ Bun?: unknown;
83
+ process?: { versions?: { bun?: string } };
84
+ };
85
+
86
+ interface HeartbeatResponse {
87
+ instanceId?: string;
88
+ nextPollIn: number;
89
+ wsRequested: boolean;
90
+ wsUrl?: string;
91
+ }
92
+
93
+ export class TunnelWebSocketHeaderSupportError extends Error {
94
+ code = 'TUNNEL_WS_HEADERS_UNSUPPORTED' as const;
95
+ constructor() {
96
+ super(
97
+ 'Tunnel WebSocket auth requires Bun WebSocket header support. ' +
98
+ 'This runtime does not advertise Bun, so Authorization headers may be dropped.',
99
+ );
100
+ this.name = 'TunnelWebSocketHeaderSupportError';
101
+ }
102
+ }
103
+
104
+ const DEFAULT_POLL_INTERVAL_S = 60;
105
+ const AUTH_FAILURE_BACKOFF_S = 300;
106
+ const AUTH_FAILURE_THRESHOLD = 3;
107
+ const AUTH_RECOVERY_SUCCESS_THRESHOLD = AUTH_FAILURE_THRESHOLD;
108
+ const WS_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
109
+ const HEARTBEAT_INTERVAL_MS = 25_000;
110
+ const BACKOFF_BASE_MS = 1_000;
111
+ const BACKOFF_MAX_MS = 60_000;
112
+
113
+ /**
114
+ * Protocol version advertised in heartbeat metadata. Stays lockstep with
115
+ * apps/api/src/lib/instance-tunnel.ts so the cloud can gate features.
116
+ *
117
+ * Version history:
118
+ * 1 — Initial tunnel with chat proxy
119
+ * 2 — Transparent proxy (any HTTP request)
120
+ * 3 — Remote state sync (projects, history routed through tunnel)
121
+ */
122
+ export const TUNNEL_PROTOCOL_VERSION = 3;
123
+
124
+ export interface WorkerTunnelOptions {
125
+ apiKey: string;
126
+ cloudUrl: string;
127
+ /** Friendly machine name reported to the cloud (default: hostname). */
128
+ name?: string;
129
+ /** Override the protocol-version-derived `kind` field on the heartbeat. */
130
+ kind?: string;
131
+ /** Explicit WS URL override (env: SHOGO_TUNNEL_WS_URL). */
132
+ wsUrlOverride?: string;
133
+ resolver: RuntimeResolver;
134
+ /** Optional logger. Defaults to console. */
135
+ logger?: Pick<Console, 'log' | 'warn' | 'error'>;
136
+ /** Called when the cloud signals a final auth failure (key revoked). */
137
+ onAuthRevoked?: (reason: string) => void;
138
+ }
139
+
140
+ export class WorkerTunnel {
141
+ private readonly opts: WorkerTunnelOptions;
142
+ private readonly log: Pick<Console, 'log' | 'warn' | 'error'>;
143
+
144
+ private pollTimer: ReturnType<typeof setTimeout> | null = null;
145
+ private ws: WebSocket | null = null;
146
+ private wsIdleTimer: ReturnType<typeof setTimeout> | null = null;
147
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
148
+ private stopped = false;
149
+ private currentPollInterval = DEFAULT_POLL_INTERVAL_S;
150
+ private wsReconnectAttempt = 0;
151
+ private lastHeartbeatError: string | null = null;
152
+ private consecutiveAuthFailures = 0;
153
+ private consecutiveAuthSuccesses = 0;
154
+ private serverPublishedWsUrl: string | null = null;
155
+ private readonly activeAbortControllers = new Map<string, AbortController>();
156
+
157
+ constructor(opts: WorkerTunnelOptions) {
158
+ this.opts = opts;
159
+ this.log = opts.logger ?? console;
160
+ }
161
+
162
+ // ─── Public lifecycle ────────────────────────────────────────────
163
+
164
+ start(): void {
165
+ if (!this.opts.apiKey) {
166
+ this.log.log('[WorkerTunnel] No API key set, skipping tunnel');
167
+ return;
168
+ }
169
+ this.stopped = false;
170
+ this.wsReconnectAttempt = 0;
171
+ this.currentPollInterval = DEFAULT_POLL_INTERVAL_S;
172
+ this.log.log('[WorkerTunnel] Starting heartbeat polling to Shogo Cloud...');
173
+ void this.heartbeatLoop();
174
+ }
175
+
176
+ stop(): void {
177
+ this.stopped = true;
178
+ if (this.pollTimer) {
179
+ clearTimeout(this.pollTimer);
180
+ this.pollTimer = null;
181
+ }
182
+ this.cleanupWs();
183
+ if (this.ws) {
184
+ try { this.ws.close(1000, 'Tunnel stopped'); } catch { /* already closed */ }
185
+ this.ws = null;
186
+ }
187
+ this.log.log('[WorkerTunnel] Tunnel stopped');
188
+ }
189
+
190
+ isConnected(): boolean {
191
+ if (this.ws !== null && this.ws.readyState === WebSocket.OPEN) return true;
192
+ // Polling alone keeps the worker reachable; the WS is on-demand.
193
+ return !this.stopped && !!this.opts.apiKey && this.lastHeartbeatError === null && this.pollTimer !== null;
194
+ }
195
+
196
+ // ─── Internals ──────────────────────────────────────────────────
197
+
198
+ private getCloudUrl(): string {
199
+ return this.opts.cloudUrl.replace(/\/$/, '');
200
+ }
201
+
202
+ private getWsBaseUrl(): string {
203
+ const explicit = (this.opts.wsUrlOverride || process.env.SHOGO_TUNNEL_WS_URL || '').trim();
204
+ if (explicit) return explicit.replace(/\/$/, '');
205
+ if (this.serverPublishedWsUrl) return this.serverPublishedWsUrl.replace(/\/$/, '');
206
+ return this.getCloudUrl().replace(/^http/, 'ws');
207
+ }
208
+
209
+ private buildWsUrl(): string {
210
+ return `${this.getWsBaseUrl()}/api/instances/ws`;
211
+ }
212
+
213
+ private supportsWebSocketConstructorHeaders(
214
+ runtime: RuntimeWithBunWebSocketHeaders = globalThis as RuntimeWithBunWebSocketHeaders,
215
+ ): boolean {
216
+ return typeof runtime.Bun !== 'undefined' || typeof runtime.process?.versions?.bun === 'string';
217
+ }
218
+
219
+ private createTunnelWebSocket(
220
+ url: string,
221
+ init: TunnelWebSocketInit,
222
+ runtime: RuntimeWithBunWebSocketHeaders = globalThis as RuntimeWithBunWebSocketHeaders,
223
+ ): WebSocket {
224
+ if (!this.supportsWebSocketConstructorHeaders(runtime)) {
225
+ throw new TunnelWebSocketHeaderSupportError();
226
+ }
227
+ const Ctor = WebSocket as unknown as TunnelWebSocketConstructor;
228
+ return new Ctor(url, init);
229
+ }
230
+
231
+ private getReconnectDelayMs(): number {
232
+ const delay = Math.min(BACKOFF_BASE_MS * Math.pow(2, this.wsReconnectAttempt), BACKOFF_MAX_MS);
233
+ const jitter = delay * 0.2 * Math.random();
234
+ return delay + jitter;
235
+ }
236
+
237
+ private async collectMetadata(): Promise<Record<string, unknown>> {
238
+ const meta: Record<string, unknown> = {
239
+ hostname: osHostname(),
240
+ os: platform(),
241
+ arch: osArch(),
242
+ uptime: process.uptime(),
243
+ protocolVersion: TUNNEL_PROTOCOL_VERSION,
244
+ tunnelStatus: this.ws?.readyState === WebSocket.OPEN ? 'connected' : 'polling',
245
+ kind: this.opts.kind ?? 'cli-worker',
246
+ };
247
+ try {
248
+ const projectIds = this.opts.resolver.getActiveProjects();
249
+ meta.activeProjects = projectIds.length;
250
+ meta.projects = projectIds.map((projectId) => {
251
+ const s = this.opts.resolver.status(projectId);
252
+ return {
253
+ projectId,
254
+ status: s?.status ?? 'unknown',
255
+ agentPort: s?.agentPort,
256
+ };
257
+ });
258
+ } catch {
259
+ meta.activeProjects = 0;
260
+ }
261
+ return meta;
262
+ }
263
+
264
+ // ─── HTTP Heartbeat Loop ────────────────────────────────────────
265
+
266
+ private async sendHeartbeat(): Promise<HeartbeatResponse> {
267
+ const cloudUrl = this.getCloudUrl();
268
+ const metadata = await this.collectMetadata();
269
+ const hn = osHostname();
270
+ const name = this.opts.name ?? hn;
271
+
272
+ const resp = await fetch(`${cloudUrl}/api/instances/heartbeat`, {
273
+ method: 'POST',
274
+ headers: {
275
+ 'Content-Type': 'application/json',
276
+ 'x-api-key': this.opts.apiKey,
277
+ 'x-shogo-kind': this.opts.kind ?? 'cli-worker',
278
+ },
279
+ body: JSON.stringify({
280
+ hostname: hn,
281
+ name,
282
+ os: platform(),
283
+ arch: osArch(),
284
+ kind: this.opts.kind ?? 'cli-worker',
285
+ metadata,
286
+ }),
287
+ });
288
+
289
+ if (!resp.ok) {
290
+ throw new Error(`Heartbeat failed: HTTP ${resp.status}`);
291
+ }
292
+
293
+ const data = (await resp.json()) as HeartbeatResponse;
294
+
295
+ if (typeof data.wsUrl === 'string' && data.wsUrl.length > 0) {
296
+ if (data.wsUrl !== this.serverPublishedWsUrl) {
297
+ this.log.log(`[WorkerTunnel] Cloud advertised tunnel WS URL: ${data.wsUrl}`);
298
+ }
299
+ this.serverPublishedWsUrl = data.wsUrl;
300
+ }
301
+
302
+ return data;
303
+ }
304
+
305
+ private scheduleNextPoll(intervalS?: number): void {
306
+ if (this.stopped) return;
307
+ if (this.pollTimer) clearTimeout(this.pollTimer);
308
+ const delay = (intervalS ?? this.currentPollInterval) * 1000;
309
+ this.pollTimer = setTimeout(() => void this.heartbeatLoop(), delay);
310
+ }
311
+
312
+ private async heartbeatLoop(): Promise<void> {
313
+ if (this.stopped) return;
314
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
315
+ this.scheduleNextPoll(this.currentPollInterval);
316
+ return;
317
+ }
318
+
319
+ try {
320
+ const result = await this.sendHeartbeat();
321
+ const nextPollIn = result.nextPollIn || DEFAULT_POLL_INTERVAL_S;
322
+ const wasInAuthBackoff = this.consecutiveAuthFailures >= AUTH_FAILURE_THRESHOLD;
323
+
324
+ if (wasInAuthBackoff) {
325
+ this.consecutiveAuthSuccesses++;
326
+ if (this.consecutiveAuthSuccesses < AUTH_RECOVERY_SUCCESS_THRESHOLD) {
327
+ this.currentPollInterval = AUTH_FAILURE_BACKOFF_S;
328
+ this.scheduleNextPoll();
329
+ return;
330
+ }
331
+ }
332
+
333
+ this.currentPollInterval = nextPollIn;
334
+ if (this.lastHeartbeatError) {
335
+ this.log.log('[WorkerTunnel] Heartbeat recovered');
336
+ this.lastHeartbeatError = null;
337
+ }
338
+ this.consecutiveAuthFailures = 0;
339
+ this.consecutiveAuthSuccesses = 0;
340
+
341
+ if (result.wsRequested && !this.ws) {
342
+ this.log.log('[WorkerTunnel] Cloud requested WebSocket — connecting...');
343
+ this.connectWs();
344
+ return;
345
+ }
346
+ } catch (err: any) {
347
+ const message = err?.message ?? String(err);
348
+ const isAuthFailure = /HTTP 40[13]\b/.test(message);
349
+ if (isAuthFailure) {
350
+ this.consecutiveAuthFailures++;
351
+ this.consecutiveAuthSuccesses = 0;
352
+ } else {
353
+ this.consecutiveAuthFailures = 0;
354
+ this.consecutiveAuthSuccesses = 0;
355
+ }
356
+ if (message !== this.lastHeartbeatError) {
357
+ this.log.error(`[WorkerTunnel] Heartbeat error: ${message}`);
358
+ this.lastHeartbeatError = message;
359
+ }
360
+ if (this.consecutiveAuthFailures >= AUTH_FAILURE_THRESHOLD) {
361
+ const reason = `tunnel saw ${this.consecutiveAuthFailures} consecutive auth failures from Shogo Cloud`;
362
+ try {
363
+ this.opts.onAuthRevoked?.(reason);
364
+ } catch (cbErr: any) {
365
+ this.log.warn(`[WorkerTunnel] onAuthRevoked threw: ${cbErr?.message ?? cbErr}`);
366
+ }
367
+ if (this.currentPollInterval !== AUTH_FAILURE_BACKOFF_S) {
368
+ this.log.warn(
369
+ `[WorkerTunnel] ${this.consecutiveAuthFailures} consecutive auth failures \u2014 ` +
370
+ `backing off to ${AUTH_FAILURE_BACKOFF_S}s. Re-authenticate with \`shogo login\`.`,
371
+ );
372
+ }
373
+ this.currentPollInterval = AUTH_FAILURE_BACKOFF_S;
374
+ } else {
375
+ this.currentPollInterval = DEFAULT_POLL_INTERVAL_S;
376
+ }
377
+ }
378
+
379
+ this.scheduleNextPoll();
380
+ }
381
+
382
+ // ─── On-demand WebSocket ────────────────────────────────────────
383
+
384
+ private async handleRequest(msg: TunnelRequest): Promise<void> {
385
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
386
+ this.resetWsIdleTimer();
387
+
388
+ const controller = new AbortController();
389
+ this.activeAbortControllers.set(msg.requestId, controller);
390
+
391
+ try {
392
+ const url = await this.resolveLocalUrl(msg.path, msg.projectId);
393
+ if (!url) {
394
+ this.sendFrame({
395
+ type: 'response',
396
+ requestId: msg.requestId,
397
+ status: 502,
398
+ body: JSON.stringify({ error: 'No local runtime available for path' }),
399
+ });
400
+ return;
401
+ }
402
+
403
+ const headers: Record<string, string> = { ...(msg.headers ?? {}) };
404
+
405
+ if (msg.projectId && (msg.path.startsWith('/agent/') || msg.path === '/agent')) {
406
+ const token = this.opts.resolver.deriveRuntimeToken(msg.projectId);
407
+ if (token) headers['x-runtime-token'] = token;
408
+ }
409
+
410
+ const init: RequestInit = {
411
+ method: msg.method,
412
+ headers,
413
+ signal: controller.signal,
414
+ };
415
+ if (msg.body && msg.method !== 'GET' && msg.method !== 'HEAD') {
416
+ init.body = msg.body;
417
+ }
418
+
419
+ const resp = await fetch(url, init);
420
+
421
+ if (msg.stream) {
422
+ const reader = resp.body?.getReader();
423
+ if (!reader) {
424
+ this.sendFrame({
425
+ type: 'stream-error',
426
+ requestId: msg.requestId,
427
+ error: 'No response body for stream',
428
+ });
429
+ return;
430
+ }
431
+ const decoder = new TextDecoder();
432
+ try {
433
+ while (true) {
434
+ const { done, value } = await reader.read();
435
+ if (done) break;
436
+ if (this.ws?.readyState !== WebSocket.OPEN) break;
437
+ this.sendFrame({
438
+ type: 'stream-chunk',
439
+ requestId: msg.requestId,
440
+ data: decoder.decode(value, { stream: true }),
441
+ });
442
+ }
443
+ if (this.ws?.readyState === WebSocket.OPEN) {
444
+ this.sendFrame({ type: 'stream-end', requestId: msg.requestId });
445
+ }
446
+ } catch (err: any) {
447
+ if (err?.name !== 'AbortError' && this.ws?.readyState === WebSocket.OPEN) {
448
+ this.sendFrame({
449
+ type: 'stream-error',
450
+ requestId: msg.requestId,
451
+ error: err?.message ?? String(err),
452
+ });
453
+ }
454
+ }
455
+ } else {
456
+ const body = await resp.text();
457
+ const respHeaders: Record<string, string> = {};
458
+ resp.headers.forEach((v, k) => { respHeaders[k] = v; });
459
+ this.sendFrame({
460
+ type: 'response',
461
+ requestId: msg.requestId,
462
+ status: resp.status,
463
+ headers: respHeaders,
464
+ body,
465
+ });
466
+ }
467
+ } catch (err: any) {
468
+ if (err?.name === 'AbortError') return;
469
+ if (this.ws?.readyState !== WebSocket.OPEN) return;
470
+ const payload = msg.stream
471
+ ? { type: 'stream-error' as const, requestId: msg.requestId, error: err?.message ?? String(err) }
472
+ : {
473
+ type: 'response' as const,
474
+ requestId: msg.requestId,
475
+ status: 502,
476
+ body: JSON.stringify({ error: err?.message ?? String(err) }),
477
+ };
478
+ this.sendFrame(payload);
479
+ } finally {
480
+ this.activeAbortControllers.delete(msg.requestId);
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Path → local URL via the injected resolver. The tunnel itself has
486
+ * no opinion about which paths route where; that's the resolver's
487
+ * job (see `RuntimeResolver`).
488
+ */
489
+ private async resolveLocalUrl(pathWithQuery: string, projectId?: string): Promise<string | null> {
490
+ return this.opts.resolver.resolveLocalUrl(pathWithQuery, projectId);
491
+ }
492
+
493
+ private sendFrame(frame: Record<string, unknown>): void {
494
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
495
+ try {
496
+ this.ws.send(JSON.stringify(frame));
497
+ } catch (err: any) {
498
+ this.log.warn(`[WorkerTunnel] Frame send failed: ${err?.message ?? err}`);
499
+ }
500
+ }
501
+
502
+ private resetWsIdleTimer(): void {
503
+ if (this.wsIdleTimer) clearTimeout(this.wsIdleTimer);
504
+ this.wsIdleTimer = setTimeout(() => {
505
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
506
+ this.log.log('[WorkerTunnel] WebSocket idle timeout — closing, returning to polling');
507
+ try { this.ws.close(1000, 'Idle timeout'); } catch { /* already gone */ }
508
+ }
509
+ }, WS_IDLE_TIMEOUT_MS);
510
+ }
511
+
512
+ private startWsHeartbeat(): void {
513
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
514
+ this.heartbeatTimer = setInterval(async () => {
515
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
516
+ try {
517
+ const metadata = await this.collectMetadata();
518
+ this.sendFrame({ type: 'heartbeat', metadata });
519
+ } catch { /* heartbeat is best-effort */ }
520
+ }, HEARTBEAT_INTERVAL_MS);
521
+ }
522
+
523
+ private connectWs(): void {
524
+ if (this.stopped || this.ws) return;
525
+
526
+ const url = this.buildWsUrl();
527
+ const hn = osHostname();
528
+ const os = platform();
529
+ const arch = osArch();
530
+ const name = this.opts.name ?? hn;
531
+
532
+ this.log.log(`[WorkerTunnel] Opening WebSocket to ${url} (hostname=${hn})`);
533
+
534
+ const wsInit: TunnelWebSocketInit = {
535
+ headers: {
536
+ Authorization: `Bearer ${this.opts.apiKey}`,
537
+ 'x-shogo-hostname': hn,
538
+ 'x-shogo-name': name,
539
+ 'x-shogo-os': os,
540
+ 'x-shogo-arch': arch,
541
+ 'x-shogo-kind': this.opts.kind ?? 'cli-worker',
542
+ },
543
+ };
544
+
545
+ let socket: WebSocket;
546
+ try {
547
+ socket = this.createTunnelWebSocket(url, wsInit);
548
+ } catch (err: any) {
549
+ this.log.error(`[WorkerTunnel] WebSocket creation failed: ${err?.message ?? err}`);
550
+ this.ws = null;
551
+ this.scheduleNextPoll(5);
552
+ return;
553
+ }
554
+ this.ws = socket;
555
+
556
+ socket.onopen = () => {
557
+ this.log.log('[WorkerTunnel] WebSocket connected — session active');
558
+ this.wsReconnectAttempt = 0;
559
+ this.startWsHeartbeat();
560
+ this.resetWsIdleTimer();
561
+ };
562
+
563
+ socket.onmessage = (event) => {
564
+ let msg: IncomingMessage;
565
+ try {
566
+ const raw = typeof event.data === 'string' ? event.data : (event.data as any).toString();
567
+ msg = JSON.parse(raw);
568
+ } catch {
569
+ return;
570
+ }
571
+
572
+ if (msg.type === 'ping') {
573
+ this.sendFrame({ type: 'pong' });
574
+ this.resetWsIdleTimer();
575
+ return;
576
+ }
577
+ if (msg.type === 'cancel') {
578
+ const controller = this.activeAbortControllers.get((msg as CancelMessage).requestId);
579
+ if (controller) controller.abort();
580
+ return;
581
+ }
582
+ if (msg.type === 'request') {
583
+ void this.handleRequest(msg as TunnelRequest).catch((err) => {
584
+ this.log.error(`[WorkerTunnel] Error handling request: ${err?.message ?? err}`);
585
+ });
586
+ return;
587
+ }
588
+ // Unknown message types ignored for forward-compat.
589
+ };
590
+
591
+ socket.onclose = (event: { code: number; reason?: string }) => {
592
+ this.log.log(`[WorkerTunnel] WebSocket closed: code=${event.code} reason=${event.reason || 'none'}`);
593
+ this.cleanupWs();
594
+ if (this.stopped) return;
595
+ if (event.code === 1000 || event.code === 4000) {
596
+ this.scheduleNextPoll(this.currentPollInterval);
597
+ } else {
598
+ this.wsReconnectAttempt++;
599
+ const delay = this.getReconnectDelayMs();
600
+ this.log.log(
601
+ `[WorkerTunnel] Reconnecting in ${Math.round(delay / 1000)}s (attempt ${this.wsReconnectAttempt})`,
602
+ );
603
+ this.scheduleNextPoll(Math.ceil(delay / 1000));
604
+ }
605
+ };
606
+
607
+ socket.onerror = (event: { message?: string }) => {
608
+ this.log.error('[WorkerTunnel] WebSocket error:', event?.message ?? 'unknown');
609
+ };
610
+ }
611
+
612
+ private cleanupWs(): void {
613
+ if (this.heartbeatTimer) {
614
+ clearInterval(this.heartbeatTimer);
615
+ this.heartbeatTimer = null;
616
+ }
617
+ if (this.wsIdleTimer) {
618
+ clearTimeout(this.wsIdleTimer);
619
+ this.wsIdleTimer = null;
620
+ }
621
+ for (const [, controller] of this.activeAbortControllers) {
622
+ try { controller.abort(); } catch { /* nothing to do */ }
623
+ }
624
+ this.activeAbortControllers.clear();
625
+ this.ws = null;
626
+ }
627
+
628
+ /**
629
+ * Test-only access to internals. Returned as a fresh object that closes
630
+ * over `this` so getters reflect live state without needing to re-bind.
631
+ */
632
+ _testing() {
633
+ const self = this;
634
+ return {
635
+ sendHeartbeat: () => self.sendHeartbeat(),
636
+ heartbeatLoop: () => self.heartbeatLoop(),
637
+ connectWs: () => self.connectWs(),
638
+ cleanupWs: () => self.cleanupWs(),
639
+ getCloudUrl: () => self.getCloudUrl(),
640
+ getWsBaseUrl: () => self.getWsBaseUrl(),
641
+ buildWsUrl: () => self.buildWsUrl(),
642
+ getReconnectDelayMs: () => self.getReconnectDelayMs(),
643
+ supportsWebSocketConstructorHeaders: (runtime?: RuntimeWithBunWebSocketHeaders) =>
644
+ self.supportsWebSocketConstructorHeaders(runtime),
645
+ createTunnelWebSocket: (
646
+ url: string,
647
+ init: TunnelWebSocketInit,
648
+ runtime?: RuntimeWithBunWebSocketHeaders,
649
+ ) => self.createTunnelWebSocket(url, init, runtime),
650
+ DEFAULT_POLL_INTERVAL_S,
651
+ BACKOFF_BASE_MS,
652
+ BACKOFF_MAX_MS,
653
+ TUNNEL_PROTOCOL_VERSION,
654
+ get currentPollInterval() { return self.currentPollInterval; },
655
+ set currentPollInterval(v: number) { self.currentPollInterval = v; },
656
+ get wsReconnectAttempt() { return self.wsReconnectAttempt; },
657
+ set wsReconnectAttempt(v: number) { self.wsReconnectAttempt = v; },
658
+ get ws() { return self.ws; },
659
+ get stopped() { return self.stopped; },
660
+ get serverPublishedWsUrl() { return self.serverPublishedWsUrl; },
661
+ set serverPublishedWsUrl(v: string | null) { self.serverPublishedWsUrl = v; },
662
+ };
663
+ }
664
+ }