@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 +36 -0
- package/dist/index.cjs +388 -88
- package/dist/index.d.cts +36 -17
- package/dist/index.d.ts +36 -17
- package/dist/index.js +378 -89
- package/package.json +1 -1
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
|
|
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
|
|
44
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
148
|
+
jpeg
|
|
98
149
|
};
|
|
99
150
|
}
|
|
100
|
-
|
|
101
|
-
const
|
|
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
|
-
*
|
|
106
|
-
* relay
|
|
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 =
|
|
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
|
|
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.
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
this.retryTimer = null;
|
|
172
|
-
}
|
|
398
|
+
this.generation += 1;
|
|
399
|
+
this.clearRetry();
|
|
173
400
|
this.clearHeartbeat();
|
|
174
|
-
|
|
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
|
-
|
|
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
|
|
413
|
+
/** Send a validated pre-encoded agent→relay frame when the socket is open. */
|
|
183
414
|
sendFrame(buf) {
|
|
184
|
-
if (
|
|
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.
|
|
188
|
-
this.log.info(`Connecting to remote relay at ${this.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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.
|
|
199
|
-
this.
|
|
446
|
+
this.setConnected(true);
|
|
447
|
+
this.startHeartbeat(socket, generation);
|
|
200
448
|
});
|
|
201
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
463
|
+
socket.on("ping", () => {
|
|
464
|
+
if (!this.isCurrent(socket, generation)) return;
|
|
213
465
|
this.isAlive = true;
|
|
214
|
-
this.ws?.pong();
|
|
215
466
|
});
|
|
216
|
-
|
|
217
|
-
this.isAlive = true;
|
|
467
|
+
socket.on("pong", () => {
|
|
468
|
+
if (this.isCurrent(socket, generation)) this.isAlive = true;
|
|
218
469
|
});
|
|
219
|
-
|
|
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.
|
|
223
|
-
this.
|
|
224
|
-
this.scheduleReconnect();
|
|
475
|
+
this.setConnected(false);
|
|
476
|
+
this.scheduleReconnect(generation);
|
|
225
477
|
});
|
|
226
|
-
|
|
227
|
-
this.log.error(`Remote relay WS error: ${
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
501
|
+
socket.terminate();
|
|
253
502
|
return;
|
|
254
503
|
}
|
|
255
504
|
this.isAlive = false;
|
|
256
505
|
try {
|
|
257
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|