wave-code 1.3.0 → 1.5.0
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/bundle/wave.mjs +1382 -1131
- package/package.json +9 -7
- package/src/cli.tsx +14 -22
- package/src/commands/update.ts +62 -22
- package/src/components/AgentsManager.tsx +3 -3
- package/src/components/App.tsx +5 -0
- package/src/components/BackgroundTaskManager.tsx +76 -54
- package/src/components/BtwDisplay.tsx +71 -15
- package/src/components/CompactDisplay.tsx +1 -6
- package/src/components/ConfirmationDetails.tsx +34 -13
- package/src/components/ConfirmationSelector.tsx +31 -36
- package/src/components/DiffDisplay.tsx +1 -1
- package/src/components/HooksManager.tsx +3 -3
- package/src/components/InputBox.tsx +37 -11
- package/src/components/LoginCommand.tsx +16 -35
- package/src/components/Markdown.tsx +7 -3
- package/src/components/MarketplaceAddForm.tsx +15 -19
- package/src/components/MessageBlockItem.tsx +0 -6
- package/src/components/RemoteControlView.tsx +175 -0
- package/src/components/ScrolledContent.tsx +15 -13
- package/src/components/SkillsManager.tsx +3 -3
- package/src/components/TaskList.tsx +3 -3
- package/src/components/ToolDisplay.tsx +3 -18
- package/src/components/WorkflowManager.tsx +150 -136
- package/src/components/WorkflowRunDetail.tsx +193 -0
- package/src/constants/commands.ts +6 -0
- package/src/contexts/useChat.tsx +308 -154
- package/src/daemon/commands.ts +31 -22
- package/src/daemon-cli.ts +6 -1
- package/src/hooks/useInputManager.ts +25 -39
- package/src/hooks/useLineScroll.ts +51 -5
- package/src/hooks/useRemoteControl.ts +346 -0
- package/src/index.ts +35 -0
- package/src/managers/inputHandlers.ts +5 -0
- package/src/managers/inputReducer.ts +39 -0
- package/src/print-cli.ts +5 -0
- package/src/reducers/workflowManagerReducer.ts +21 -2
- package/src/remote/frameBudget.ts +45 -0
- package/src/remote/hostIdentity.ts +128 -0
- package/src/remote/messageWindow.ts +46 -0
- package/src/remote/notificationCoalescer.ts +192 -0
- package/src/remote/relayClient.ts +690 -0
- package/src/remote/remoteControlCliHost.ts +420 -0
- package/src/remote/remoteControlCommands.ts +197 -0
- package/src/remote/remoteControlConfig.ts +353 -0
- package/src/remote/remoteControlService.ts +822 -0
- package/src/remote/remoteControlSink.ts +78 -0
- package/src/remote/remoteNotifications.ts +200 -0
- package/src/stdio/agentBridge.ts +874 -106
- package/src/stdio/daemonServer.ts +30 -0
- package/src/stdio/hostSlashCommands.ts +209 -0
- package/src/stdio/jsonRpcConnection.ts +11 -2
- package/src/stdio/protocol.ts +14 -115
- package/src/stdio/stdioServer.ts +33 -2
- package/src/stdio/uiPayload.ts +65 -0
- package/src/stdio-cli.ts +11 -2
- package/src/types.ts +7 -0
- package/src/utils/gitCredentialHelper.ts +106 -0
- package/src/utils/logger.ts +5 -2
- package/src/utils/rewindCheckpoints.ts +2 -4
- package/src/utils/workspaceDiff.ts +620 -0
- package/src/utils/worktree.ts +97 -0
- package/src/utils/bracketedPaste.ts +0 -170
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RelayClient — the machine's outbound connection to the remote-control relay.
|
|
3
|
+
*
|
|
4
|
+
* The phone never connects to this machine: it cannot (no public address, no
|
|
5
|
+
* port forwarding). Instead this machine dials out to the relay and subscribes
|
|
6
|
+
* to its own channel:
|
|
7
|
+
*
|
|
8
|
+
* wss://<relay>/v1/hosts/ws/{hostId}/subscribe
|
|
9
|
+
*
|
|
10
|
+
* The channel is keyed by **this machine**, not by session — a session-level
|
|
11
|
+
* channel cannot work here, because the phone has to learn the session list
|
|
12
|
+
* from this machine in the first place, and a session-level channel would leave
|
|
13
|
+
* the list screen with nowhere to ask (see `codechat#40` §1 for the three
|
|
14
|
+
* reasons). One connection therefore carries every session: `sessionId` is
|
|
15
|
+
* already a field of every JSON-RPC envelope the bridge emits
|
|
16
|
+
* (`agentBridge.emit(method, params, sessionId)`), so the relay keeps forwarding
|
|
17
|
+
* bytes without ever parsing what they mean.
|
|
18
|
+
*
|
|
19
|
+
* Everything about the stream is deliberately dumb: one JSON object per
|
|
20
|
+
* WebSocket message, the same envelope the local transports use, plus the
|
|
21
|
+
* **control frames the relay itself owns**. Those are told apart structurally,
|
|
22
|
+
* not by a whitelist of names: a control frame is a JSON object with a
|
|
23
|
+
* top-level `type` and no `jsonrpc`, and the relay never forwards one — which
|
|
24
|
+
* is exactly why the machine has to speak them (`auth`, `ping`, `host_state`)
|
|
25
|
+
* and parse them (`auth_ok`, `auth_error`, `peer_disconnected`,
|
|
26
|
+
* `resync_required`, `pong`). Everything else is forwarded byte for byte,
|
|
27
|
+
* unparsed, in both directions.
|
|
28
|
+
*
|
|
29
|
+
* A control frame that arrives malformed is dropped, never fed to the local
|
|
30
|
+
* JSON-RPC layer: the relay is a shared component and a future control frame
|
|
31
|
+
* we do not recognise must not take the phone offline or reach the agent.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** One notification or request travelling in either direction. */
|
|
35
|
+
export interface RelayEnvelope {
|
|
36
|
+
method: string;
|
|
37
|
+
params?: unknown;
|
|
38
|
+
sessionId?: string;
|
|
39
|
+
id?: number | string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Who this connection is. The relay has exactly two credentials and tells them
|
|
44
|
+
* apart by this field alone:
|
|
45
|
+
*
|
|
46
|
+
* - `host` — the machine being controlled, holding the long-lived token the
|
|
47
|
+
* relay minted at registration (`rch_…`).
|
|
48
|
+
* - `sso` — a phone, holding the user's login JWT.
|
|
49
|
+
*
|
|
50
|
+
* The two cannot be inferred from the caller: the phone and the machine belong
|
|
51
|
+
* to the **same person**, so there is no identity to distinguish them by. And
|
|
52
|
+
* the machine's link has to outlive an hour-long JWT — it is reconnected for
|
|
53
|
+
* hours — so it needs the revocable `rch_` token rather than a login token.
|
|
54
|
+
*/
|
|
55
|
+
export interface RelayCredential {
|
|
56
|
+
type: "host" | "sso";
|
|
57
|
+
/** Never logged. */
|
|
58
|
+
token: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The control frames the relay sends us. Not forwarded, not JSON-RPC. */
|
|
62
|
+
export type RelayControlFrame =
|
|
63
|
+
| { type: "auth_ok"; role?: string; hostId?: string }
|
|
64
|
+
| { type: "auth_error"; error: string }
|
|
65
|
+
| { type: "peer_disconnected"; hostId?: string }
|
|
66
|
+
| { type: "resync_required"; hostId?: string }
|
|
67
|
+
| { type: "ping" }
|
|
68
|
+
| { type: "pong" };
|
|
69
|
+
|
|
70
|
+
function asString(value: unknown): string | undefined {
|
|
71
|
+
return typeof value === "string" ? value : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The slice of the WebSocket client this transport needs. Node ≥22 ships a
|
|
76
|
+
* global `WebSocket`, so the CLI needs no `ws` dependency; tests inject a
|
|
77
|
+
* scripted double instead of standing up a server.
|
|
78
|
+
*/
|
|
79
|
+
export interface RelaySocket {
|
|
80
|
+
send(data: string): void;
|
|
81
|
+
close(code?: number, reason?: string): void;
|
|
82
|
+
readonly bufferedAmount?: number;
|
|
83
|
+
onopen: (() => void) | null;
|
|
84
|
+
onmessage: ((event: { data: unknown }) => void) | null;
|
|
85
|
+
onclose: ((event: { code?: number; reason?: string }) => void) | null;
|
|
86
|
+
onerror: ((event: unknown) => void) | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type RelaySocketFactory = (url: string) => RelaySocket;
|
|
90
|
+
|
|
91
|
+
export const defaultRelaySocketFactory: RelaySocketFactory = (url) => {
|
|
92
|
+
const Ctor = (
|
|
93
|
+
globalThis as unknown as { WebSocket?: new (url: string) => RelaySocket }
|
|
94
|
+
).WebSocket;
|
|
95
|
+
if (!Ctor) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
"WebSocket is not available in this runtime (Node >= 22 required)",
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return new Ctor(url);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** Close codes the relay uses to tell us how to react. */
|
|
104
|
+
export const RELAY_CLOSE_UNAVAILABLE = 4001; // transient — the host is not ready yet
|
|
105
|
+
export const RELAY_CLOSE_UNAUTHORIZED = 4003; // permanent — do not come back
|
|
106
|
+
// (the one exception is a *stale stored identity*, which the credential's owner
|
|
107
|
+
// can replace by registering again: see `RELAY_STALE_IDENTITY_ERRORS`)
|
|
108
|
+
export const RELAY_CLOSE_BACKPRESSURE = 4008; // we gave up on a stuck socket
|
|
109
|
+
/**
|
|
110
|
+
* The `4001` reason the relay sends when another process on this machine dialled
|
|
111
|
+
* in and took the channel over (it keeps the original host running the same
|
|
112
|
+
* close code, so only the reason separates "I was replaced" from "this machine
|
|
113
|
+
* is not online" — and those two want opposite answers: step down, versus retry).
|
|
114
|
+
*/
|
|
115
|
+
export const RELAY_CLOSE_REPLACED = "replaced";
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The `auth_error` reasons that mean the **identity this machine stored** no
|
|
119
|
+
* longer exists on the relay: the row was revoked, the token aged out, or the
|
|
120
|
+
* local file holds an id/token pair that never belonged together. All of them
|
|
121
|
+
* are fixed the same way — register again, and the relay hands back the same row
|
|
122
|
+
* (the id is derived from the machine, so registration is idempotent) — which is
|
|
123
|
+
* exactly why they are separated from the rest of `4003`. The others
|
|
124
|
+
* (`organization_mismatch`, `unauthorized`, and anything a relay adds later) are
|
|
125
|
+
* about *this account, this organization*: registering again cannot change any
|
|
126
|
+
* of them, and treating them as stale would only hide the real reason behind a
|
|
127
|
+
* pointless round trip.
|
|
128
|
+
*/
|
|
129
|
+
export const RELAY_STALE_IDENTITY_ERRORS: readonly string[] = [
|
|
130
|
+
"invalid_host_token",
|
|
131
|
+
"unknown_host",
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
export function isStaleIdentityError(
|
|
135
|
+
error: string | undefined,
|
|
136
|
+
): error is string {
|
|
137
|
+
return error !== undefined && RELAY_STALE_IDENTITY_ERRORS.includes(error);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Retry cadence. Flat 2s: the relay is a well-known host, not a flaky peer —
|
|
141
|
+
* exponential backoff would only delay a laptop waking from sleep. */
|
|
142
|
+
export const RELAY_RETRY_DELAY_MS = 2_000;
|
|
143
|
+
/** Attempts for an ordinary drop (relay restart, network blip). */
|
|
144
|
+
export const RELAY_MAX_ATTEMPTS = 5;
|
|
145
|
+
/** Separate, smaller budget for `4001`: the host announced itself but had no
|
|
146
|
+
* session ready. Retrying a real outage five times buys nothing. */
|
|
147
|
+
export const RELAY_MAX_UNAVAILABLE_ATTEMPTS = 3;
|
|
148
|
+
/** Idle keepalive. Browsers cannot send WebSocket control pings, and the relay
|
|
149
|
+
* faces the phone anyway, so this is an application-level frame. */
|
|
150
|
+
export const RELAY_PING_INTERVAL_MS = 30_000;
|
|
151
|
+
/** Above this much unsent data the phone is not keeping up. Closing (rather
|
|
152
|
+
* than queueing) bounds this process's memory; the phone reconnects and catches
|
|
153
|
+
* up through `getMessages({ since })`, which is the same path a cold start
|
|
154
|
+
* takes. Matches the relay's own single-frame ceiling. */
|
|
155
|
+
export const RELAY_MAX_BUFFERED_BYTES = 4 * 1024 * 1024;
|
|
156
|
+
|
|
157
|
+
export type RelayState = "idle" | "connecting" | "open" | "closed";
|
|
158
|
+
|
|
159
|
+
export interface RelayClientOptions {
|
|
160
|
+
/** Full subscribe URL, including the host id path segment. */
|
|
161
|
+
url: string;
|
|
162
|
+
/** Who we are on that channel — read from the auth service / state file, never logged. */
|
|
163
|
+
credential: RelayCredential;
|
|
164
|
+
/**
|
|
165
|
+
* This CLI's release version, reported so the relay can enforce the minimum
|
|
166
|
+
* the served page was built against (the page itself is always the newest
|
|
167
|
+
* one; the skew is handled by refusing old clients, not by keeping old
|
|
168
|
+
* pages — see the version note in `docs/specs/ui/remote-control.md`).
|
|
169
|
+
*
|
|
170
|
+
* It sits at the **top level** of the auth frame, not inside `params`: that
|
|
171
|
+
* is where the relay reads it, and a version it cannot parse is silently
|
|
172
|
+
* treated as "too old to judge", i.e. the floor would quietly stop applying.
|
|
173
|
+
*/
|
|
174
|
+
version: string;
|
|
175
|
+
createSocket?: RelaySocketFactory;
|
|
176
|
+
/**
|
|
177
|
+
* The socket is up and the auth frame is on the wire. Not "authenticated" —
|
|
178
|
+
* that is `auth_ok` (`onAuthenticated`).
|
|
179
|
+
*/
|
|
180
|
+
onOpen?: () => void;
|
|
181
|
+
/** `auth_ok`: the relay accepted us and will fan frames in both directions. */
|
|
182
|
+
onAuthenticated?: (info: { role?: string; hostId?: string }) => void;
|
|
183
|
+
/**
|
|
184
|
+
* `auth_error`. The relay closes with `4003` right after, so this only adds
|
|
185
|
+
* the *reason* — the close code cannot say why.
|
|
186
|
+
*/
|
|
187
|
+
onAuthError?: (info: { error: string }) => void;
|
|
188
|
+
/** The other end of this channel is gone (the phone, or the machine). */
|
|
189
|
+
onPeerDisconnected?: () => void;
|
|
190
|
+
/**
|
|
191
|
+
* The relay dropped frames because our peer stopped draining, and asked for
|
|
192
|
+
* a resync: whatever we were sending incrementally is no longer complete.
|
|
193
|
+
*/
|
|
194
|
+
onResyncRequired?: () => void;
|
|
195
|
+
/** A frame from the relay (phone request or notification). */
|
|
196
|
+
onFrame?: (frame: RelayEnvelope) => void;
|
|
197
|
+
/**
|
|
198
|
+
* `4003` whose reason says the stored identity is stale (see
|
|
199
|
+
* `RELAY_STALE_IDENTITY_ERRORS`). Reported **instead of** `onFatal` because it
|
|
200
|
+
* is not the end of the road: replacing the credential means an HTTP
|
|
201
|
+
* registration, which only the credential's owner can do — this class knows
|
|
202
|
+
* nothing about that. Nothing is retried from here either way.
|
|
203
|
+
*/
|
|
204
|
+
onStaleIdentity?: (info: { error: string }) => void;
|
|
205
|
+
/** The connection is gone for good — `4003`, or the retry budget ran out. */
|
|
206
|
+
onFatal?: (reason: string) => void;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export class RelayClient {
|
|
210
|
+
private state: RelayState = "idle";
|
|
211
|
+
private socket: RelaySocket | undefined;
|
|
212
|
+
private retryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
213
|
+
private pingTimer: ReturnType<typeof setInterval> | undefined;
|
|
214
|
+
private attempts = 0;
|
|
215
|
+
private unavailableAttempts = 0;
|
|
216
|
+
private stopped = false;
|
|
217
|
+
/** Why the relay refused us, kept so the `4003` that follows can say more. */
|
|
218
|
+
private authError: { error: string } | undefined;
|
|
219
|
+
/**
|
|
220
|
+
* `auth_ok` has arrived. Until it does the relay parses *every* frame as the
|
|
221
|
+
* auth frame, and one it cannot parse closes the connection for good
|
|
222
|
+
* (`4003`, never retried) — so nothing but the auth frame may go out first.
|
|
223
|
+
*/
|
|
224
|
+
private authed = false;
|
|
225
|
+
/** Frames held back until `auth_ok`, replayed in order once it lands. */
|
|
226
|
+
private outbox: string[] = [];
|
|
227
|
+
private outboxBytes = 0;
|
|
228
|
+
|
|
229
|
+
constructor(private readonly options: RelayClientOptions) {}
|
|
230
|
+
|
|
231
|
+
getState(): RelayState {
|
|
232
|
+
return this.state;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
start(): void {
|
|
236
|
+
if (this.stopped || this.state === "connecting" || this.state === "open") {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
this.connect();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Tear down for good — no retry, no pending timers. */
|
|
243
|
+
stop(): void {
|
|
244
|
+
this.stopped = true;
|
|
245
|
+
this.clearTimers();
|
|
246
|
+
this.clearOutbox();
|
|
247
|
+
const socket = this.socket;
|
|
248
|
+
this.socket = undefined;
|
|
249
|
+
this.state = "closed";
|
|
250
|
+
// Close only after detaching handlers: a deliberate stop must not be
|
|
251
|
+
// mistaken for a dropped link and schedule a reconnect.
|
|
252
|
+
if (socket) {
|
|
253
|
+
socket.onopen = null;
|
|
254
|
+
socket.onmessage = null;
|
|
255
|
+
socket.onclose = null;
|
|
256
|
+
socket.onerror = null;
|
|
257
|
+
try {
|
|
258
|
+
socket.close(1000, "client stop");
|
|
259
|
+
} catch {
|
|
260
|
+
// Already gone.
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Send one envelope. Returns false when there is nowhere to put it (the link
|
|
267
|
+
* is down) — the caller may drop the frame; the phone re-syncs from a
|
|
268
|
+
* snapshot on reconnect, so a gap is recoverable, a crash is not. A frame
|
|
269
|
+
* handed over before the handshake completes is parked rather than dropped:
|
|
270
|
+
* it still goes out, in order, the moment the relay accepts us.
|
|
271
|
+
*/
|
|
272
|
+
send(frame: RelayEnvelope): boolean {
|
|
273
|
+
return this.write(JSON.stringify(frame));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Send one control frame. Same channel, different judge: control frames are
|
|
278
|
+
* the only ones the relay interprets rather than forwards, so they are plain
|
|
279
|
+
* objects (`{type}`) rather than JSON-RPC envelopes (`{method}`).
|
|
280
|
+
*/
|
|
281
|
+
sendControl(frame: Record<string, unknown>): boolean {
|
|
282
|
+
return this.write(JSON.stringify(frame));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The handshake owns the first frame. On an unauthenticated connection the
|
|
287
|
+
* relay parses *every* frame it receives as `auth` (it has no way to tell a
|
|
288
|
+
* straggler apart from the handshake), and a frame that does not parse is a
|
|
289
|
+
* permanent `4003` — so going out early is the difference between "the phone
|
|
290
|
+
* is reconnecting" and "this machine never comes back".
|
|
291
|
+
*/
|
|
292
|
+
private write(payload: string): boolean {
|
|
293
|
+
const socket = this.socket;
|
|
294
|
+
if (!socket || this.state !== "open") return false;
|
|
295
|
+
if (!this.authed) return this.park(payload);
|
|
296
|
+
return this.writeNow(payload);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Straight to the wire: the auth frame, and the replay after `auth_ok`. Kept
|
|
301
|
+
* separate from `write` so the one frame that is allowed before the handshake
|
|
302
|
+
* completes has to say so at the call site.
|
|
303
|
+
*/
|
|
304
|
+
private writeNow(payload: string): boolean {
|
|
305
|
+
const socket = this.socket;
|
|
306
|
+
if (!socket || this.state !== "open") return false;
|
|
307
|
+
|
|
308
|
+
const buffered = socket.bufferedAmount ?? 0;
|
|
309
|
+
if (buffered > RELAY_MAX_BUFFERED_BYTES) {
|
|
310
|
+
this.dropForBackpressure();
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
socket.send(payload);
|
|
316
|
+
return true;
|
|
317
|
+
} catch {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Hold one frame until `auth_ok`. The window is a single relay round trip
|
|
324
|
+
* (the relay verifies the token against the database before answering), so
|
|
325
|
+
* this stays tiny — but it is capped anyway, at the same ceiling `writeNow`
|
|
326
|
+
* uses: a queue that can grow without bound is exactly what the backpressure
|
|
327
|
+
* judgement exists to prevent, and the remedy is the same (drop the link,
|
|
328
|
+
* let the phone resync).
|
|
329
|
+
*/
|
|
330
|
+
private park(payload: string): boolean {
|
|
331
|
+
const bytes = Buffer.byteLength(payload, "utf8");
|
|
332
|
+
if (this.outboxBytes + bytes > RELAY_MAX_BUFFERED_BYTES) {
|
|
333
|
+
this.dropForBackpressure();
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
this.outbox.push(payload);
|
|
337
|
+
this.outboxBytes += bytes;
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Replay what was parked, in order. Called the moment `auth_ok` lands. */
|
|
342
|
+
private flushOutbox(): void {
|
|
343
|
+
const queued = this.outbox;
|
|
344
|
+
this.clearOutbox();
|
|
345
|
+
for (const payload of queued) {
|
|
346
|
+
// The gate is behind us, so these are ordinary sends again — the
|
|
347
|
+
// backpressure judgement still applies to every one of them.
|
|
348
|
+
if (!this.writeNow(payload)) return;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private clearOutbox(): void {
|
|
353
|
+
this.outbox = [];
|
|
354
|
+
this.outboxBytes = 0;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ── Connection lifecycle ──────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
private connect(): void {
|
|
360
|
+
this.state = "connecting";
|
|
361
|
+
// Every socket starts unauthenticated, however the last one ended.
|
|
362
|
+
this.authed = false;
|
|
363
|
+
let socket: RelaySocket;
|
|
364
|
+
try {
|
|
365
|
+
socket = (this.options.createSocket ?? defaultRelaySocketFactory)(
|
|
366
|
+
this.options.url,
|
|
367
|
+
);
|
|
368
|
+
} catch (err) {
|
|
369
|
+
this.state = "closed";
|
|
370
|
+
this.options.onFatal?.((err as Error).message);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
this.socket = socket;
|
|
374
|
+
|
|
375
|
+
socket.onopen = () => {
|
|
376
|
+
if (this.socket !== socket) return;
|
|
377
|
+
this.state = "open";
|
|
378
|
+
this.attempts = 0;
|
|
379
|
+
this.unavailableAttempts = 0;
|
|
380
|
+
// The relay authenticates the runner before it will fan anything out, and
|
|
381
|
+
// it reads the version from the top level of this same frame. Ten seconds
|
|
382
|
+
// without it and the relay closes with `auth_timeout` (4003). This is the
|
|
383
|
+
// one frame that may travel before `auth_ok`, so it goes to the wire
|
|
384
|
+
// directly instead of through the gate in `write`.
|
|
385
|
+
this.writeNow(
|
|
386
|
+
JSON.stringify({
|
|
387
|
+
type: "auth",
|
|
388
|
+
credential: {
|
|
389
|
+
type: this.options.credential.type,
|
|
390
|
+
token: this.options.credential.token,
|
|
391
|
+
},
|
|
392
|
+
version: this.options.version,
|
|
393
|
+
}),
|
|
394
|
+
);
|
|
395
|
+
this.startPing();
|
|
396
|
+
this.options.onOpen?.();
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
socket.onmessage = (event) => {
|
|
400
|
+
if (this.socket !== socket) return;
|
|
401
|
+
// Control frames are judged first and never reach the JSON-RPC layer:
|
|
402
|
+
// they carry no `method`, so the envelope parser would drop them
|
|
403
|
+
// silently — which is how "the version floor is too low" and "the token
|
|
404
|
+
// was revoked" used to look exactly alike from here.
|
|
405
|
+
const control = parseRelayControlFrame(event.data);
|
|
406
|
+
if (control) {
|
|
407
|
+
this.handleControlFrame(control);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const frame = parseRelayFrame(event.data);
|
|
411
|
+
if (frame) this.options.onFrame?.(frame);
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
socket.onerror = () => {
|
|
415
|
+
// The close event that follows carries the code; nothing to do here.
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
socket.onclose = (event) => {
|
|
419
|
+
if (this.socket !== socket) return;
|
|
420
|
+
this.socket = undefined;
|
|
421
|
+
this.stopPing();
|
|
422
|
+
this.handleClose(event?.code, event?.reason);
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
private handleClose(code: number | undefined, reason?: string): void {
|
|
427
|
+
if (this.stopped) {
|
|
428
|
+
this.state = "closed";
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (code === RELAY_CLOSE_UNAVAILABLE && reason === RELAY_CLOSE_REPLACED) {
|
|
433
|
+
// Another process on this machine dialled in and the relay handed the
|
|
434
|
+
// channel over to it. Retrying would take the channel back, and then the
|
|
435
|
+
// other side would retry too: the two would replace each other every two
|
|
436
|
+
// seconds, forever — every successful connect resets the retry budget, so
|
|
437
|
+
// neither ever exceeds it. So this side steps down for good; dialling
|
|
438
|
+
// again from here is an explicit user action and takes the channel back.
|
|
439
|
+
this.stopped = true;
|
|
440
|
+
this.state = "closed";
|
|
441
|
+
this.options.onFatal?.(
|
|
442
|
+
"another wave process on this machine took over remote control",
|
|
443
|
+
);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (code === RELAY_CLOSE_UNAUTHORIZED) {
|
|
448
|
+
this.stopped = true;
|
|
449
|
+
this.state = "closed";
|
|
450
|
+
const error = this.authError?.error;
|
|
451
|
+
if (isStaleIdentityError(error)) {
|
|
452
|
+
// No reconnect from here: the fix is a registration round trip, not a
|
|
453
|
+
// retry (dialling again with the same dead token can only repeat this).
|
|
454
|
+
this.options.onStaleIdentity?.({ error });
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
this.options.onFatal?.(
|
|
458
|
+
this.authError
|
|
459
|
+
? `relay rejected the credential (4003): ${this.authError.error}`
|
|
460
|
+
: "relay rejected the credential (4003)",
|
|
461
|
+
);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (code === RELAY_CLOSE_UNAVAILABLE) {
|
|
466
|
+
this.unavailableAttempts += 1;
|
|
467
|
+
if (this.unavailableAttempts > RELAY_MAX_UNAVAILABLE_ATTEMPTS) {
|
|
468
|
+
this.stopped = true;
|
|
469
|
+
this.state = "closed";
|
|
470
|
+
this.options.onFatal?.("relay reported the host unavailable (4001)");
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
} else {
|
|
474
|
+
this.attempts += 1;
|
|
475
|
+
if (this.attempts > RELAY_MAX_ATTEMPTS) {
|
|
476
|
+
this.stopped = true;
|
|
477
|
+
this.state = "closed";
|
|
478
|
+
this.options.onFatal?.(
|
|
479
|
+
`relay connection lost${code === undefined ? "" : ` (${code})`}`,
|
|
480
|
+
);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
this.state = "closed";
|
|
486
|
+
this.retryTimer = setTimeout(() => {
|
|
487
|
+
this.retryTimer = undefined;
|
|
488
|
+
if (!this.stopped) this.connect();
|
|
489
|
+
}, RELAY_RETRY_DELAY_MS);
|
|
490
|
+
this.retryTimer.unref?.();
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* The phone stopped draining. Queueing would grow this process without bound,
|
|
495
|
+
* so the link is dropped instead: the phone reconnects and re-reads the tail
|
|
496
|
+
* through `getMessages({ since })`. `4008` tells it not to bother re-sending
|
|
497
|
+
* whatever it had buffered.
|
|
498
|
+
*/
|
|
499
|
+
private dropForBackpressure(): void {
|
|
500
|
+
const socket = this.socket;
|
|
501
|
+
if (!socket) return;
|
|
502
|
+
this.socket = undefined;
|
|
503
|
+
this.stopPing();
|
|
504
|
+
socket.onopen = null;
|
|
505
|
+
socket.onmessage = null;
|
|
506
|
+
socket.onclose = null;
|
|
507
|
+
socket.onerror = null;
|
|
508
|
+
try {
|
|
509
|
+
socket.close(RELAY_CLOSE_BACKPRESSURE, "backpressure");
|
|
510
|
+
} catch {
|
|
511
|
+
// Already gone.
|
|
512
|
+
}
|
|
513
|
+
this.attempts = 0;
|
|
514
|
+
this.state = "closed";
|
|
515
|
+
if (!this.stopped) {
|
|
516
|
+
this.retryTimer = setTimeout(() => {
|
|
517
|
+
this.retryTimer = undefined;
|
|
518
|
+
if (!this.stopped) this.connect();
|
|
519
|
+
}, RELAY_RETRY_DELAY_MS);
|
|
520
|
+
this.retryTimer.unref?.();
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* The relay owns the control channel, so it is the one that gets to refuse
|
|
526
|
+
* us — and its refusals are not interchangeable: a revoked token and a build
|
|
527
|
+
* the relay considers too old both end in `4003`, and only the frame tells
|
|
528
|
+
* them apart.
|
|
529
|
+
*/
|
|
530
|
+
private handleControlFrame(frame: RelayControlFrame): void {
|
|
531
|
+
switch (frame.type) {
|
|
532
|
+
case "auth_ok":
|
|
533
|
+
this.authError = undefined;
|
|
534
|
+
this.authed = true;
|
|
535
|
+
// Before anything the callback sends: parked frames are older, and the
|
|
536
|
+
// phone has to see them in the order they were produced.
|
|
537
|
+
this.flushOutbox();
|
|
538
|
+
this.options.onAuthenticated?.({
|
|
539
|
+
...(frame.role !== undefined ? { role: frame.role } : {}),
|
|
540
|
+
...(frame.hostId !== undefined ? { hostId: frame.hostId } : {}),
|
|
541
|
+
});
|
|
542
|
+
return;
|
|
543
|
+
case "auth_error":
|
|
544
|
+
this.authError = { error: frame.error };
|
|
545
|
+
this.options.onAuthError?.(this.authError);
|
|
546
|
+
return;
|
|
547
|
+
case "peer_disconnected":
|
|
548
|
+
this.options.onPeerDisconnected?.();
|
|
549
|
+
return;
|
|
550
|
+
case "resync_required":
|
|
551
|
+
this.options.onResyncRequired?.();
|
|
552
|
+
return;
|
|
553
|
+
case "ping":
|
|
554
|
+
this.sendControl({ type: "pong" });
|
|
555
|
+
return;
|
|
556
|
+
case "pong":
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Give up for good, with a reason worth showing a human. */
|
|
562
|
+
private fail(reason: string): void {
|
|
563
|
+
this.stopped = true;
|
|
564
|
+
this.clearTimers();
|
|
565
|
+
this.clearOutbox();
|
|
566
|
+
this.state = "closed";
|
|
567
|
+
this.options.onFatal?.(reason);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
private startPing(): void {
|
|
571
|
+
this.stopPing();
|
|
572
|
+
this.pingTimer = setInterval(() => {
|
|
573
|
+
// Application-level on purpose: browsers cannot send WebSocket control
|
|
574
|
+
// pings, and this connection is the one a phone page would hold too. The
|
|
575
|
+
// relay answers it (`pong`) and forwards neither direction — with a
|
|
576
|
+
// `method` envelope instead, every heartbeat would be broadcast to every
|
|
577
|
+
// subscriber of this machine.
|
|
578
|
+
this.sendControl({ type: "ping" });
|
|
579
|
+
}, RELAY_PING_INTERVAL_MS);
|
|
580
|
+
this.pingTimer.unref?.();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
private stopPing(): void {
|
|
584
|
+
if (this.pingTimer !== undefined) {
|
|
585
|
+
clearInterval(this.pingTimer);
|
|
586
|
+
this.pingTimer = undefined;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
private clearTimers(): void {
|
|
591
|
+
if (this.retryTimer !== undefined) {
|
|
592
|
+
clearTimeout(this.retryTimer);
|
|
593
|
+
this.retryTimer = undefined;
|
|
594
|
+
}
|
|
595
|
+
this.stopPing();
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Read one **control** frame off the wire, or `undefined` when this is payload.
|
|
601
|
+
*
|
|
602
|
+
* The judge is the relay's own, read in reverse: a JSON object with a top-level
|
|
603
|
+
* string `type` and no `jsonrpc` marker. A `type` we do not know is dropped
|
|
604
|
+
* rather than thrown — a newer relay may grow control frames, and none of them
|
|
605
|
+
* should be able to disconnect a machine — and so is a malformed one, for the
|
|
606
|
+
* same reason `parseRelayFrame` drops those.
|
|
607
|
+
*/
|
|
608
|
+
export function parseRelayControlFrame(
|
|
609
|
+
data: unknown,
|
|
610
|
+
): RelayControlFrame | undefined {
|
|
611
|
+
if (typeof data !== "string") return undefined;
|
|
612
|
+
let parsed: unknown;
|
|
613
|
+
try {
|
|
614
|
+
parsed = JSON.parse(data);
|
|
615
|
+
} catch {
|
|
616
|
+
return undefined;
|
|
617
|
+
}
|
|
618
|
+
if (typeof parsed !== "object" || parsed === null) return undefined;
|
|
619
|
+
const frame = parsed as Record<string, unknown>;
|
|
620
|
+
const type = asString(frame.type);
|
|
621
|
+
if (type === undefined || frame.jsonrpc !== undefined) return undefined;
|
|
622
|
+
|
|
623
|
+
const hostId = asString(frame.hostId);
|
|
624
|
+
switch (type) {
|
|
625
|
+
case "auth_ok": {
|
|
626
|
+
const role = asString(frame.role);
|
|
627
|
+
return {
|
|
628
|
+
type: "auth_ok",
|
|
629
|
+
...(role !== undefined ? { role } : {}),
|
|
630
|
+
...(hostId !== undefined ? { hostId } : {}),
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
case "auth_error": {
|
|
634
|
+
const error = asString(frame.error);
|
|
635
|
+
if (error === undefined) return undefined;
|
|
636
|
+
// Any `minVersion` an older relay still sends is dropped here: the
|
|
637
|
+
// version floor is no longer part of the contract.
|
|
638
|
+
return { type: "auth_error", error };
|
|
639
|
+
}
|
|
640
|
+
case "peer_disconnected":
|
|
641
|
+
return {
|
|
642
|
+
type: "peer_disconnected",
|
|
643
|
+
...(hostId !== undefined ? { hostId } : {}),
|
|
644
|
+
};
|
|
645
|
+
case "resync_required":
|
|
646
|
+
return {
|
|
647
|
+
type: "resync_required",
|
|
648
|
+
...(hostId !== undefined ? { hostId } : {}),
|
|
649
|
+
};
|
|
650
|
+
case "ping":
|
|
651
|
+
return { type: "ping" };
|
|
652
|
+
case "pong":
|
|
653
|
+
return { type: "pong" };
|
|
654
|
+
default:
|
|
655
|
+
return undefined;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Read one envelope off the wire. A malformed frame is dropped rather than
|
|
661
|
+
* tearing the connection down: the relay is a shared component and a future
|
|
662
|
+
* control frame we do not understand must not take the phone offline.
|
|
663
|
+
*
|
|
664
|
+
* Only frames **without** a top-level `type` reach here — control frames are
|
|
665
|
+
* claimed by `parseRelayControlFrame` first, so this stays a JSON-RPC parser.
|
|
666
|
+
*/
|
|
667
|
+
export function parseRelayFrame(data: unknown): RelayEnvelope | undefined {
|
|
668
|
+
if (typeof data !== "string") return undefined;
|
|
669
|
+
let parsed: unknown;
|
|
670
|
+
try {
|
|
671
|
+
parsed = JSON.parse(data);
|
|
672
|
+
} catch {
|
|
673
|
+
return undefined;
|
|
674
|
+
}
|
|
675
|
+
if (typeof parsed !== "object" || parsed === null) return undefined;
|
|
676
|
+
const frame = parsed as {
|
|
677
|
+
method?: unknown;
|
|
678
|
+
params?: unknown;
|
|
679
|
+
sessionId?: unknown;
|
|
680
|
+
id?: unknown;
|
|
681
|
+
};
|
|
682
|
+
if (typeof frame.method !== "string") return undefined;
|
|
683
|
+
const result: RelayEnvelope = { method: frame.method };
|
|
684
|
+
if (frame.params !== undefined) result.params = frame.params;
|
|
685
|
+
if (typeof frame.sessionId === "string") result.sessionId = frame.sessionId;
|
|
686
|
+
if (typeof frame.id === "string" || typeof frame.id === "number") {
|
|
687
|
+
result.id = frame.id;
|
|
688
|
+
}
|
|
689
|
+
return result;
|
|
690
|
+
}
|