@alfe.ai/remote 0.0.0 → 0.1.1
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 +367 -84
- package/dist/index.d.cts +33 -15
- package/dist/index.d.ts +33 -15
- package/dist/index.js +357 -85
- package/package.json +9 -1
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
|
|
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
|
|
68
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
172
|
+
jpeg
|
|
122
173
|
};
|
|
123
174
|
}
|
|
124
|
-
|
|
125
|
-
const
|
|
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
|
-
*
|
|
130
|
-
* relay
|
|
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 =
|
|
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
|
|
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.
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
this.retryTimer = null;
|
|
196
|
-
}
|
|
422
|
+
this.generation += 1;
|
|
423
|
+
this.clearRetry();
|
|
197
424
|
this.clearHeartbeat();
|
|
198
|
-
|
|
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
|
-
|
|
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
|
|
437
|
+
/** Send a validated pre-encoded agent→relay frame when the socket is open. */
|
|
207
438
|
sendFrame(buf) {
|
|
208
|
-
if (
|
|
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.
|
|
212
|
-
this.log.info(`Connecting to remote relay at ${this.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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.
|
|
223
|
-
this.
|
|
470
|
+
this.setConnected(true);
|
|
471
|
+
this.startHeartbeat(socket, generation);
|
|
224
472
|
});
|
|
225
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
|
|
487
|
+
socket.on("ping", () => {
|
|
488
|
+
if (!this.isCurrent(socket, generation)) return;
|
|
237
489
|
this.isAlive = true;
|
|
238
|
-
this.ws?.pong();
|
|
239
490
|
});
|
|
240
|
-
|
|
241
|
-
this.isAlive = true;
|
|
491
|
+
socket.on("pong", () => {
|
|
492
|
+
if (this.isCurrent(socket, generation)) this.isAlive = true;
|
|
242
493
|
});
|
|
243
|
-
|
|
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.
|
|
247
|
-
this.
|
|
248
|
-
this.scheduleReconnect();
|
|
499
|
+
this.setConnected(false);
|
|
500
|
+
this.scheduleReconnect(generation);
|
|
249
501
|
});
|
|
250
|
-
|
|
251
|
-
this.log.error(`Remote relay WS error: ${
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
525
|
+
socket.terminate();
|
|
277
526
|
return;
|
|
278
527
|
}
|
|
279
528
|
this.isAlive = false;
|
|
280
529
|
try {
|
|
281
|
-
|
|
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);
|
|
@@ -343,14 +615,25 @@ var TurnController = class {
|
|
|
343
615
|
};
|
|
344
616
|
//#endregion
|
|
345
617
|
exports.REMOTE_HEADER_SIZE = REMOTE_HEADER_SIZE;
|
|
618
|
+
exports.REMOTE_MAX_JSON_PAYLOAD = REMOTE_MAX_JSON_PAYLOAD;
|
|
346
619
|
exports.REMOTE_MAX_PAYLOAD = REMOTE_MAX_PAYLOAD;
|
|
347
620
|
exports.RemoteFrameType = RemoteFrameType;
|
|
348
621
|
exports.RemoteServiceClient = RemoteServiceClient;
|
|
349
622
|
exports.TurnController = TurnController;
|
|
350
623
|
exports.decodeFrame = decodeFrame;
|
|
351
624
|
exports.decodeJson = decodeJson;
|
|
625
|
+
exports.decodeKeyInputPayload = decodeKeyInputPayload;
|
|
626
|
+
exports.decodeMouseInputPayload = decodeMouseInputPayload;
|
|
627
|
+
exports.decodeResizePayload = decodeResizePayload;
|
|
628
|
+
exports.decodeScreencastAckPayload = decodeScreencastAckPayload;
|
|
352
629
|
exports.decodeScreencastFrame = decodeScreencastFrame;
|
|
630
|
+
exports.decodeSessionOpenPayload = decodeSessionOpenPayload;
|
|
631
|
+
exports.decodeTerminalResizePayload = decodeTerminalResizePayload;
|
|
632
|
+
exports.decodeWheelInputPayload = decodeWheelInputPayload;
|
|
353
633
|
exports.encodeFrame = encodeFrame;
|
|
354
634
|
exports.encodeJsonFrame = encodeJsonFrame;
|
|
355
635
|
exports.encodeScreencastFrame = encodeScreencastFrame;
|
|
636
|
+
exports.isAgentToRelayFrameType = isAgentToRelayFrameType;
|
|
637
|
+
exports.isRelayToAgentFrameType = isRelayToAgentFrameType;
|
|
356
638
|
exports.isRemoteFrame = isRemoteFrame;
|
|
639
|
+
exports.isRemoteFrameType = isRemoteFrameType;
|