@alfe.ai/remote 0.1.0 → 0.2.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/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # `@alfe.ai/remote`
2
+
3
+ Shared transport and frame codec for Alfe's interactive browser and terminal
4
+ relay. The package provides:
5
+
6
+ - `RemoteServiceClient`, one outbound authenticated WebSocket per agent;
7
+ - the five-byte multiplexed binary frame codec and direction helpers;
8
+ - bounded runtime decoders for relay control payloads;
9
+ - `SurfaceHandler` and `TurnController` contracts used by the browser and
10
+ terminal packages.
11
+
12
+ ## Client usage
13
+
14
+ ```ts
15
+ import { RemoteServiceClient } from "@alfe.ai/remote";
16
+
17
+ const client = new RemoteServiceClient({
18
+ wsUrl: "wss://remote.dev.alfe.ai/ws",
19
+ apiKey: process.env.ALFE_API_KEY!,
20
+ onFrame: (frame) => routeFrame(frame),
21
+ });
22
+
23
+ client.start();
24
+ ```
25
+
26
+ The relay URL is an authentication boundary because the API key is sent in the
27
+ WebSocket upgrade. Production callers must derive or authorize the exact relay
28
+ host from trusted Alfe configuration. The client requires WSS, permits plain WS
29
+ only for loopback development, rejects URL userinfo/fragments, and redacts query
30
+ parameters from logs.
31
+
32
+ Inbound and outbound frames are capped at 10 MiB. Control JSON is capped at
33
+ 64 KiB. Use the exported shape-specific decoders instead of casting
34
+ `decodeJson()` results at a surface boundary.
35
+
36
+ See [Alfe](https://alfe.ai) and the [documentation](https://docs.alfe.ai).
package/dist/index.cjs CHANGED
@@ -41,9 +41,15 @@ ws = __toESM(ws);
41
41
  * • viewer → plugin: SESSION_OPEN, SESSION_CLOSE, SCREENCAST_ACK, INPUT_*,
42
42
  * RESIZE, TAKEOVER_REQUEST, RELEASE_CONTROL, TERMINAL_INPUT, TERMINAL_RESIZE
43
43
  *
44
- * Keep this file byte-identical with `services/remote/src/remote-protocol.ts`.
44
+ * Keep agent-facing frame values byte-compatible with
45
+ * `services/remote/src/remote-protocol.ts`. The relay may define private frame
46
+ * types that it consumes without forwarding them to the agent.
45
47
  */
46
48
  const REMOTE_HEADER_SIZE = 5;
49
+ /** Maximum complete WebSocket message size, including the five-byte header. */
50
+ const REMOTE_MAX_PAYLOAD = 10 * 1024 * 1024;
51
+ /** Control payloads are deliberately much smaller than media/terminal frames. */
52
+ const REMOTE_MAX_JSON_PAYLOAD = 64 * 1024;
47
53
  const RemoteFrameType = {
48
54
  SESSION_OPEN: 1,
49
55
  SESSION_CLOSE: 2,
@@ -64,36 +70,76 @@ const RemoteFrameType = {
64
70
  TERMINAL_INPUT: 65,
65
71
  TERMINAL_RESIZE: 66
66
72
  };
67
- const MIN_FRAME_TYPE = RemoteFrameType.SESSION_OPEN;
68
- const MAX_FRAME_TYPE = RemoteFrameType.TERMINAL_RESIZE;
73
+ const VALID_FRAME_TYPES = new Set(Object.values(RemoteFrameType));
74
+ const RELAY_TO_AGENT_FRAME_TYPES = new Set([
75
+ RemoteFrameType.SESSION_OPEN,
76
+ RemoteFrameType.SESSION_CLOSE,
77
+ RemoteFrameType.SCREENCAST_ACK,
78
+ RemoteFrameType.INPUT_MOUSE,
79
+ RemoteFrameType.INPUT_WHEEL,
80
+ RemoteFrameType.INPUT_KEY,
81
+ RemoteFrameType.RESIZE,
82
+ RemoteFrameType.TAKEOVER_REQUEST,
83
+ RemoteFrameType.RELEASE_CONTROL,
84
+ RemoteFrameType.TERMINAL_INPUT,
85
+ RemoteFrameType.TERMINAL_RESIZE
86
+ ]);
87
+ const AGENT_TO_RELAY_FRAME_TYPES = new Set([
88
+ RemoteFrameType.SESSION_CLOSE,
89
+ RemoteFrameType.SESSION_STATE,
90
+ RemoteFrameType.NAVIGATION,
91
+ RemoteFrameType.SCREENCAST_FRAME,
92
+ RemoteFrameType.TAKEOVER_GRANTED,
93
+ RemoteFrameType.TAKEOVER_DENIED,
94
+ RemoteFrameType.CONTROL_REVOKED,
95
+ RemoteFrameType.TERMINAL_DATA
96
+ ]);
97
+ function isRemoteFrameType(value) {
98
+ return typeof value === "number" && VALID_FRAME_TYPES.has(value);
99
+ }
100
+ function isRelayToAgentFrameType(value) {
101
+ return typeof value === "number" && RELAY_TO_AGENT_FRAME_TYPES.has(value);
102
+ }
103
+ function isAgentToRelayFrameType(value) {
104
+ return typeof value === "number" && AGENT_TO_RELAY_FRAME_TYPES.has(value);
105
+ }
69
106
  function isRemoteFrame(data) {
70
- if (data.length < 5) return false;
71
- const type = data[0];
72
- return type >= MIN_FRAME_TYPE && type <= MAX_FRAME_TYPE;
107
+ return Buffer.isBuffer(data) && data.length >= 5 && data.length <= 10485760 && isRemoteFrameType(data[0]);
73
108
  }
74
109
  function encodeFrame(type, sessionId, payload) {
110
+ if (!isRemoteFrameType(type)) throw new TypeError("Remote frame type is invalid");
111
+ if (!Number.isInteger(sessionId) || sessionId < 0 || sessionId > 4294967295) throw new RangeError("Remote session ID must be an unsigned 32-bit integer");
112
+ if (payload !== void 0 && !Buffer.isBuffer(payload)) throw new TypeError("Remote frame payload must be a Buffer");
75
113
  const body = payload ?? Buffer.alloc(0);
114
+ if (body.length > 10485755) throw new RangeError(`Remote frame exceeds the ${String(REMOTE_MAX_PAYLOAD)} byte limit`);
76
115
  const buf = Buffer.allocUnsafe(5 + body.length);
77
116
  buf.writeUInt8(type, 0);
78
- buf.writeUInt32BE(sessionId >>> 0, 1);
117
+ buf.writeUInt32BE(sessionId, 1);
79
118
  if (body.length > 0) body.copy(buf, 5);
80
119
  return buf;
81
120
  }
82
121
  function decodeFrame(buf) {
122
+ if (!Buffer.isBuffer(buf)) throw new TypeError("Remote frame must be a Buffer");
83
123
  if (buf.length < 5) throw new Error(`Remote frame too short: ${String(buf.length)} bytes`);
124
+ if (buf.length > 10485760) throw new RangeError(`Remote frame exceeds the ${String(REMOTE_MAX_PAYLOAD)} byte limit`);
125
+ const type = buf.readUInt8(0);
126
+ if (!isRemoteFrameType(type)) throw new Error(`Unknown remote frame type: ${String(type)}`);
84
127
  return {
85
- type: buf.readUInt8(0),
128
+ type,
86
129
  sessionId: buf.readUInt32BE(1),
87
130
  payload: buf.subarray(5)
88
131
  };
89
132
  }
90
133
  function encodeJsonFrame(type, sessionId, obj) {
91
- return encodeFrame(type, sessionId, Buffer.from(JSON.stringify(obj), "utf-8"));
134
+ if (obj === void 0 || typeof obj === "function" || typeof obj === "symbol") throw new TypeError("Remote JSON payload is not serializable");
135
+ const json = JSON.stringify(obj);
136
+ const payload = Buffer.from(json, "utf-8");
137
+ if (payload.length > 65536) throw new RangeError(`Remote JSON payload exceeds the ${String(REMOTE_MAX_JSON_PAYLOAD)} byte limit`);
138
+ return encodeFrame(type, sessionId, payload);
92
139
  }
93
- /** Best-effort JSON decode returns null on a malformed payload so a single
94
- * corrupt frame can't crash the per-session handler. Callers assert the shape
95
- * (e.g. `decodeJson(p) as SessionOpenPayload | null`). */
140
+ /** Best-effort bounded JSON decode. Shape-specific callers use the decoders below. */
96
141
  function decodeJson(payload) {
142
+ if (!Buffer.isBuffer(payload) || payload.length < 1 || payload.length > 65536) return null;
97
143
  try {
98
144
  return JSON.parse(payload.toString("utf-8"));
99
145
  } catch {
@@ -103,7 +149,11 @@ function decodeJson(payload) {
103
149
  /** Encode a screencast frame: [metaLen:u16BE][meta JSON][raw JPEG]. The JPEG is
104
150
  * forwarded raw (already base64-decoded from CDP) to avoid base64 bloat. */
105
151
  function encodeScreencastFrame(sessionId, meta, jpeg) {
106
- const metaJson = Buffer.from(JSON.stringify(meta), "utf-8");
152
+ const normalizedMeta = normalizeScreencastFrameMeta(meta);
153
+ if (normalizedMeta === null) throw new TypeError("Screencast metadata is invalid");
154
+ if (!isJpeg(jpeg)) throw new TypeError("Screencast content must be a non-empty JPEG image");
155
+ const metaJson = Buffer.from(JSON.stringify(normalizedMeta), "utf-8");
156
+ if (metaJson.length > 65535) throw new RangeError("Screencast metadata is too large");
107
157
  const body = Buffer.allocUnsafe(2 + metaJson.length + jpeg.length);
108
158
  body.writeUInt16BE(metaJson.length, 0);
109
159
  metaJson.copy(body, 2);
@@ -111,30 +161,169 @@ function encodeScreencastFrame(sessionId, meta, jpeg) {
111
161
  return encodeFrame(RemoteFrameType.SCREENCAST_FRAME, sessionId, body);
112
162
  }
113
163
  function decodeScreencastFrame(payload) {
114
- if (payload.length < 2) return null;
164
+ if (!Buffer.isBuffer(payload) || payload.length < 2 || payload.length > 10485755) return null;
115
165
  const metaLen = payload.readUInt16BE(0);
116
- if (payload.length < 2 + metaLen) return null;
117
- const meta = decodeJson(payload.subarray(2, 2 + metaLen));
118
- if (!meta) return null;
166
+ if (metaLen < 1 || payload.length < 2 + metaLen) return null;
167
+ const meta = normalizeScreencastFrameMeta(decodeJson(payload.subarray(2, 2 + metaLen)));
168
+ const jpeg = payload.subarray(2 + metaLen);
169
+ if (meta === null || !isJpeg(jpeg)) return null;
119
170
  return {
120
171
  meta,
121
- jpeg: payload.subarray(2 + metaLen)
172
+ jpeg
122
173
  };
123
174
  }
124
- /** Maximum WS message size (must align across daemon plugin and relay). */
125
- const REMOTE_MAX_PAYLOAD = 10 * 1024 * 1024;
175
+ function decodeSessionOpenPayload(payload) {
176
+ const value = decodeJson(payload);
177
+ if (!isRecord(value) || value.surface !== "browser" && value.surface !== "terminal") return null;
178
+ const surface = value.surface;
179
+ const width = optionalBoundedNumber(value.width, 1, 32768, true);
180
+ const height = optionalBoundedNumber(value.height, 1, 32768, true);
181
+ const dpr = optionalBoundedNumber(value.dpr, .1, 10, false);
182
+ const cols = optionalBoundedNumber(value.cols, 1, 1e3, true);
183
+ const rows = optionalBoundedNumber(value.rows, 1, 1e3, true);
184
+ if (width === false || height === false || dpr === false || cols === false || rows === false) return null;
185
+ if (surface === "browser" && (cols !== void 0 || rows !== void 0)) return null;
186
+ if (surface === "terminal" && (width !== void 0 || height !== void 0 || dpr !== void 0)) return null;
187
+ return compact({
188
+ surface,
189
+ width,
190
+ height,
191
+ dpr,
192
+ cols,
193
+ rows
194
+ });
195
+ }
196
+ function decodeScreencastAckPayload(payload) {
197
+ const value = decodeJson(payload);
198
+ return isRecord(value) && isUint32(value.frameSeq) ? { frameSeq: value.frameSeq } : null;
199
+ }
200
+ function decodeResizePayload(payload) {
201
+ const value = decodeJson(payload);
202
+ if (!isRecord(value)) return null;
203
+ if (!boundedNumber(value.width, 1, 32768, true) || !boundedNumber(value.height, 1, 32768, true)) return null;
204
+ const dpr = optionalBoundedNumber(value.dpr, .1, 10, false);
205
+ if (dpr === false) return null;
206
+ return compact({
207
+ width: value.width,
208
+ height: value.height,
209
+ dpr
210
+ });
211
+ }
212
+ function decodeMouseInputPayload(payload) {
213
+ const value = decodeJson(payload);
214
+ if (!isRecord(value)) return null;
215
+ const inputTypes = new Set([
216
+ "mousemoved",
217
+ "mousepressed",
218
+ "mousereleased"
219
+ ]);
220
+ const buttons = optionalBoundedNumber(value.buttons, 0, 31, true);
221
+ const clickCount = optionalBoundedNumber(value.clickCount, 0, 10, true);
222
+ const modifiers = optionalBoundedNumber(value.modifiers, 0, 15, true);
223
+ const validButton = value.button === void 0 || typeof value.button === "string" && [
224
+ "none",
225
+ "left",
226
+ "middle",
227
+ "right"
228
+ ].includes(value.button);
229
+ if (typeof value.type !== "string" || !inputTypes.has(value.type) || !boundedNumber(value.nx, 0, 1, false) || !boundedNumber(value.ny, 0, 1, false) || !validButton || buttons === false || clickCount === false || modifiers === false) return null;
230
+ return compact({
231
+ type: value.type,
232
+ nx: value.nx,
233
+ ny: value.ny,
234
+ button: value.button,
235
+ buttons,
236
+ clickCount,
237
+ modifiers
238
+ });
239
+ }
240
+ function decodeWheelInputPayload(payload) {
241
+ const value = decodeJson(payload);
242
+ if (!isRecord(value)) return null;
243
+ const modifiers = optionalBoundedNumber(value.modifiers, 0, 15, true);
244
+ if (!boundedNumber(value.nx, 0, 1, false) || !boundedNumber(value.ny, 0, 1, false) || !boundedNumber(value.deltaX, -1e6, 1e6, false) || !boundedNumber(value.deltaY, -1e6, 1e6, false) || modifiers === false) return null;
245
+ return compact({
246
+ nx: value.nx,
247
+ ny: value.ny,
248
+ deltaX: value.deltaX,
249
+ deltaY: value.deltaY,
250
+ modifiers
251
+ });
252
+ }
253
+ function decodeKeyInputPayload(payload) {
254
+ const value = decodeJson(payload);
255
+ if (!isRecord(value) || ![
256
+ "keydown",
257
+ "keyup",
258
+ "char"
259
+ ].includes(String(value.type))) return null;
260
+ const key = optionalBoundedString(value.key, 256);
261
+ const code = optionalBoundedString(value.code, 256);
262
+ const text = optionalBoundedString(value.text, 4096);
263
+ const modifiers = optionalBoundedNumber(value.modifiers, 0, 15, true);
264
+ if (key === false || code === false || text === false || modifiers === false) return null;
265
+ return compact({
266
+ type: value.type,
267
+ key,
268
+ code,
269
+ text,
270
+ modifiers
271
+ });
272
+ }
273
+ function decodeTerminalResizePayload(payload) {
274
+ const value = decodeJson(payload);
275
+ if (!isRecord(value) || !boundedNumber(value.cols, 1, 1e3, true) || !boundedNumber(value.rows, 1, 1e3, true)) return null;
276
+ return {
277
+ cols: value.cols,
278
+ rows: value.rows
279
+ };
280
+ }
281
+ function normalizeScreencastFrameMeta(value) {
282
+ if (!isRecord(value)) return null;
283
+ if (!boundedNumber(value.deviceWidth, 1, 32768, false) || !boundedNumber(value.deviceHeight, 1, 32768, false) || !isUint32(value.frameSeq)) return null;
284
+ const offsetTop = optionalBoundedNumber(value.offsetTop, -1e6, 1e6, false);
285
+ const pageScaleFactor = optionalBoundedNumber(value.pageScaleFactor, .01, 100, false);
286
+ const scrollOffsetX = optionalBoundedNumber(value.scrollOffsetX, -1e7, 1e7, false);
287
+ const scrollOffsetY = optionalBoundedNumber(value.scrollOffsetY, -1e7, 1e7, false);
288
+ if (offsetTop === false || pageScaleFactor === false || scrollOffsetX === false || scrollOffsetY === false) return null;
289
+ return compact({
290
+ deviceWidth: value.deviceWidth,
291
+ deviceHeight: value.deviceHeight,
292
+ frameSeq: value.frameSeq,
293
+ offsetTop,
294
+ pageScaleFactor,
295
+ scrollOffsetX,
296
+ scrollOffsetY
297
+ });
298
+ }
299
+ function isJpeg(value) {
300
+ return Buffer.isBuffer(value) && value.length >= 3 && value[0] === 255 && value[1] === 216 && value[2] === 255;
301
+ }
302
+ function isRecord(value) {
303
+ return typeof value === "object" && value !== null && !Array.isArray(value);
304
+ }
305
+ function isUint32(value) {
306
+ return Number.isInteger(value) && typeof value === "number" && value >= 0 && value <= 4294967295;
307
+ }
308
+ function boundedNumber(value, min, max, integer) {
309
+ return typeof value === "number" && Number.isFinite(value) && (!integer || Number.isInteger(value)) && value >= min && value <= max;
310
+ }
311
+ function optionalBoundedNumber(value, min, max, integer) {
312
+ if (value === void 0) return void 0;
313
+ return boundedNumber(value, min, max, integer) ? value : false;
314
+ }
315
+ function optionalBoundedString(value, maxLength) {
316
+ if (value === void 0) return void 0;
317
+ return typeof value === "string" && value.length <= maxLength ? value : false;
318
+ }
319
+ function compact(value) {
320
+ return Object.fromEntries(Object.entries(value).filter(([, field]) => field !== void 0));
321
+ }
126
322
  //#endregion
127
323
  //#region src/client.ts
128
324
  /**
129
- * RemoteServiceClient — runtime-agnostic outbound WS client to the Alfe remote
130
- * relay service. Brings up a persistent WebSocket, decodes binary frames and
131
- * hands them to a surface-agnostic `onFrame` callback, exposes `sendFrame` for
132
- * outbound frames, and reconnects with backoff.
133
- *
134
- * One client per agent. Multiple viewer attachments (browser + terminal) are
135
- * multiplexed by the relay over the single WS using `sessionId`.
136
- *
137
- * Reconnect + heartbeat logic mirrors `@alfe.ai/console-client`'s client.
325
+ * Runtime-agnostic, generation-fenced WebSocket client for the Alfe remote
326
+ * relay. One connection multiplexes every browser and terminal viewer session.
138
327
  */
139
328
  const RECONNECT_DELAYS = [
140
329
  1e3,
@@ -145,6 +334,8 @@ const RECONNECT_DELAYS = [
145
334
  3e4
146
335
  ];
147
336
  const HEARTBEAT_INTERVAL_MS = 3e4;
337
+ const MAX_REMOTE_URL_LENGTH = 2048;
338
+ const MAX_API_KEY_LENGTH = 16384;
148
339
  const defaultLogger = {
149
340
  info: (msg) => {
150
341
  console.log(`[remote-client] ${msg}`);
@@ -162,17 +353,49 @@ function wsDataToBuffer(data) {
162
353
  if (Array.isArray(data)) return Buffer.concat(data);
163
354
  return Buffer.from(data);
164
355
  }
356
+ function validateOptions(options) {
357
+ if (typeof options.wsUrl !== "string" || options.wsUrl.length < 1 || options.wsUrl.length > MAX_REMOTE_URL_LENGTH) throw new TypeError("Remote relay URL must contain 1 to 2048 characters");
358
+ let url;
359
+ try {
360
+ url = new URL(options.wsUrl);
361
+ } catch {
362
+ throw new TypeError("Remote relay URL is invalid");
363
+ }
364
+ const loopback = isLoopbackHostname(url.hostname);
365
+ if (url.protocol !== "wss:" && !(url.protocol === "ws:" && loopback) || url.username || url.password || url.hash) throw new TypeError("Remote relay URL must use WSS without userinfo or fragments (WS is loopback-only)");
366
+ if (typeof options.apiKey !== "string" || options.apiKey.trim().length < 1 || options.apiKey !== options.apiKey.trim() || options.apiKey.length > MAX_API_KEY_LENGTH || /[\r\n]/u.test(options.apiKey)) throw new TypeError("Remote relay API key is invalid");
367
+ if (typeof options.onFrame !== "function") throw new TypeError("Remote frame callback is required");
368
+ if (options.onConnectionChange !== void 0 && typeof options.onConnectionChange !== "function") throw new TypeError("Remote connection callback must be a function");
369
+ return {
370
+ url,
371
+ endpointLabel: `${url.origin}${url.pathname}`
372
+ };
373
+ }
374
+ function isLoopbackHostname(hostname) {
375
+ const normalized = hostname.toLowerCase().replace(/^\[|\]$/gu, "");
376
+ if (normalized === "localhost" || normalized === "::1") return true;
377
+ return /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u.exec(normalized)?.slice(1).every((part) => Number(part) <= 255) ?? false;
378
+ }
379
+ function errorMessage(error) {
380
+ return error instanceof Error ? error.message : String(error);
381
+ }
165
382
  var RemoteServiceClient = class {
166
383
  ws = null;
167
- stopped = false;
384
+ stopped = true;
168
385
  connected = false;
386
+ generation = 0;
169
387
  retryCount = 0;
170
388
  retryTimer = null;
171
389
  heartbeatTimer = null;
172
390
  isAlive = false;
173
391
  log;
392
+ relayUrl;
393
+ endpointLabel;
174
394
  constructor(options) {
175
395
  this.options = options;
396
+ const validated = validateOptions(options);
397
+ this.relayUrl = validated.url;
398
+ this.endpointLabel = validated.endpointLabel;
176
399
  this.log = options.logger ?? defaultLogger;
177
400
  }
178
401
  get isConnected() {
@@ -180,110 +403,159 @@ var RemoteServiceClient = class {
180
403
  }
181
404
  /** Backpressure signal for the screencast pump — bytes queued but not flushed. */
182
405
  get bufferedAmount() {
183
- return this.ws?.bufferedAmount ?? 0;
406
+ return this.ws?.readyState === ws.default.OPEN ? this.ws.bufferedAmount : 0;
184
407
  }
408
+ /** Start one connection lifecycle. Repeated starts in the same lifecycle are no-ops. */
185
409
  start() {
410
+ if (!this.stopped) return;
186
411
  this.log.debug("RemoteServiceClient starting...");
187
412
  this.stopped = false;
188
- this.doConnect();
413
+ this.retryCount = 0;
414
+ const generation = ++this.generation;
415
+ this.doConnect(generation);
189
416
  }
417
+ /** Stop the current lifecycle and invalidate every callback owned by it. */
190
418
  stop() {
419
+ if (this.stopped) return;
191
420
  this.log.debug("RemoteServiceClient stopping...");
192
421
  this.stopped = true;
193
- if (this.retryTimer) {
194
- clearTimeout(this.retryTimer);
195
- this.retryTimer = null;
196
- }
422
+ this.generation += 1;
423
+ this.clearRetry();
197
424
  this.clearHeartbeat();
198
- if (this.ws) {
425
+ const socket = this.ws;
426
+ this.ws = null;
427
+ this.setConnected(false);
428
+ if (socket) {
429
+ socket.removeAllListeners();
430
+ socket.on("error", () => {});
199
431
  try {
200
- this.ws.close(1e3, "client shutdown");
432
+ if (socket.readyState === ws.default.CONNECTING) socket.terminate();
433
+ else socket.close(1e3, "client shutdown");
201
434
  } catch {}
202
- this.ws = null;
203
435
  }
204
- this.connected = false;
205
436
  }
206
- /** Send a pre-encoded binary frame. No-op if the socket isn't open. */
437
+ /** Send a validated pre-encoded agent→relay frame when the socket is open. */
207
438
  sendFrame(buf) {
208
- if (this.ws?.readyState === ws.default.OPEN) this.ws.send(buf, { binary: true });
439
+ if (!isRemoteFrame(buf) || !isAgentToRelayFrameType(buf[0])) throw new TypeError("Outbound remote frame is malformed or invalid for the agent-to-relay direction");
440
+ const socket = this.ws;
441
+ if (socket?.readyState !== ws.default.OPEN) return;
442
+ try {
443
+ socket.send(buf, { binary: true }, (error) => {
444
+ if (error && this.isCurrent(socket, this.generation)) this.log.warn(`Failed to send a remote frame: ${error.message}`);
445
+ });
446
+ } catch (error) {
447
+ this.log.warn(`Failed to send a remote frame: ${errorMessage(error)}`);
448
+ }
209
449
  }
210
- doConnect() {
211
- if (this.stopped) return;
212
- this.log.info(`Connecting to remote relay at ${this.options.wsUrl}...`);
213
- this.ws = new ws.default(this.options.wsUrl, {
214
- headers: { authorization: `Bearer ${this.options.apiKey}` },
215
- maxPayload: REMOTE_MAX_PAYLOAD,
216
- handshakeTimeout: 1e4
217
- });
218
- this.ws.on("open", () => {
450
+ doConnect(generation) {
451
+ if (!this.isActive(generation) || this.ws !== null) return;
452
+ this.log.info(`Connecting to remote relay at ${this.endpointLabel}...`);
453
+ let socket;
454
+ try {
455
+ socket = new ws.default(this.relayUrl, {
456
+ headers: { authorization: `Bearer ${this.options.apiKey}` },
457
+ maxPayload: REMOTE_MAX_PAYLOAD,
458
+ handshakeTimeout: 1e4
459
+ });
460
+ } catch (error) {
461
+ this.log.error(`Could not create remote relay WebSocket: ${errorMessage(error)}`);
462
+ this.scheduleReconnect(generation);
463
+ return;
464
+ }
465
+ this.ws = socket;
466
+ socket.on("open", () => {
467
+ if (!this.isCurrent(socket, generation)) return;
219
468
  this.log.info("Connected to remote relay");
220
- this.connected = true;
221
469
  this.retryCount = 0;
222
- this.startHeartbeat();
223
- this.options.onConnectionChange?.(true);
470
+ this.setConnected(true);
471
+ this.startHeartbeat(socket, generation);
224
472
  });
225
- this.ws.on("message", (data, isBinary) => {
473
+ socket.on("message", (data, isBinary) => {
474
+ if (!this.isCurrent(socket, generation)) return;
226
475
  this.isAlive = true;
227
476
  if (!isBinary) return;
228
477
  const buf = wsDataToBuffer(data);
229
- if (!isRemoteFrame(buf)) return;
478
+ if (!isRemoteFrame(buf) || !isRelayToAgentFrameType(buf[0])) return;
230
479
  try {
231
- this.options.onFrame(decodeFrame(buf));
232
- } catch (err) {
233
- this.log.warn(`Failed to handle remote frame: ${err.message}`);
480
+ Promise.resolve(this.options.onFrame(decodeFrame(buf))).catch((error) => {
481
+ this.log.warn(`Failed to handle remote frame: ${errorMessage(error)}`);
482
+ });
483
+ } catch (error) {
484
+ this.log.warn(`Failed to handle remote frame: ${errorMessage(error)}`);
234
485
  }
235
486
  });
236
- this.ws.on("ping", () => {
487
+ socket.on("ping", () => {
488
+ if (!this.isCurrent(socket, generation)) return;
237
489
  this.isAlive = true;
238
- this.ws?.pong();
239
490
  });
240
- this.ws.on("pong", () => {
241
- this.isAlive = true;
491
+ socket.on("pong", () => {
492
+ if (this.isCurrent(socket, generation)) this.isAlive = true;
242
493
  });
243
- this.ws.on("close", (code, reason) => {
494
+ socket.on("close", (code, reason) => {
495
+ if (!this.isCurrent(socket, generation)) return;
496
+ this.ws = null;
244
497
  this.clearHeartbeat();
245
498
  this.log.warn(`Disconnected from remote relay (${String(code)}: ${reason.toString()})`);
246
- this.connected = false;
247
- this.options.onConnectionChange?.(false);
248
- this.scheduleReconnect();
499
+ this.setConnected(false);
500
+ this.scheduleReconnect(generation);
249
501
  });
250
- this.ws.on("error", (err) => {
251
- this.log.error(`Remote relay WS error: ${err.message}`);
502
+ socket.on("error", (error) => {
503
+ if (this.isCurrent(socket, generation)) this.log.error(`Remote relay WS error: ${error.message}`);
252
504
  });
253
505
  }
254
- scheduleReconnect() {
255
- if (this.stopped) return;
256
- const delay = RECONNECT_DELAYS[Math.min(this.retryCount, RECONNECT_DELAYS.length - 1)];
506
+ scheduleReconnect(generation) {
507
+ if (!this.isActive(generation) || this.retryTimer !== null) return;
508
+ const delay = RECONNECT_DELAYS[Math.min(this.retryCount, RECONNECT_DELAYS.length - 1)] ?? 3e4;
257
509
  this.retryCount += 1;
258
510
  this.log.info(`Reconnecting to remote relay in ${String(delay)}ms (attempt ${String(this.retryCount)})...`);
259
- this.retryTimer = setTimeout(() => {
260
- this.retryTimer = null;
261
- this.doConnect();
511
+ const timer = setTimeout(() => {
512
+ if (this.retryTimer === timer) this.retryTimer = null;
513
+ this.doConnect(generation);
262
514
  }, delay);
515
+ timer.unref();
516
+ this.retryTimer = timer;
263
517
  }
264
- /**
265
- * Detect a silently-dropped connection. Each tick: if no inbound liveness
266
- * (pong/ping/message) was seen since the last tick, force-close so the
267
- * `close` handler reconnects; otherwise send a fresh ping and arm the next.
268
- */
269
- startHeartbeat() {
518
+ startHeartbeat(socket, generation) {
270
519
  this.clearHeartbeat();
271
520
  this.isAlive = true;
272
521
  const timer = setInterval(() => {
273
- if (this.ws?.readyState !== ws.default.OPEN) return;
522
+ if (!this.isCurrent(socket, generation) || socket.readyState !== ws.default.OPEN) return;
274
523
  if (!this.isAlive) {
275
524
  this.log.warn("Remote relay heartbeat timed out — terminating stale connection");
276
- this.ws.terminate();
525
+ socket.terminate();
277
526
  return;
278
527
  }
279
528
  this.isAlive = false;
280
529
  try {
281
- this.ws.ping();
530
+ socket.ping();
282
531
  } catch {}
283
532
  }, HEARTBEAT_INTERVAL_MS);
284
533
  timer.unref();
285
534
  this.heartbeatTimer = timer;
286
535
  }
536
+ setConnected(connected) {
537
+ if (this.connected === connected) return;
538
+ this.connected = connected;
539
+ try {
540
+ Promise.resolve(this.options.onConnectionChange?.(connected)).catch((error) => {
541
+ this.log.warn(`Remote connection callback failed: ${errorMessage(error)}`);
542
+ });
543
+ } catch (error) {
544
+ this.log.warn(`Remote connection callback failed: ${errorMessage(error)}`);
545
+ }
546
+ }
547
+ isActive(generation) {
548
+ return !this.stopped && this.generation === generation;
549
+ }
550
+ isCurrent(socket, generation) {
551
+ return this.isActive(generation) && this.ws === socket;
552
+ }
553
+ clearRetry() {
554
+ if (this.retryTimer) {
555
+ clearTimeout(this.retryTimer);
556
+ this.retryTimer = null;
557
+ }
558
+ }
287
559
  clearHeartbeat() {
288
560
  if (this.heartbeatTimer) {
289
561
  clearInterval(this.heartbeatTimer);
@@ -309,12 +581,29 @@ var TurnController = class {
309
581
  /**
310
582
  * Resolve once the agent is (again) allowed to drive. Resolves immediately if
311
583
  * the agent already holds the token; otherwise parks until `releaseHuman()`.
312
- * Every automation op awaits this before touching the page.
584
+ * Every automation op awaits this before touching the page. Cancellation
585
+ * removes only this waiter and never changes the current human owner.
313
586
  */
314
- acquireAgent() {
587
+ acquireAgent(signal) {
588
+ if (signal?.aborted) return Promise.reject(/* @__PURE__ */ new Error("Agent turn acquisition interrupted"));
315
589
  if (this.owner === "agent") return Promise.resolve();
316
- return new Promise((resolve) => {
317
- this.waiters.push(resolve);
590
+ return new Promise((resolve, reject) => {
591
+ const cleanup = () => {
592
+ const index = this.waiters.indexOf(resume);
593
+ if (index !== -1) this.waiters.splice(index, 1);
594
+ signal?.removeEventListener("abort", onAbort);
595
+ };
596
+ const resume = () => {
597
+ cleanup();
598
+ resolve();
599
+ };
600
+ const onAbort = () => {
601
+ cleanup();
602
+ reject(/* @__PURE__ */ new Error("Agent turn acquisition interrupted"));
603
+ };
604
+ this.waiters.push(resume);
605
+ signal?.addEventListener("abort", onAbort, { once: true });
606
+ if (signal?.aborted) onAbort();
318
607
  });
319
608
  }
320
609
  /**
@@ -343,14 +632,25 @@ var TurnController = class {
343
632
  };
344
633
  //#endregion
345
634
  exports.REMOTE_HEADER_SIZE = REMOTE_HEADER_SIZE;
635
+ exports.REMOTE_MAX_JSON_PAYLOAD = REMOTE_MAX_JSON_PAYLOAD;
346
636
  exports.REMOTE_MAX_PAYLOAD = REMOTE_MAX_PAYLOAD;
347
637
  exports.RemoteFrameType = RemoteFrameType;
348
638
  exports.RemoteServiceClient = RemoteServiceClient;
349
639
  exports.TurnController = TurnController;
350
640
  exports.decodeFrame = decodeFrame;
351
641
  exports.decodeJson = decodeJson;
642
+ exports.decodeKeyInputPayload = decodeKeyInputPayload;
643
+ exports.decodeMouseInputPayload = decodeMouseInputPayload;
644
+ exports.decodeResizePayload = decodeResizePayload;
645
+ exports.decodeScreencastAckPayload = decodeScreencastAckPayload;
352
646
  exports.decodeScreencastFrame = decodeScreencastFrame;
647
+ exports.decodeSessionOpenPayload = decodeSessionOpenPayload;
648
+ exports.decodeTerminalResizePayload = decodeTerminalResizePayload;
649
+ exports.decodeWheelInputPayload = decodeWheelInputPayload;
353
650
  exports.encodeFrame = encodeFrame;
354
651
  exports.encodeJsonFrame = encodeJsonFrame;
355
652
  exports.encodeScreencastFrame = encodeScreencastFrame;
653
+ exports.isAgentToRelayFrameType = isAgentToRelayFrameType;
654
+ exports.isRelayToAgentFrameType = isRelayToAgentFrameType;
356
655
  exports.isRemoteFrame = isRemoteFrame;
656
+ exports.isRemoteFrameType = isRemoteFrameType;