@shogo-ai/worker 2.3.11 → 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.
- package/dist/cli.mjs +8 -8
- package/package.json +2 -1
- package/src/lib/__tests__/helpers/fake-cloud.ts +139 -0
- package/src/lib/__tests__/tunnel-worker-block.test.ts +243 -0
- package/src/lib/tunnel-host.ts +253 -0
- package/src/lib/tunnel-worker.ts +223 -0
- package/src/lib/tunnel.ts +37 -2
- package/src/lib/web-worker.d.ts +23 -0
|
@@ -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
|
+
}
|