@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,223 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* The tunnel, in its own thread (SHO-185 §5)
|
|
5
|
+
*
|
|
6
|
+
* `WorkerTunnel` answers the cloud's `{type:'ping'}` with `{type:'pong'}` and
|
|
7
|
+
* drops its socket after an idle window. Run on the local API's thread — which
|
|
8
|
+
* is what it did — a busy moment (a `lsof`, a migration, an agent writing a
|
|
9
|
+
* large file) means no pong, and the cloud closes the socket: the desktop
|
|
10
|
+
* disappears from Studio with a 1006 and the person is told the machine is
|
|
11
|
+
* offline while it is sitting right there.
|
|
12
|
+
*
|
|
13
|
+
* So the socket, its timers and its `fetch`es live here, in a Bun `Worker`,
|
|
14
|
+
* where the only thing that can delay a pong is this file. What the tunnel
|
|
15
|
+
* needs from the main thread — which local URL a request maps to, which runtime
|
|
16
|
+
* token it wants, which projects are up — arrives as messages; the parts of
|
|
17
|
+
* `RuntimeResolver` that are synchronous per heartbeat read a snapshot the host
|
|
18
|
+
* refreshes before each one.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { WorkerTunnel, type ResolveRejection, type RuntimeResolver } from './tunnel'
|
|
22
|
+
|
|
23
|
+
export interface TunnelWorkerInit {
|
|
24
|
+
type: 'init'
|
|
25
|
+
config: {
|
|
26
|
+
apiKey: string
|
|
27
|
+
cloudUrl: string
|
|
28
|
+
name?: string
|
|
29
|
+
kind?: string
|
|
30
|
+
wsUrlOverride?: string
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface HostProjectStatus {
|
|
35
|
+
projectId: string
|
|
36
|
+
status: string
|
|
37
|
+
agentPort?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Worker → host: something the tunnel needs resolved on the main thread. */
|
|
41
|
+
export type WorkerRequest =
|
|
42
|
+
| { type: 'resolve'; id: number; path: string; projectId?: string }
|
|
43
|
+
| { type: 'token'; id: number; projectId: string }
|
|
44
|
+
| { type: 'refresh'; id: number }
|
|
45
|
+
| { type: 'machineSnapshot'; id: number }
|
|
46
|
+
|
|
47
|
+
/** Host → worker: the answer, plus what the host learned meanwhile. */
|
|
48
|
+
export type HostReply =
|
|
49
|
+
| {
|
|
50
|
+
type: 'resolved'
|
|
51
|
+
id: number
|
|
52
|
+
url: string | null
|
|
53
|
+
rejection?: ResolveRejection
|
|
54
|
+
}
|
|
55
|
+
| { type: 'token-result'; id: number; token: string | null }
|
|
56
|
+
| {
|
|
57
|
+
type: 'refresh-result'
|
|
58
|
+
id: number
|
|
59
|
+
projects: HostProjectStatus[]
|
|
60
|
+
/** Keyed `path|projectId`, filled in by the `resolve` that just failed. */
|
|
61
|
+
rejections?: Record<string, ResolveRejection>
|
|
62
|
+
}
|
|
63
|
+
| { type: 'machine-snapshot-result'; id: number; snapshot: Record<string, unknown> | null }
|
|
64
|
+
|
|
65
|
+
/** Worker → host: what happened, so the main thread can answer questions. */
|
|
66
|
+
export type WorkerEvent =
|
|
67
|
+
| { type: 'event'; name: 'instance'; instanceId: string | null }
|
|
68
|
+
| { type: 'event'; name: 'connection'; connected: boolean }
|
|
69
|
+
| { type: 'event'; name: 'auth-revoked'; reason: string }
|
|
70
|
+
| { type: 'log'; level: 'log' | 'warn' | 'error'; message: string }
|
|
71
|
+
| { type: 'ready' }
|
|
72
|
+
|
|
73
|
+
type Pending = { resolve: (value: any) => void }
|
|
74
|
+
|
|
75
|
+
/** A request before the id is assigned. */
|
|
76
|
+
type WorkerRequestInput =
|
|
77
|
+
| { type: 'resolve'; path: string; projectId?: string }
|
|
78
|
+
| { type: 'token'; projectId: string }
|
|
79
|
+
| { type: 'refresh' }
|
|
80
|
+
| { type: 'machineSnapshot' }
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `RuntimeResolver` for the worker: async calls round-trip to the host, sync
|
|
84
|
+
* calls read the snapshot the last `refresh()` left behind.
|
|
85
|
+
*/
|
|
86
|
+
class BridgedResolver implements RuntimeResolver {
|
|
87
|
+
private projects: HostProjectStatus[] = []
|
|
88
|
+
private rejections = new Map<string, ResolveRejection>()
|
|
89
|
+
private pending = new Map<number, Pending>()
|
|
90
|
+
private nextId = 1
|
|
91
|
+
|
|
92
|
+
constructor(private readonly post: (message: WorkerRequest) => void) {}
|
|
93
|
+
|
|
94
|
+
/** Called by the tunnel before each heartbeat. */
|
|
95
|
+
async refresh(): Promise<void> {
|
|
96
|
+
const reply = (await this.request({ type: 'refresh' })) as Extract<HostReply, { type: 'refresh-result' }>
|
|
97
|
+
this.projects = reply.projects ?? []
|
|
98
|
+
this.rejections.clear()
|
|
99
|
+
for (const [key, rejection] of Object.entries(reply.rejections ?? {})) {
|
|
100
|
+
this.rejections.set(key, rejection)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private request(request: WorkerRequestInput): Promise<HostReply> {
|
|
105
|
+
const id = this.nextId++
|
|
106
|
+
return new Promise<HostReply>((resolve) => {
|
|
107
|
+
this.pending.set(id, { resolve })
|
|
108
|
+
this.post({ ...request, id } as WorkerRequest)
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The host's answer to one of our requests. */
|
|
113
|
+
handleReply(reply: HostReply): void {
|
|
114
|
+
const entry = this.pending.get(reply.id)
|
|
115
|
+
if (!entry) return
|
|
116
|
+
this.pending.delete(reply.id)
|
|
117
|
+
if (reply.type === 'resolved' && reply.url === null && reply.rejection) {
|
|
118
|
+
entry.resolve(reply)
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
entry.resolve(reply)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async resolveLocalUrl(pathWithQuery: string, projectId?: string): Promise<string | null> {
|
|
125
|
+
const reply = (await this.request({ type: 'resolve', path: pathWithQuery, projectId })) as Extract<
|
|
126
|
+
HostReply,
|
|
127
|
+
{ type: 'resolved' }
|
|
128
|
+
>
|
|
129
|
+
if (reply.url === null && reply.rejection) {
|
|
130
|
+
this.rejections.set(`${pathWithQuery}|${projectId ?? ''}`, reply.rejection)
|
|
131
|
+
}
|
|
132
|
+
return reply.url
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async deriveRuntimeToken(projectId: string): Promise<string | null> {
|
|
136
|
+
const reply = (await this.request({ type: 'token', projectId })) as Extract<
|
|
137
|
+
HostReply,
|
|
138
|
+
{ type: 'token-result' }
|
|
139
|
+
>
|
|
140
|
+
return reply.token
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
getActiveProjects(): string[] {
|
|
144
|
+
return this.projects.map((project) => project.projectId)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
status(projectId: string): { status: string; agentPort?: number } | null {
|
|
148
|
+
const project = this.projects.find((entry) => entry.projectId === projectId)
|
|
149
|
+
if (!project) return null
|
|
150
|
+
return { status: project.status, agentPort: project.agentPort }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
describeRejection(pathWithQuery: string, projectId?: string): ResolveRejection {
|
|
154
|
+
return (
|
|
155
|
+
this.rejections.get(`${pathWithQuery}|${projectId ?? ''}`) ?? {
|
|
156
|
+
code: 'NO_LOCAL_RUNTIME',
|
|
157
|
+
message: `no local runtime available for path: ${pathWithQuery}`,
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async machineSnapshot(): Promise<Record<string, unknown> | null> {
|
|
163
|
+
const reply = (await this.request({ type: 'machineSnapshot' })) as Extract<
|
|
164
|
+
HostReply,
|
|
165
|
+
{ type: 'machine-snapshot-result' }
|
|
166
|
+
>
|
|
167
|
+
return reply.snapshot
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const scope = self as unknown as {
|
|
172
|
+
postMessage: (message: unknown) => void
|
|
173
|
+
onmessage: ((event: MessageEvent) => void) | null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const post = (message: WorkerEvent | WorkerRequest) => scope.postMessage(message)
|
|
177
|
+
|
|
178
|
+
let tunnel: WorkerTunnel | null = null
|
|
179
|
+
let resolver: BridgedResolver | null = null
|
|
180
|
+
let connected = false
|
|
181
|
+
let instanceId: string | null = null
|
|
182
|
+
|
|
183
|
+
scope.onmessage = (event: MessageEvent) => {
|
|
184
|
+
const data = event.data as TunnelWorkerInit | HostReply
|
|
185
|
+
if (!data || typeof data !== 'object') return
|
|
186
|
+
|
|
187
|
+
if ((data as TunnelWorkerInit).type === 'init') {
|
|
188
|
+
const init = data as TunnelWorkerInit
|
|
189
|
+
resolver = new BridgedResolver(post as (message: WorkerRequest) => void)
|
|
190
|
+
tunnel = new WorkerTunnel({
|
|
191
|
+
...init.config,
|
|
192
|
+
resolver,
|
|
193
|
+
logger: {
|
|
194
|
+
log: (message: string) => post({ type: 'log', level: 'log', message }),
|
|
195
|
+
warn: (message: string) => post({ type: 'log', level: 'warn', message }),
|
|
196
|
+
error: (message: string) => post({ type: 'log', level: 'error', message }),
|
|
197
|
+
},
|
|
198
|
+
onAuthRevoked: (reason: string) => post({ type: 'event', name: 'auth-revoked', reason }),
|
|
199
|
+
})
|
|
200
|
+
tunnel.start()
|
|
201
|
+
post({ type: 'ready' })
|
|
202
|
+
// The cloud only knows this machine's id after the first heartbeat, and the
|
|
203
|
+
// machine copy (SHO-185) pushes under it, so the host has to learn it here.
|
|
204
|
+
setInterval(() => {
|
|
205
|
+
if (!tunnel) return
|
|
206
|
+
const nextInstance = tunnel.instanceId ?? null
|
|
207
|
+
if (nextInstance !== instanceId) {
|
|
208
|
+
instanceId = nextInstance
|
|
209
|
+
post({ type: 'event', name: 'instance', instanceId })
|
|
210
|
+
}
|
|
211
|
+
const nextConnected = tunnel.isConnected()
|
|
212
|
+
if (nextConnected !== connected) {
|
|
213
|
+
connected = nextConnected
|
|
214
|
+
post({ type: 'event', name: 'connection', connected })
|
|
215
|
+
}
|
|
216
|
+
}, 1_000)
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (typeof (data as any).type === 'string' && (data as any).id !== undefined) {
|
|
221
|
+
resolver?.handleReply(data as HostReply)
|
|
222
|
+
}
|
|
223
|
+
}
|
package/src/lib/tunnel.ts
CHANGED
|
@@ -77,7 +77,20 @@ export interface RuntimeResolver {
|
|
|
77
77
|
* carry an actionable code + message. Optional — when absent the
|
|
78
78
|
* tunnel falls back to a generic `NO_LOCAL_RUNTIME` payload.
|
|
79
79
|
*/
|
|
80
|
-
describeRejection?(
|
|
80
|
+
describeRejection?(
|
|
81
|
+
pathWithQuery: string,
|
|
82
|
+
projectId?: string,
|
|
83
|
+
): ResolveRejection | Promise<ResolveRejection>;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Refresh whatever the synchronous calls above read from.
|
|
87
|
+
*
|
|
88
|
+
* The tunnel runs on its own thread (SHO-185 §5), and `getActiveProjects()` /
|
|
89
|
+
* `status()` are synchronous per heartbeat — so a resolver that cannot answer
|
|
90
|
+
* those from its own memory (a bridged one asks the main thread) refreshes in
|
|
91
|
+
* one awaited round trip here, before the heartbeat that reads them.
|
|
92
|
+
*/
|
|
93
|
+
refresh?(): Promise<void>;
|
|
81
94
|
|
|
82
95
|
/**
|
|
83
96
|
* Owner-gated snapshot of this machine's own workspaces (ids, names,
|
|
@@ -183,6 +196,12 @@ export class WorkerTunnel {
|
|
|
183
196
|
private consecutiveAuthFailures = 0;
|
|
184
197
|
private consecutiveAuthSuccesses = 0;
|
|
185
198
|
private serverPublishedWsUrl: string | null = null;
|
|
199
|
+
/**
|
|
200
|
+
* The cloud `instances` row id the last heartbeat reported. The machine copy
|
|
201
|
+
* (SHO-185) pushes under it, and it only exists once the cloud has answered a
|
|
202
|
+
* heartbeat, so it is read from there rather than assumed.
|
|
203
|
+
*/
|
|
204
|
+
private lastInstanceId: string | null = null;
|
|
186
205
|
private readonly activeAbortControllers = new Map<string, AbortController>();
|
|
187
206
|
|
|
188
207
|
constructor(opts: WorkerTunnelOptions) {
|
|
@@ -190,6 +209,11 @@ export class WorkerTunnel {
|
|
|
190
209
|
this.log = opts.logger ?? console;
|
|
191
210
|
}
|
|
192
211
|
|
|
212
|
+
/** The cloud instance id for this machine, or null before the first heartbeat. */
|
|
213
|
+
get instanceId(): string | null {
|
|
214
|
+
return this.lastInstanceId;
|
|
215
|
+
}
|
|
216
|
+
|
|
193
217
|
// ─── Public lifecycle ────────────────────────────────────────────
|
|
194
218
|
|
|
195
219
|
start(): void {
|
|
@@ -270,6 +294,13 @@ export class WorkerTunnel {
|
|
|
270
294
|
}
|
|
271
295
|
|
|
272
296
|
private async collectMetadata(): Promise<Record<string, unknown>> {
|
|
297
|
+
// One awaited round trip for everything read synchronously below, so the
|
|
298
|
+
// heartbeat carries this cycle's runtimes rather than the last thread's.
|
|
299
|
+
try {
|
|
300
|
+
await this.opts.resolver.refresh?.();
|
|
301
|
+
} catch {
|
|
302
|
+
// A stale snapshot is better than no heartbeat at all.
|
|
303
|
+
}
|
|
273
304
|
const meta: Record<string, unknown> = {
|
|
274
305
|
hostname: osHostname(),
|
|
275
306
|
os: platform(),
|
|
@@ -334,6 +365,10 @@ export class WorkerTunnel {
|
|
|
334
365
|
|
|
335
366
|
const data = (await resp.json()) as HeartbeatResponse;
|
|
336
367
|
|
|
368
|
+
if (typeof data.instanceId === 'string' && data.instanceId.length > 0) {
|
|
369
|
+
this.lastInstanceId = data.instanceId;
|
|
370
|
+
}
|
|
371
|
+
|
|
337
372
|
if (typeof data.wsUrl === 'string' && data.wsUrl.length > 0) {
|
|
338
373
|
if (data.wsUrl !== this.serverPublishedWsUrl) {
|
|
339
374
|
this.log.log(`[WorkerTunnel] Cloud advertised tunnel WS URL: ${data.wsUrl}`);
|
|
@@ -439,7 +474,7 @@ export class WorkerTunnel {
|
|
|
439
474
|
// back the original path so the operator doesn't have to
|
|
440
475
|
// correlate request-ids to figure out which fetch failed.
|
|
441
476
|
const rejection: ResolveRejection = this.opts.resolver.describeRejection
|
|
442
|
-
? this.opts.resolver.describeRejection(msg.path, msg.projectId)
|
|
477
|
+
? await this.opts.resolver.describeRejection(msg.path, msg.projectId)
|
|
443
478
|
: { code: 'NO_LOCAL_RUNTIME', message: `no local runtime available for path: ${msg.path}` };
|
|
444
479
|
this.sendFrame({
|
|
445
480
|
type: 'response',
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* The slice of the web Worker API the tunnel uses (SHO-185 §5).
|
|
5
|
+
*
|
|
6
|
+
* This package compiles against `lib: ["ES2022"]` — no DOM, no WebWorker — while
|
|
7
|
+
* Bun supplies both globals at runtime. Declaring only what the tunnel touches
|
|
8
|
+
* keeps the surface honest: a new worker global has to be added here, and the
|
|
9
|
+
* tunnel keeps its real types instead of a cast at every use.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
declare const self: {
|
|
13
|
+
postMessage: (message: unknown) => void
|
|
14
|
+
onmessage: ((event: MessageEvent) => void) | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
declare class Worker {
|
|
18
|
+
constructor(url: URL)
|
|
19
|
+
onmessage: ((event: MessageEvent) => void) | null
|
|
20
|
+
onerror: ((event: unknown) => void) | null
|
|
21
|
+
postMessage(message: unknown): void
|
|
22
|
+
terminate(): void
|
|
23
|
+
}
|