@shogo-ai/worker 1.0.10

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