@shogo-ai/worker 2.3.12 → 2.3.13

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,139 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * A fake Shogo cloud, in its own process, for the tunnel-block test.
5
+ *
6
+ * It has to be a separate process and not an in-test server: the test blocks the
7
+ * main thread on purpose, and an observer on that thread cannot see the pings it
8
+ * is supposed to be policing. Here the pings keep flowing while the API's thread
9
+ * is busy, which is exactly how the real cloud behaves.
10
+ *
11
+ * Speaks the real protocol: `POST /api/instances/heartbeat` (asking for a socket
12
+ * and advertising its URL), `GET /api/instances/ws` upgrade, `{type:'ping'}`
13
+ * every 40ms answered by `{type:'pong'}`, and a close (1006) for a socket that
14
+ * misses the deadline — the same idle check the cloud runs.
15
+ *
16
+ * Reports over stdout, one JSON object per line: `ready`, `pong`, `kill`,
17
+ * `request`, `close`.
18
+ */
19
+
20
+ const PING_INTERVAL_MS = Number(process.env.FAKE_CLOUD_PING_MS ?? 40)
21
+ const PONG_DEADLINE_MS = Number(process.env.FAKE_CLOUD_DEADLINE_MS ?? 120)
22
+
23
+ const say = (line: Record<string, unknown>) => {
24
+ process.stdout.write(`${JSON.stringify(line)}\n`)
25
+ }
26
+
27
+ let socket: any = null
28
+ let awaitingPongSince: number | null = null
29
+ let kills = 0
30
+
31
+ const server = Bun.serve({
32
+ port: 0,
33
+ hostname: '127.0.0.1',
34
+ fetch(req, srv) {
35
+ const url = new URL(req.url)
36
+ if (url.pathname === '/api/instances/ws') {
37
+ if (srv.upgrade(req)) return undefined as any
38
+ return new Response('upgrade failed', { status: 400 })
39
+ }
40
+ if (url.pathname === '/api/instances/heartbeat') {
41
+ return Response.json({
42
+ instanceId: 'inst-worker-1',
43
+ wsRequested: true,
44
+ nextPollIn: 30,
45
+ wsUrl: `ws://127.0.0.1:${srv.port}`,
46
+ })
47
+ }
48
+ return new Response('{}', { status: 404 })
49
+ },
50
+ websocket: {
51
+ open(ws) {
52
+ socket = ws
53
+ awaitingPongSince = null
54
+ },
55
+ message(_ws, raw) {
56
+ let message: any
57
+ try {
58
+ message = JSON.parse(String(raw))
59
+ } catch {
60
+ return
61
+ }
62
+ if (message.type === 'pong') {
63
+ awaitingPongSince = null
64
+ say({ event: 'pong' })
65
+ return
66
+ }
67
+ if (message.type === 'response') {
68
+ say({ event: 'request', status: message.status, body: String(message.body ?? '').slice(0, 200) })
69
+ return
70
+ }
71
+ if (message.type === 'stream-chunk' || message.type === 'stream-end') {
72
+ say({ event: 'request', status: 200, body: 'stream' })
73
+ }
74
+ },
75
+ close(_ws, code) {
76
+ socket = null
77
+ say({ event: 'close', code })
78
+ },
79
+ },
80
+ })
81
+
82
+ setInterval(() => {
83
+ if (!socket) return
84
+ if (awaitingPongSince === null) {
85
+ awaitingPongSince = Date.now()
86
+ } else if (Date.now() - awaitingPongSince > PONG_DEADLINE_MS) {
87
+ kills += 1
88
+ say({ event: 'kill', kills })
89
+ try {
90
+ socket.close(1006, 'no pong')
91
+ } catch {
92
+ // already gone
93
+ }
94
+ socket = null
95
+ awaitingPongSince = null
96
+ return
97
+ }
98
+ try {
99
+ socket.send(JSON.stringify({ type: 'ping' }))
100
+ } catch {
101
+ // socket went away
102
+ }
103
+ }, PING_INTERVAL_MS)
104
+
105
+ // The test drives requests over stdin (one JSON object per line), because a
106
+ // spawned child has no IPC channel to listen on.
107
+ void (async () => {
108
+ let buffer = ''
109
+ const decoder = new TextDecoder()
110
+ for await (const chunk of Bun.stdin.stream()) {
111
+ buffer += decoder.decode(chunk as Uint8Array, { stream: true })
112
+ let newline = buffer.indexOf('\n')
113
+ while (newline !== -1) {
114
+ const line = buffer.slice(0, newline).trim()
115
+ buffer = buffer.slice(newline + 1)
116
+ newline = buffer.indexOf('\n')
117
+ if (!line) continue
118
+ let message: any
119
+ try {
120
+ message = JSON.parse(line)
121
+ } catch {
122
+ continue
123
+ }
124
+ if (message?.type === 'request' && socket) {
125
+ socket.send(
126
+ JSON.stringify({
127
+ type: 'request',
128
+ requestId: message.requestId,
129
+ method: 'GET',
130
+ path: message.path,
131
+ headers: {},
132
+ }),
133
+ )
134
+ }
135
+ }
136
+ }
137
+ })()
138
+
139
+ say({ event: 'ready', port: server.port })
@@ -0,0 +1,243 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * The tunnel's socket survives a busy main thread (SHO-185 §5).
5
+ *
6
+ * This is the test the whole Worker move exists for. The fake cloud lives in its
7
+ * own process (`helpers/fake-cloud.ts`) because the point of the test is to block
8
+ * *this* thread: an observer here would be blocked too and could not see the
9
+ * pings it is supposed to be policing.
10
+ *
11
+ * Two cases, same cloud, same block:
12
+ * • on its own thread — pongs keep flowing, the socket stays open, and a
13
+ * tunneled request still round-trips afterwards;
14
+ * • on the API's thread — the deadline passes unanswered and the cloud closes
15
+ * the socket (1006). That is the bug, and without it the first case would
16
+ * only prove that a socket somewhere stayed open.
17
+ */
18
+
19
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
20
+ import { createServer, type Server } from 'node:http'
21
+ import type { AddressInfo } from 'node:net'
22
+ import { fileURLToPath } from 'node:url'
23
+ import { startTunnelInWorker } from '../tunnel-host'
24
+ import { WorkerTunnel, type RuntimeResolver } from '../tunnel'
25
+
26
+ const BLOCK_MS = 400
27
+
28
+ interface FakeCloud {
29
+ cloudUrl: string
30
+ pongs: number
31
+ kills: number
32
+ closeCodes: number[]
33
+ requests: { status: number; body: string }[]
34
+ sendRequest: (requestId: string, path: string) => void
35
+ stop: () => void
36
+ }
37
+
38
+ /**
39
+ * Spawn the fake cloud and read its stdout, one JSON event per line.
40
+ *
41
+ * The child is `bun --no-env-file` so the workspace's `.env` cannot point it at a
42
+ * real cloud, and it prints `ready` with the port it bound before anything else.
43
+ */
44
+ async function startFakeCloud(): Promise<FakeCloud> {
45
+ // `pathname` stays percent-encoded, and this checkout's path has a space in it.
46
+ const entry = fileURLToPath(new URL('./helpers/fake-cloud.ts', import.meta.url))
47
+ const child = Bun.spawn(['bun', '--no-env-file', entry], {
48
+ stdout: 'pipe',
49
+ stdin: 'pipe',
50
+ stderr: 'inherit',
51
+ env: { ...process.env, FAKE_CLOUD_PING_MS: '40', FAKE_CLOUD_DEADLINE_MS: '120' },
52
+ })
53
+
54
+ const state = {
55
+ pongs: 0,
56
+ kills: 0,
57
+ closeCodes: [] as number[],
58
+ requests: [] as { status: number; body: string }[],
59
+ }
60
+
61
+ let port = 0
62
+ let buffer = ''
63
+ let settleReady: (port: number) => void = () => {}
64
+ const ready = new Promise<number>((resolve) => {
65
+ settleReady = resolve
66
+ })
67
+
68
+ const drain = (async () => {
69
+ const stream = child.stdout as ReadableStream<Uint8Array>
70
+ const reader = stream.getReader()
71
+ const decoder = new TextDecoder()
72
+ while (true) {
73
+ const { done, value } = await reader.read()
74
+ if (done) break
75
+ buffer += decoder.decode(value, { stream: true })
76
+ let newline = buffer.indexOf('\n')
77
+ while (newline !== -1) {
78
+ const line = buffer.slice(0, newline).trim()
79
+ buffer = buffer.slice(newline + 1)
80
+ newline = buffer.indexOf('\n')
81
+ if (!line) continue
82
+ let event: any
83
+ try {
84
+ event = JSON.parse(line)
85
+ } catch {
86
+ continue
87
+ }
88
+ if (event.event === 'ready') {
89
+ port = Number(event.port)
90
+ settleReady(port)
91
+ } else if (event.event === 'pong') state.pongs += 1
92
+ else if (event.event === 'kill') state.kills += 1
93
+ else if (event.event === 'close') state.closeCodes.push(Number(event.code))
94
+ else if (event.event === 'request') {
95
+ state.requests.push({ status: Number(event.status), body: String(event.body ?? '') })
96
+ }
97
+ }
98
+ }
99
+ })().catch(() => {})
100
+
101
+ await ready
102
+
103
+ return {
104
+ cloudUrl: `http://127.0.0.1:${port}`,
105
+ get pongs() {
106
+ return state.pongs
107
+ },
108
+ get kills() {
109
+ return state.kills
110
+ },
111
+ get closeCodes() {
112
+ return state.closeCodes
113
+ },
114
+ get requests() {
115
+ return state.requests
116
+ },
117
+ sendRequest(requestId: string, path: string) {
118
+ child.stdin?.write(`${JSON.stringify({ type: 'request', requestId, path })}\n`)
119
+ },
120
+ stop() {
121
+ try {
122
+ child.kill()
123
+ } catch {
124
+ // already gone
125
+ }
126
+ void drain
127
+ },
128
+ }
129
+ }
130
+
131
+ let localApi: Server
132
+ let localPort = 0
133
+
134
+ const resolver: RuntimeResolver = {
135
+ async resolveLocalUrl(pathWithQuery: string) {
136
+ return `http://127.0.0.1:${localPort}${pathWithQuery}`
137
+ },
138
+ async deriveRuntimeToken() {
139
+ return 'runtime-token'
140
+ },
141
+ getActiveProjects() {
142
+ return ['proj-1']
143
+ },
144
+ status() {
145
+ return { status: 'running', agentPort: 1234 }
146
+ },
147
+ }
148
+
149
+ beforeAll(async () => {
150
+ localApi = createServer((req, res) => {
151
+ res.setHeader('content-type', 'application/json')
152
+ res.end(JSON.stringify({ answeredBy: 'local-api', path: req.url }))
153
+ })
154
+ await new Promise<void>((resolve) => localApi.listen(0, '127.0.0.1', () => resolve()))
155
+ localPort = (localApi.address() as AddressInfo).port
156
+ })
157
+
158
+ afterAll(async () => {
159
+ await new Promise<void>((resolve) => localApi.close(() => resolve()))
160
+ })
161
+
162
+ /** Burn this thread — the local API's thread — past the cloud's deadline. */
163
+ function blockMainThread(ms: number): void {
164
+ const until = Date.now() + ms
165
+ while (Date.now() < until) {
166
+ // A busy loop is the honest stand-in for a `lsof` or a big write.
167
+ }
168
+ }
169
+
170
+ const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
171
+
172
+ async function waitFor(predicate: () => boolean, timeoutMs = 8_000): Promise<void> {
173
+ const started = Date.now()
174
+ while (Date.now() < started + timeoutMs) {
175
+ if (predicate()) return
176
+ await wait(20)
177
+ }
178
+ throw new Error(`timed out after ${timeoutMs}ms waiting for a condition that never held`)
179
+ }
180
+
181
+ describe('a tunnel on its own thread', () => {
182
+ test('keeps its socket through a main thread blocked for 4× the ping interval', async () => {
183
+ const cloud = await startFakeCloud()
184
+ const host = startTunnelInWorker({
185
+ apiKey: 'test-key',
186
+ cloudUrl: cloud.cloudUrl,
187
+ name: 'worker-test',
188
+ kind: 'desktop',
189
+ resolver,
190
+ logger: { log: () => {}, warn: () => {}, error: () => {} },
191
+ })
192
+
193
+ try {
194
+ await host.ready()
195
+ // The socket only opens once the cloud asks for it on a heartbeat.
196
+ await waitFor(() => cloud.pongs > 0)
197
+ const before = cloud.pongs
198
+
199
+ blockMainThread(BLOCK_MS)
200
+
201
+ // The cloud never reached for the close code, and the pongs never stopped.
202
+ expect(cloud.kills).toBe(0)
203
+ expect(cloud.closeCodes).toEqual([])
204
+ await waitFor(() => cloud.pongs > before, 3_000)
205
+
206
+ // And the tunnel still forwards afterwards: a request pushed down the
207
+ // socket comes back from the local API.
208
+ cloud.sendRequest('req-after-block', '/api/projects')
209
+ await waitFor(() => cloud.requests.some((entry) => entry.status === 200), 8_000)
210
+ expect(cloud.requests.find((entry) => entry.status === 200)?.body).toContain('local-api')
211
+ } finally {
212
+ host.stop()
213
+ cloud.stop()
214
+ }
215
+ }, 40_000)
216
+
217
+ test('the same block kills an in-process tunnel — the bug §5 fixes', async () => {
218
+ const cloud = await startFakeCloud()
219
+ const tunnel = new WorkerTunnel({
220
+ apiKey: 'test-key',
221
+ cloudUrl: cloud.cloudUrl,
222
+ name: 'in-process',
223
+ kind: 'desktop',
224
+ resolver,
225
+ logger: { log: () => {}, warn: () => {}, error: () => {} },
226
+ })
227
+
228
+ try {
229
+ tunnel.start()
230
+ await waitFor(() => cloud.pongs > 0)
231
+
232
+ blockMainThread(BLOCK_MS)
233
+
234
+ // The cloud's deadline passed with the pong stuck behind the block, so it
235
+ // closed the socket: this is the 1006 the person sees as "machine offline".
236
+ await waitFor(() => cloud.kills > 0)
237
+ expect(cloud.kills).toBeGreaterThan(0)
238
+ } finally {
239
+ tunnel.stop()
240
+ cloud.stop()
241
+ }
242
+ }, 40_000)
243
+ })
@@ -0,0 +1,253 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * Main-thread side of the tunnel Worker (SHO-185 §5)
5
+ *
6
+ * `startTunnelInWorker()` puts `WorkerTunnel` on its own thread and answers the
7
+ * questions it cannot answer itself: which local URL a path maps to, the runtime
8
+ * token for a project, and — once per heartbeat — which runtimes are up. The
9
+ * socket, its timers and its local `fetch`es never touch this thread, so a busy
10
+ * local API cannot cost the machine its connection any more.
11
+ */
12
+
13
+ import { existsSync } from 'node:fs'
14
+ import { dirname, join } from 'node:path'
15
+ import { fileURLToPath, pathToFileURL } from 'node:url'
16
+ import type { ResolveRejection, RuntimeResolver } from './tunnel'
17
+ import type { HostProjectStatus, HostReply, WorkerEvent, WorkerRequest } from './tunnel-worker'
18
+
19
+ /**
20
+ * How long the worker gets to answer `ready` before it is written off.
21
+ *
22
+ * The worker posts `ready` from its `init` handler, before it touches the
23
+ * network, so this is only ever spent on a worker that never came up at all
24
+ * (crashed on load, wrong entry file, a runtime that quietly refuses to spawn
25
+ * one). A tunnel that is slow to connect is not slow to be ready.
26
+ */
27
+ const READY_TIMEOUT_MS = 10_000
28
+
29
+ /** This module's directory: `resources/bundle/` once packaged, `src/lib/` in a checkout. */
30
+ const HOST_DIR = dirname(fileURLToPath(import.meta.url))
31
+
32
+ /**
33
+ * The Worker entry file that sits next to this module.
34
+ *
35
+ * A source checkout has only `tunnel-worker.ts`; the desktop ships the compiled
36
+ * `resources/bundle/tunnel-worker.js` (see `apps/desktop/scripts/bundle-api.mjs`)
37
+ * and no source tree at all. Relative to `import.meta.url` — which inside
38
+ * `resources/bundle/api.js` is the bundle itself — `new URL('./tunnel-worker.ts')`
39
+ * could only ever name the checkout, so the packaged app asked for a `.ts` file
40
+ * that was never shipped, the worker never started, and the tunnel vanished.
41
+ *
42
+ * `SHOGO_TUNNEL_WORKER_PATH` overrides the search for packagers that relocate
43
+ * the bundle.
44
+ */
45
+ export function resolveTunnelWorkerEntry(env: NodeJS.ProcessEnv = process.env): string {
46
+ const override = env.SHOGO_TUNNEL_WORKER_PATH?.trim()
47
+ if (override) return override
48
+
49
+ const candidates = [join(HOST_DIR, 'tunnel-worker.js'), join(HOST_DIR, 'tunnel-worker.ts')]
50
+ const found = candidates.find((candidate) => existsSync(candidate))
51
+ if (found) return found
52
+
53
+ throw new Error(
54
+ `no tunnel worker entry next to ${HOST_DIR} (looked for ${candidates.join(', ')}); ` +
55
+ `packaging must ship the tunnel-worker build output beside the API bundle`,
56
+ )
57
+ }
58
+
59
+ export interface TunnelHostOptions {
60
+ apiKey: string
61
+ cloudUrl: string
62
+ name?: string
63
+ kind?: string
64
+ wsUrlOverride?: string
65
+ /** Everything the tunnel asks about the local machine. */
66
+ resolver: RuntimeResolver
67
+ logger?: Pick<Console, 'log' | 'warn' | 'error'>
68
+ onAuthRevoked?: (reason: string) => void
69
+ /** Called when the cloud tells us this machine's instance id (or forgets it). */
70
+ onInstanceId?: (instanceId: string | null) => void
71
+ /** Called when the socket's connected state changes. */
72
+ onConnection?: (connected: boolean) => void
73
+ /** The worker died (on load or later). The host is not usable after this. */
74
+ onWorkerError?: (error: Error) => void
75
+ /** Overrides the readiness deadline. Tests only. */
76
+ readyTimeoutMs?: number
77
+ }
78
+
79
+ export interface TunnelHost {
80
+ stop(): void
81
+ instanceId(): string | null
82
+ isConnected(): boolean
83
+ /** Resolves once the worker has the tunnel running. */
84
+ ready(): Promise<void>
85
+ }
86
+
87
+ /**
88
+ * Start the tunnel on its own thread.
89
+ *
90
+ * Throws if the worker cannot be created (a bundle without the entry file, a
91
+ * runtime that will not spawn one), which is the caller's cue to fall back to an
92
+ * in-process tunnel rather than lose remote control entirely. A worker that is
93
+ * created but never answers `ready` rejects `ready()` for the same reason — the
94
+ * throw alone is not enough, because a missing entry file does not always fail
95
+ * synchronously.
96
+ */
97
+ export function startTunnelInWorker(options: TunnelHostOptions): TunnelHost {
98
+ const log = options.logger ?? console
99
+ const entry = resolveTunnelWorkerEntry()
100
+ // The web Worker API, which is what Bun implements (and what the worker file
101
+ // uses on its side with `self.postMessage`). `node:worker_threads` needs
102
+ // `parentPort` instead, and mixing the two silently never delivers a message.
103
+ const worker = new Worker(pathToFileURL(entry))
104
+
105
+ const readyTimeoutMs = options.readyTimeoutMs ?? READY_TIMEOUT_MS
106
+ let instanceId: string | null = null
107
+ let connected = false
108
+ let stopped = false
109
+ let readySettled = false
110
+ let settleReady: () => void = () => {}
111
+ let failReady: (error: Error) => void = () => {}
112
+ const ready = new Promise<void>((resolve, reject) => {
113
+ settleReady = resolve
114
+ failReady = reject
115
+ })
116
+ // Nobody is obliged to await `ready()`; an unobserved rejection would take the
117
+ // process down instead of letting the caller fall back.
118
+ void ready.catch(() => {})
119
+
120
+ const readyTimer = setTimeout(() => {
121
+ failReady(new Error(`tunnel worker (${entry}) did not answer ready within ${readyTimeoutMs}ms`))
122
+ }, readyTimeoutMs)
123
+ void ready.then(
124
+ () => {
125
+ readySettled = true
126
+ clearTimeout(readyTimer)
127
+ },
128
+ () => {
129
+ readySettled = true
130
+ clearTimeout(readyTimer)
131
+ },
132
+ )
133
+
134
+ /** What the last `resolve` decided, so a rejection needs no extra round trip. */
135
+ const rejections = new Map<string, ResolveRejection>()
136
+
137
+ worker.onmessage = (event: MessageEvent) => {
138
+ const data = event.data as any
139
+ if (!data || typeof data !== 'object') return
140
+
141
+ if (data.type === 'ready') {
142
+ settleReady()
143
+ return
144
+ }
145
+
146
+ if (data.type === 'log') {
147
+ const sink = data.level === 'error' ? log.error : data.level === 'warn' ? log.warn : log.log
148
+ sink.call(log, data.message)
149
+ return
150
+ }
151
+
152
+ if (data.type === 'event') {
153
+ if (data.name === 'instance') {
154
+ instanceId = data.instanceId ?? null
155
+ options.onInstanceId?.(instanceId)
156
+ } else if (data.name === 'connection') {
157
+ connected = Boolean(data.connected)
158
+ options.onConnection?.(connected)
159
+ } else if (data.name === 'auth-revoked') {
160
+ options.onAuthRevoked?.(String(data.reason ?? 'auth revoked'))
161
+ }
162
+ return
163
+ }
164
+
165
+ void answer(data as WorkerRequest)
166
+ }
167
+
168
+ worker.onerror = (event: any) => {
169
+ const message = event?.message ?? event
170
+ log.error(`[TunnelHost] worker error: ${message}`)
171
+ const error = new Error(`tunnel worker failed: ${message}`)
172
+ // A worker that dies on load never posts `ready`. Reject now so the caller
173
+ // can fall back instead of waiting out the whole deadline.
174
+ if (!readySettled) failReady(error)
175
+ // A worker that dies later leaves a socket nobody is watching, which looks
176
+ // exactly like an offline machine. Same answer: hand it back to the caller.
177
+ options.onWorkerError?.(error)
178
+ }
179
+
180
+ async function answer(request: WorkerRequest): Promise<void> {
181
+ const resolver = options.resolver
182
+ try {
183
+ if (request.type === 'resolve') {
184
+ const url = await resolver.resolveLocalUrl(request.path, request.projectId)
185
+ const rejection =
186
+ url === null
187
+ ? await Promise.resolve(resolver.describeRejection?.(request.path, request.projectId))
188
+ : undefined
189
+ if (rejection) rejections.set(`${request.path}|${request.projectId ?? ''}`, rejection)
190
+ reply({ type: 'resolved', id: request.id, url, rejection })
191
+ return
192
+ }
193
+
194
+ if (request.type === 'token') {
195
+ const token = await resolver.deriveRuntimeToken(request.projectId)
196
+ reply({ type: 'token-result', id: request.id, token })
197
+ return
198
+ }
199
+
200
+ if (request.type === 'refresh') {
201
+ const projects: HostProjectStatus[] = resolver.getActiveProjects().map((projectId) => {
202
+ const status = resolver.status(projectId)
203
+ return { projectId, status: status?.status ?? 'unknown', agentPort: status?.agentPort }
204
+ })
205
+ reply({
206
+ type: 'refresh-result',
207
+ id: request.id,
208
+ projects,
209
+ rejections: Object.fromEntries(rejections),
210
+ })
211
+ return
212
+ }
213
+
214
+ if (request.type === 'machineSnapshot') {
215
+ const snapshot = (await resolver.machineSnapshot?.()) ?? null
216
+ reply({ type: 'machine-snapshot-result', id: request.id, snapshot })
217
+ }
218
+ } catch (error: any) {
219
+ log.warn(`[TunnelHost] ${request.type} failed: ${error?.message ?? error}`)
220
+ if (request.type === 'resolve') reply({ type: 'resolved', id: request.id, url: null })
221
+ else if (request.type === 'token') reply({ type: 'token-result', id: request.id, token: null })
222
+ else if (request.type === 'refresh') reply({ type: 'refresh-result', id: request.id, projects: [] })
223
+ else reply({ type: 'machine-snapshot-result', id: request.id, snapshot: null })
224
+ }
225
+ }
226
+
227
+ function reply(message: HostReply): void {
228
+ if (stopped) return
229
+ worker.postMessage(message)
230
+ }
231
+
232
+ worker.postMessage({
233
+ type: 'init',
234
+ config: {
235
+ apiKey: options.apiKey,
236
+ cloudUrl: options.cloudUrl,
237
+ name: options.name,
238
+ kind: options.kind,
239
+ wsUrlOverride: options.wsUrlOverride,
240
+ },
241
+ })
242
+
243
+ return {
244
+ stop() {
245
+ stopped = true
246
+ clearTimeout(readyTimer)
247
+ void worker.terminate()
248
+ },
249
+ instanceId: () => instanceId,
250
+ isConnected: () => connected,
251
+ ready: () => ready,
252
+ }
253
+ }