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