@aigne/afs-session 1.11.0-beta.6
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/LICENSE.md +26 -0
- package/dist/index.d.mts +642 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1345 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +59 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1345 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as crypto from "node:crypto";
|
|
4
|
+
|
|
5
|
+
//#region src/protocol/types.ts
|
|
6
|
+
/**
|
|
7
|
+
* Protocol constants
|
|
8
|
+
*/
|
|
9
|
+
const HEADER_SIZE = 9;
|
|
10
|
+
const MAX_PAYLOAD_SIZE = 16 * 1024 * 1024;
|
|
11
|
+
const MAX_JSON_DEPTH = 100;
|
|
12
|
+
const MAX_JSON_SIZE = 1 * 1024 * 1024;
|
|
13
|
+
const MAX_UINT32 = 4294967295;
|
|
14
|
+
/**
|
|
15
|
+
* Frame types
|
|
16
|
+
*/
|
|
17
|
+
let FrameType = /* @__PURE__ */ function(FrameType) {
|
|
18
|
+
FrameType[FrameType["JSON"] = 1] = "JSON";
|
|
19
|
+
FrameType[FrameType["Binary"] = 2] = "Binary";
|
|
20
|
+
return FrameType;
|
|
21
|
+
}({});
|
|
22
|
+
/**
|
|
23
|
+
* Protocol errors
|
|
24
|
+
*/
|
|
25
|
+
var ProtocolError = class extends Error {
|
|
26
|
+
constructor(code, message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.code = code;
|
|
29
|
+
this.name = "ProtocolError";
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
var PayloadTooLargeError = class extends ProtocolError {
|
|
33
|
+
constructor(size) {
|
|
34
|
+
super("payload_too_large", `Payload size ${size} exceeds maximum ${MAX_PAYLOAD_SIZE}`);
|
|
35
|
+
this.name = "PayloadTooLargeError";
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
var InvalidPayloadError = class extends ProtocolError {
|
|
39
|
+
constructor(message) {
|
|
40
|
+
super("invalid_payload", message);
|
|
41
|
+
this.name = "InvalidPayloadError";
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
var InvalidFrameTypeError = class extends ProtocolError {
|
|
45
|
+
constructor(type) {
|
|
46
|
+
super("invalid_frame_type", `Invalid frame type: 0x${type.toString(16).padStart(2, "0")}`);
|
|
47
|
+
this.name = "InvalidFrameTypeError";
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
var InvalidReqIdError = class extends ProtocolError {
|
|
51
|
+
constructor(reqId) {
|
|
52
|
+
super("invalid_req_id", `Invalid request ID: ${reqId} (must be 0-${MAX_UINT32})`);
|
|
53
|
+
this.name = "InvalidReqIdError";
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
var IncompleteFrameError = class extends ProtocolError {
|
|
57
|
+
constructor(message) {
|
|
58
|
+
super("incomplete_frame", message);
|
|
59
|
+
this.name = "IncompleteFrameError";
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region src/protocol/frame.ts
|
|
65
|
+
/**
|
|
66
|
+
* Validates frame type.
|
|
67
|
+
*/
|
|
68
|
+
function isValidFrameType(type) {
|
|
69
|
+
return type === FrameType.JSON || type === FrameType.Binary;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Encodes a frame with the given type, request ID, and payload.
|
|
73
|
+
*
|
|
74
|
+
* Frame structure (big-endian):
|
|
75
|
+
* - Length (4 bytes): payload length
|
|
76
|
+
* - Type (1 byte): frame type
|
|
77
|
+
* - ReqId (4 bytes): request ID
|
|
78
|
+
* - Payload (variable): actual data
|
|
79
|
+
*
|
|
80
|
+
* @param type Frame type (JSON or Binary)
|
|
81
|
+
* @param reqId Request ID (0 for events, >0 for request/response)
|
|
82
|
+
* @param payload The payload data
|
|
83
|
+
* @returns Encoded frame as Uint8Array
|
|
84
|
+
*/
|
|
85
|
+
function encodeFrame(type, reqId, payload) {
|
|
86
|
+
if (payload == null) throw new InvalidPayloadError("Payload cannot be null or undefined");
|
|
87
|
+
if (payload.length > MAX_PAYLOAD_SIZE) throw new PayloadTooLargeError(payload.length);
|
|
88
|
+
if (!isValidFrameType(type)) throw new InvalidFrameTypeError(type);
|
|
89
|
+
if (reqId < 0 || reqId > MAX_UINT32 || !Number.isInteger(reqId)) throw new InvalidReqIdError(reqId);
|
|
90
|
+
const frame = new Uint8Array(HEADER_SIZE + payload.length);
|
|
91
|
+
const view = new DataView(frame.buffer, frame.byteOffset);
|
|
92
|
+
view.setUint32(0, payload.length);
|
|
93
|
+
view.setUint8(4, type);
|
|
94
|
+
view.setUint32(5, reqId);
|
|
95
|
+
frame.set(payload, HEADER_SIZE);
|
|
96
|
+
return frame;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Decodes a frame from a buffer.
|
|
100
|
+
*
|
|
101
|
+
* @param buffer The buffer containing the frame data
|
|
102
|
+
* @returns Object containing the decoded frame and any remaining bytes
|
|
103
|
+
* @throws IncompleteFrameError if buffer doesn't contain a complete frame
|
|
104
|
+
* @throws InvalidFrameTypeError if frame type is invalid
|
|
105
|
+
* @throws PayloadTooLargeError if payload length exceeds maximum
|
|
106
|
+
*/
|
|
107
|
+
function decodeFrame(buffer) {
|
|
108
|
+
if (buffer.length < HEADER_SIZE) throw new IncompleteFrameError(`Buffer too small: ${buffer.length} bytes, need at least ${HEADER_SIZE}`);
|
|
109
|
+
const view = new DataView(buffer.buffer, buffer.byteOffset);
|
|
110
|
+
const length = view.getUint32(0);
|
|
111
|
+
const type = view.getUint8(4);
|
|
112
|
+
const reqId = view.getUint32(5);
|
|
113
|
+
if (length > MAX_PAYLOAD_SIZE) throw new PayloadTooLargeError(length);
|
|
114
|
+
if (!isValidFrameType(type)) throw new InvalidFrameTypeError(type);
|
|
115
|
+
const totalSize = HEADER_SIZE + length;
|
|
116
|
+
if (buffer.length < totalSize) throw new IncompleteFrameError(`Incomplete frame: have ${buffer.length} bytes, need ${totalSize}`);
|
|
117
|
+
const payload = buffer.slice(HEADER_SIZE, totalSize);
|
|
118
|
+
const remaining = buffer.slice(totalSize);
|
|
119
|
+
return {
|
|
120
|
+
frame: {
|
|
121
|
+
type,
|
|
122
|
+
reqId,
|
|
123
|
+
payload
|
|
124
|
+
},
|
|
125
|
+
remaining
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
//#endregion
|
|
130
|
+
//#region src/protocol/frame-decoder.ts
|
|
131
|
+
/**
|
|
132
|
+
* Error thrown when pending buffer exceeds maximum size
|
|
133
|
+
*/
|
|
134
|
+
var BufferOverflowError = class extends ProtocolError {
|
|
135
|
+
constructor(size, maxSize) {
|
|
136
|
+
super("buffer_overflow", `Pending buffer size ${size} exceeds maximum ${maxSize}`);
|
|
137
|
+
this.name = "BufferOverflowError";
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* Streaming frame decoder that handles:
|
|
142
|
+
* - Partial frames across multiple pushes (拆包)
|
|
143
|
+
* - Multiple frames in single push (粘包)
|
|
144
|
+
* - Buffer overflow protection (security)
|
|
145
|
+
*/
|
|
146
|
+
var FrameDecoder = class {
|
|
147
|
+
buffer = new Uint8Array(0);
|
|
148
|
+
maxPendingSize;
|
|
149
|
+
constructor(options) {
|
|
150
|
+
this.maxPendingSize = options?.maxPendingSize ?? MAX_PAYLOAD_SIZE + HEADER_SIZE;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Push data into the decoder and return any complete frames.
|
|
154
|
+
*
|
|
155
|
+
* @param data New data to append to buffer
|
|
156
|
+
* @returns Array of complete frames (may be empty)
|
|
157
|
+
* @throws InvalidFrameTypeError if frame type is invalid
|
|
158
|
+
* @throws PayloadTooLargeError if payload exceeds maximum
|
|
159
|
+
* @throws BufferOverflowError if pending buffer exceeds maximum
|
|
160
|
+
*/
|
|
161
|
+
push(data) {
|
|
162
|
+
if (data.length === 0) return [];
|
|
163
|
+
const newBuffer = new Uint8Array(this.buffer.length + data.length);
|
|
164
|
+
newBuffer.set(this.buffer, 0);
|
|
165
|
+
newBuffer.set(data, this.buffer.length);
|
|
166
|
+
this.buffer = newBuffer;
|
|
167
|
+
if (this.buffer.length > this.maxPendingSize) throw new BufferOverflowError(this.buffer.length, this.maxPendingSize);
|
|
168
|
+
const frames = [];
|
|
169
|
+
while (this.buffer.length >= HEADER_SIZE) {
|
|
170
|
+
const payloadLength = new DataView(this.buffer.buffer, this.buffer.byteOffset).getUint32(0);
|
|
171
|
+
if (payloadLength > MAX_PAYLOAD_SIZE) throw new PayloadTooLargeError(payloadLength);
|
|
172
|
+
const totalFrameSize = HEADER_SIZE + payloadLength;
|
|
173
|
+
if (this.buffer.length < totalFrameSize) break;
|
|
174
|
+
const { frame, remaining } = decodeFrame(this.buffer);
|
|
175
|
+
frames.push(frame);
|
|
176
|
+
this.buffer = remaining;
|
|
177
|
+
}
|
|
178
|
+
return frames;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Reset the decoder state, discarding any buffered data.
|
|
182
|
+
*/
|
|
183
|
+
reset() {
|
|
184
|
+
this.buffer = new Uint8Array(0);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Get the current pending buffer size.
|
|
188
|
+
*/
|
|
189
|
+
get pendingSize() {
|
|
190
|
+
return this.buffer.length;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
//#endregion
|
|
195
|
+
//#region src/protocol/message.ts
|
|
196
|
+
/**
|
|
197
|
+
* Error for invalid message structure
|
|
198
|
+
*/
|
|
199
|
+
var InvalidMessageError = class extends ProtocolError {
|
|
200
|
+
constructor(message) {
|
|
201
|
+
super("invalid_message", message);
|
|
202
|
+
this.name = "InvalidMessageError";
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
/**
|
|
206
|
+
* Error for JSON parsing failures
|
|
207
|
+
*/
|
|
208
|
+
var ParseError = class extends ProtocolError {
|
|
209
|
+
constructor(message) {
|
|
210
|
+
super("parse_error", `Parse error: ${message}`);
|
|
211
|
+
this.name = "ParseError";
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
/**
|
|
215
|
+
* Error for depth limit exceeded
|
|
216
|
+
*/
|
|
217
|
+
var DepthLimitExceededError = class extends ProtocolError {
|
|
218
|
+
constructor() {
|
|
219
|
+
super("depth_limit_exceeded", `JSON depth limit exceeded (max ${MAX_JSON_DEPTH})`);
|
|
220
|
+
this.name = "DepthLimitExceededError";
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
/**
|
|
224
|
+
* Error for message too large
|
|
225
|
+
*/
|
|
226
|
+
var MessageTooLargeError = class extends ProtocolError {
|
|
227
|
+
constructor(size) {
|
|
228
|
+
super("message_too_large", `Message too large: ${size} bytes (max ${MAX_JSON_SIZE})`);
|
|
229
|
+
this.name = "MessageTooLargeError";
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
/**
|
|
233
|
+
* Dangerous keys that should be filtered for security
|
|
234
|
+
*/
|
|
235
|
+
const DANGEROUS_KEYS = new Set([
|
|
236
|
+
"__proto__",
|
|
237
|
+
"constructor",
|
|
238
|
+
"prototype"
|
|
239
|
+
]);
|
|
240
|
+
/**
|
|
241
|
+
* Recursively filter dangerous keys from an object
|
|
242
|
+
*/
|
|
243
|
+
function filterDangerousKeys(obj, depth = 0) {
|
|
244
|
+
if (depth > MAX_JSON_DEPTH) throw new DepthLimitExceededError();
|
|
245
|
+
if (obj === null || typeof obj !== "object") return obj;
|
|
246
|
+
if (Array.isArray(obj)) return obj.map((item) => filterDangerousKeys(item, depth + 1));
|
|
247
|
+
const filtered = {};
|
|
248
|
+
for (const [key, value] of Object.entries(obj)) if (!DANGEROUS_KEYS.has(key)) filtered[key] = filterDangerousKeys(value, depth + 1);
|
|
249
|
+
return filtered;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Calculate depth of a JSON object
|
|
253
|
+
*/
|
|
254
|
+
function calculateDepth(obj, depth = 0) {
|
|
255
|
+
if (depth > MAX_JSON_DEPTH) return depth;
|
|
256
|
+
if (obj === null || typeof obj !== "object") return depth;
|
|
257
|
+
let maxDepth = depth;
|
|
258
|
+
if (Array.isArray(obj)) for (const item of obj) {
|
|
259
|
+
maxDepth = Math.max(maxDepth, calculateDepth(item, depth + 1));
|
|
260
|
+
if (maxDepth > MAX_JSON_DEPTH) return maxDepth;
|
|
261
|
+
}
|
|
262
|
+
else for (const value of Object.values(obj)) {
|
|
263
|
+
maxDepth = Math.max(maxDepth, calculateDepth(value, depth + 1));
|
|
264
|
+
if (maxDepth > MAX_JSON_DEPTH) return maxDepth;
|
|
265
|
+
}
|
|
266
|
+
return maxDepth;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Serialize a message to JSON bytes.
|
|
270
|
+
*
|
|
271
|
+
* @param message The message to serialize
|
|
272
|
+
* @returns UTF-8 encoded JSON bytes
|
|
273
|
+
* @throws InvalidMessageError if message is invalid
|
|
274
|
+
*/
|
|
275
|
+
function serializeMessage(message) {
|
|
276
|
+
if (!message || typeof message !== "object" || !("type" in message)) throw new InvalidMessageError("Invalid message: missing type");
|
|
277
|
+
const { type } = message;
|
|
278
|
+
if (type === "request") {
|
|
279
|
+
const req = message;
|
|
280
|
+
if (!req.id) throw new InvalidMessageError("Invalid request: missing id");
|
|
281
|
+
if (!req.method) throw new InvalidMessageError("Invalid request: missing method");
|
|
282
|
+
}
|
|
283
|
+
const clean = {};
|
|
284
|
+
for (const [key, value] of Object.entries(message)) if (value !== void 0) clean[key] = value;
|
|
285
|
+
const json = JSON.stringify(clean);
|
|
286
|
+
return new TextEncoder().encode(json);
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Deserialize JSON bytes to a message.
|
|
290
|
+
*
|
|
291
|
+
* @param data UTF-8 encoded JSON bytes
|
|
292
|
+
* @returns Parsed message
|
|
293
|
+
* @throws ParseError if JSON is invalid
|
|
294
|
+
* @throws InvalidMessageError if message structure is invalid
|
|
295
|
+
* @throws DepthLimitExceededError if JSON is too deeply nested
|
|
296
|
+
* @throws MessageTooLargeError if message exceeds size limit
|
|
297
|
+
*/
|
|
298
|
+
function deserializeMessage(data) {
|
|
299
|
+
if (data.length > MAX_JSON_SIZE) throw new MessageTooLargeError(data.length);
|
|
300
|
+
let parsed;
|
|
301
|
+
try {
|
|
302
|
+
const json = new TextDecoder().decode(data);
|
|
303
|
+
parsed = JSON.parse(json);
|
|
304
|
+
} catch (e) {
|
|
305
|
+
throw new ParseError(e instanceof Error ? e.message : "Invalid JSON");
|
|
306
|
+
}
|
|
307
|
+
if (!parsed || typeof parsed !== "object") throw new InvalidMessageError("Invalid message: not an object");
|
|
308
|
+
if (calculateDepth(parsed) > MAX_JSON_DEPTH) throw new DepthLimitExceededError();
|
|
309
|
+
const filtered = filterDangerousKeys(parsed);
|
|
310
|
+
if (!("type" in filtered) || typeof filtered.type !== "string") throw new InvalidMessageError("Invalid message: missing type");
|
|
311
|
+
const { type } = filtered;
|
|
312
|
+
if (type !== "request" && type !== "response" && type !== "event") throw new InvalidMessageError(`Invalid message type: ${type}`);
|
|
313
|
+
return filtered;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
//#endregion
|
|
317
|
+
//#region src/session/types.ts
|
|
318
|
+
/**
|
|
319
|
+
* Protocol version
|
|
320
|
+
*/
|
|
321
|
+
const PROTOCOL_VERSION = "1.0";
|
|
322
|
+
/**
|
|
323
|
+
* Session error
|
|
324
|
+
*/
|
|
325
|
+
var SessionError = class extends Error {
|
|
326
|
+
constructor(code, message) {
|
|
327
|
+
super(message);
|
|
328
|
+
this.code = code;
|
|
329
|
+
this.name = "SessionError";
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
/**
|
|
333
|
+
* Default options
|
|
334
|
+
*/
|
|
335
|
+
const DEFAULT_SESSION_OPTIONS = {
|
|
336
|
+
maxSessions: 100,
|
|
337
|
+
requestTimeout: 3e4,
|
|
338
|
+
sessionTimeout: 3e4,
|
|
339
|
+
idleTimeout: 3e5,
|
|
340
|
+
heartbeatInterval: 3e4,
|
|
341
|
+
heartbeatTimeout: 9e4
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
//#endregion
|
|
345
|
+
//#region src/session/host.ts
|
|
346
|
+
/**
|
|
347
|
+
* Counter for generating unique session IDs
|
|
348
|
+
*/
|
|
349
|
+
let sessionCounter = 0;
|
|
350
|
+
/**
|
|
351
|
+
* Validates namespace string
|
|
352
|
+
*/
|
|
353
|
+
function isValidNamespace(namespace) {
|
|
354
|
+
if (namespace.length === 0 || namespace.length > 255) return false;
|
|
355
|
+
if (namespace.includes("..") || namespace.includes("/") || namespace.includes(":")) return false;
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Generate session ID with uniqueness guarantee
|
|
360
|
+
*/
|
|
361
|
+
function generateSessionId(namespace) {
|
|
362
|
+
sessionCounter++;
|
|
363
|
+
const random = Math.random().toString(36).substring(2, 8);
|
|
364
|
+
return `sess_${namespace}_${Date.now()}_${sessionCounter}_${random}`;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Session implementation
|
|
368
|
+
*/
|
|
369
|
+
var SessionImpl = class {
|
|
370
|
+
id;
|
|
371
|
+
namespace;
|
|
372
|
+
name;
|
|
373
|
+
createdAt;
|
|
374
|
+
afs;
|
|
375
|
+
_state = "pending";
|
|
376
|
+
constructor(params) {
|
|
377
|
+
this.id = params.id || generateSessionId(params.namespace);
|
|
378
|
+
this.namespace = params.namespace;
|
|
379
|
+
this.name = params.name;
|
|
380
|
+
this.createdAt = /* @__PURE__ */ new Date();
|
|
381
|
+
this.afs = params.afs;
|
|
382
|
+
this._state = "attached";
|
|
383
|
+
}
|
|
384
|
+
get state() {
|
|
385
|
+
return this._state;
|
|
386
|
+
}
|
|
387
|
+
setState(state) {
|
|
388
|
+
this._state = state;
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
/**
|
|
392
|
+
* Stub AFS module for injected sessions.
|
|
393
|
+
* The real AFS operations are proxied back to the client.
|
|
394
|
+
*/
|
|
395
|
+
var InjectedAFSModule = class {
|
|
396
|
+
name;
|
|
397
|
+
description;
|
|
398
|
+
constructor(name, description) {
|
|
399
|
+
this.name = name;
|
|
400
|
+
this.description = description;
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
/**
|
|
404
|
+
* Session host manages multiple client sessions (Observer side).
|
|
405
|
+
*/
|
|
406
|
+
var SessionHost = class {
|
|
407
|
+
options;
|
|
408
|
+
connections = /* @__PURE__ */ new Map();
|
|
409
|
+
sessions = /* @__PURE__ */ new Map();
|
|
410
|
+
namespaces = /* @__PURE__ */ new Map();
|
|
411
|
+
sessionToConnection = /* @__PURE__ */ new Map();
|
|
412
|
+
disconnectedSessions = /* @__PURE__ */ new Map();
|
|
413
|
+
constructor(options = {}) {
|
|
414
|
+
this.options = {
|
|
415
|
+
...DEFAULT_SESSION_OPTIONS,
|
|
416
|
+
...options
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Handle a new transport connection.
|
|
421
|
+
*/
|
|
422
|
+
handleConnection(transport) {
|
|
423
|
+
const state = {
|
|
424
|
+
transport,
|
|
425
|
+
attached: false,
|
|
426
|
+
lastPong: Date.now()
|
|
427
|
+
};
|
|
428
|
+
this.connections.set(transport, state);
|
|
429
|
+
transport.on("frame", (frame) => {
|
|
430
|
+
this.handleFrame(state, frame).catch((err) => {
|
|
431
|
+
console.error("Error handling frame:", err);
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
transport.on("close", () => {
|
|
435
|
+
this.handleDisconnect(state);
|
|
436
|
+
});
|
|
437
|
+
transport.on("error", (err) => {
|
|
438
|
+
console.error("Transport error:", err);
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Handle incoming frame from a connection.
|
|
443
|
+
*/
|
|
444
|
+
async handleFrame(state, frame) {
|
|
445
|
+
if (frame.type !== FrameType.JSON) return;
|
|
446
|
+
try {
|
|
447
|
+
const message = deserializeMessage(frame.payload);
|
|
448
|
+
if (message.type === "event") {
|
|
449
|
+
if (message.event === "session.pong") state.lastPong = Date.now();
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (message.type !== "request") return;
|
|
453
|
+
const request = message;
|
|
454
|
+
let response;
|
|
455
|
+
if (request.method === "session.attach") response = await this.handleAttach(state, request);
|
|
456
|
+
else if (request.method === "session.detach") response = await this.handleDetach(state, request);
|
|
457
|
+
else if (request.method.startsWith("afs.")) response = await this.handleAfsRequest(state, request);
|
|
458
|
+
else response = {
|
|
459
|
+
type: "response",
|
|
460
|
+
id: request.id,
|
|
461
|
+
error: {
|
|
462
|
+
code: "invalid_request",
|
|
463
|
+
message: `Unknown method: ${request.method}`
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
await this.sendResponse(state.transport, response);
|
|
467
|
+
} catch (err) {
|
|
468
|
+
console.error("Error processing frame:", err);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Handle session.attach request.
|
|
473
|
+
*/
|
|
474
|
+
async handleAttach(state, request) {
|
|
475
|
+
if (state.attached) return {
|
|
476
|
+
type: "response",
|
|
477
|
+
id: request.id,
|
|
478
|
+
error: {
|
|
479
|
+
code: "invalid_request",
|
|
480
|
+
message: "Already attached to a session"
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
const params = request.params;
|
|
484
|
+
if (!params || !params.version) return {
|
|
485
|
+
type: "response",
|
|
486
|
+
id: request.id,
|
|
487
|
+
error: {
|
|
488
|
+
code: "invalid_request",
|
|
489
|
+
message: "Missing required parameter: version"
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
if (!params.namespace) return {
|
|
493
|
+
type: "response",
|
|
494
|
+
id: request.id,
|
|
495
|
+
error: {
|
|
496
|
+
code: "invalid_request",
|
|
497
|
+
message: "Missing required parameter: namespace"
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
if (params.version !== PROTOCOL_VERSION) return {
|
|
501
|
+
type: "response",
|
|
502
|
+
id: request.id,
|
|
503
|
+
error: {
|
|
504
|
+
code: "version_mismatch",
|
|
505
|
+
message: `Version mismatch: expected ${PROTOCOL_VERSION}, got ${params.version}`
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
if (!isValidNamespace(params.namespace)) return {
|
|
509
|
+
type: "response",
|
|
510
|
+
id: request.id,
|
|
511
|
+
error: {
|
|
512
|
+
code: "invalid_request",
|
|
513
|
+
message: "Invalid namespace"
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
if (params.sessionId) return this.handleReconnect(state, request, params);
|
|
517
|
+
if (this.sessions.size >= this.options.maxSessions) return {
|
|
518
|
+
type: "response",
|
|
519
|
+
id: request.id,
|
|
520
|
+
error: {
|
|
521
|
+
code: "invalid_request",
|
|
522
|
+
message: "Maximum sessions reached"
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
const existingSession = this.namespaces.get(params.namespace);
|
|
526
|
+
if (existingSession) if (params.replace) {
|
|
527
|
+
const oldConnectionState = this.sessionToConnection.get(existingSession.id);
|
|
528
|
+
if (oldConnectionState) {
|
|
529
|
+
await this.sendEvent(oldConnectionState.transport, {
|
|
530
|
+
type: "event",
|
|
531
|
+
event: "session.replaced",
|
|
532
|
+
data: {
|
|
533
|
+
sessionId: existingSession.id,
|
|
534
|
+
namespace: existingSession.namespace,
|
|
535
|
+
reason: "replaced by new connection"
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
if (oldConnectionState.heartbeatTimer) clearInterval(oldConnectionState.heartbeatTimer);
|
|
539
|
+
oldConnectionState.session = void 0;
|
|
540
|
+
oldConnectionState.attached = false;
|
|
541
|
+
this.sessionToConnection.delete(existingSession.id);
|
|
542
|
+
}
|
|
543
|
+
existingSession.setState("disconnected");
|
|
544
|
+
this.sessions.delete(existingSession.id);
|
|
545
|
+
this.namespaces.delete(params.namespace);
|
|
546
|
+
} else return {
|
|
547
|
+
type: "response",
|
|
548
|
+
id: request.id,
|
|
549
|
+
error: {
|
|
550
|
+
code: "namespace_conflict",
|
|
551
|
+
message: `Namespace '${params.namespace}' already exists`
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
const afs = new InjectedAFSModule(params.namespace, params.name);
|
|
555
|
+
const session = new SessionImpl({
|
|
556
|
+
namespace: params.namespace,
|
|
557
|
+
name: params.name,
|
|
558
|
+
afs
|
|
559
|
+
});
|
|
560
|
+
this.sessions.set(session.id, session);
|
|
561
|
+
this.namespaces.set(session.namespace, session);
|
|
562
|
+
this.sessionToConnection.set(session.id, state);
|
|
563
|
+
state.session = session;
|
|
564
|
+
state.attached = true;
|
|
565
|
+
this.startHeartbeat(state);
|
|
566
|
+
const result = {
|
|
567
|
+
sessionId: session.id,
|
|
568
|
+
namespace: session.namespace
|
|
569
|
+
};
|
|
570
|
+
return {
|
|
571
|
+
type: "response",
|
|
572
|
+
id: request.id,
|
|
573
|
+
result
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Handle reconnect with existing session ID.
|
|
578
|
+
*/
|
|
579
|
+
async handleReconnect(state, request, params) {
|
|
580
|
+
const sessionId = params.sessionId;
|
|
581
|
+
const namespace = params.namespace;
|
|
582
|
+
const disconnected = this.disconnectedSessions.get(sessionId);
|
|
583
|
+
if (disconnected) {
|
|
584
|
+
const session = disconnected.session;
|
|
585
|
+
if (session.namespace !== namespace) return {
|
|
586
|
+
type: "response",
|
|
587
|
+
id: request.id,
|
|
588
|
+
error: {
|
|
589
|
+
code: "invalid_session",
|
|
590
|
+
message: "Session ID does not match namespace"
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
clearTimeout(disconnected.timeoutId);
|
|
594
|
+
this.disconnectedSessions.delete(sessionId);
|
|
595
|
+
session.setState("attached");
|
|
596
|
+
this.sessions.set(session.id, session);
|
|
597
|
+
this.namespaces.set(session.namespace, session);
|
|
598
|
+
this.sessionToConnection.set(session.id, state);
|
|
599
|
+
state.session = session;
|
|
600
|
+
state.attached = true;
|
|
601
|
+
this.startHeartbeat(state);
|
|
602
|
+
return {
|
|
603
|
+
type: "response",
|
|
604
|
+
id: request.id,
|
|
605
|
+
result: {
|
|
606
|
+
sessionId: session.id,
|
|
607
|
+
namespace: session.namespace
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
const activeSession = this.sessions.get(sessionId);
|
|
612
|
+
if (activeSession) {
|
|
613
|
+
if (activeSession.namespace !== namespace) return {
|
|
614
|
+
type: "response",
|
|
615
|
+
id: request.id,
|
|
616
|
+
error: {
|
|
617
|
+
code: "invalid_session",
|
|
618
|
+
message: "Session ID does not match namespace"
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
return {
|
|
622
|
+
type: "response",
|
|
623
|
+
id: request.id,
|
|
624
|
+
error: {
|
|
625
|
+
code: "invalid_session",
|
|
626
|
+
message: "Session is already active"
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
return {
|
|
631
|
+
type: "response",
|
|
632
|
+
id: request.id,
|
|
633
|
+
error: {
|
|
634
|
+
code: "invalid_session",
|
|
635
|
+
message: "Session not found or expired"
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Start heartbeat for a connection.
|
|
641
|
+
*/
|
|
642
|
+
startHeartbeat(state) {
|
|
643
|
+
if (this.options.heartbeatInterval <= 0) return;
|
|
644
|
+
state.lastPong = Date.now();
|
|
645
|
+
state.heartbeatTimer = setInterval(() => {
|
|
646
|
+
if (Date.now() - state.lastPong > this.options.heartbeatTimeout) {
|
|
647
|
+
this.handleHeartbeatTimeout(state);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
this.sendEvent(state.transport, {
|
|
651
|
+
type: "event",
|
|
652
|
+
event: "session.ping",
|
|
653
|
+
data: { timestamp: Date.now() }
|
|
654
|
+
}).catch((err) => {
|
|
655
|
+
console.error("Error sending ping:", err);
|
|
656
|
+
});
|
|
657
|
+
}, this.options.heartbeatInterval);
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Handle heartbeat timeout.
|
|
661
|
+
*/
|
|
662
|
+
handleHeartbeatTimeout(state) {
|
|
663
|
+
if (state.heartbeatTimer) {
|
|
664
|
+
clearInterval(state.heartbeatTimer);
|
|
665
|
+
state.heartbeatTimer = void 0;
|
|
666
|
+
}
|
|
667
|
+
this.handleDisconnect(state);
|
|
668
|
+
state.transport.close().catch(() => {});
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Handle session.detach request.
|
|
672
|
+
*/
|
|
673
|
+
async handleDetach(state, request) {
|
|
674
|
+
if (!state.attached || !state.session) return {
|
|
675
|
+
type: "response",
|
|
676
|
+
id: request.id,
|
|
677
|
+
error: {
|
|
678
|
+
code: "invalid_session",
|
|
679
|
+
message: "Not attached to a session"
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
const session = state.session;
|
|
683
|
+
if (state.heartbeatTimer) {
|
|
684
|
+
clearInterval(state.heartbeatTimer);
|
|
685
|
+
state.heartbeatTimer = void 0;
|
|
686
|
+
}
|
|
687
|
+
session.setState("disconnected");
|
|
688
|
+
this.sessions.delete(session.id);
|
|
689
|
+
this.namespaces.delete(session.namespace);
|
|
690
|
+
this.sessionToConnection.delete(session.id);
|
|
691
|
+
state.session = void 0;
|
|
692
|
+
state.attached = false;
|
|
693
|
+
return {
|
|
694
|
+
type: "response",
|
|
695
|
+
id: request.id,
|
|
696
|
+
result: {}
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Handle AFS request (passthrough to client).
|
|
701
|
+
*/
|
|
702
|
+
async handleAfsRequest(state, request) {
|
|
703
|
+
if (!state.attached) return {
|
|
704
|
+
type: "response",
|
|
705
|
+
id: request.id,
|
|
706
|
+
error: {
|
|
707
|
+
code: "invalid_session",
|
|
708
|
+
message: "Must attach before sending AFS requests"
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
return {
|
|
712
|
+
type: "response",
|
|
713
|
+
id: request.id,
|
|
714
|
+
error: {
|
|
715
|
+
code: "not_implemented",
|
|
716
|
+
message: "AFS passthrough not yet implemented"
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Handle transport disconnect.
|
|
722
|
+
*/
|
|
723
|
+
handleDisconnect(state) {
|
|
724
|
+
if (state.heartbeatTimer) {
|
|
725
|
+
clearInterval(state.heartbeatTimer);
|
|
726
|
+
state.heartbeatTimer = void 0;
|
|
727
|
+
}
|
|
728
|
+
if (state.session) {
|
|
729
|
+
const session = state.session;
|
|
730
|
+
session.setState("disconnected");
|
|
731
|
+
this.sessions.delete(session.id);
|
|
732
|
+
this.namespaces.delete(session.namespace);
|
|
733
|
+
this.sessionToConnection.delete(session.id);
|
|
734
|
+
if (this.options.sessionTimeout > 0) {
|
|
735
|
+
const timeoutId = setTimeout(() => {
|
|
736
|
+
this.disconnectedSessions.delete(session.id);
|
|
737
|
+
}, this.options.sessionTimeout);
|
|
738
|
+
this.disconnectedSessions.set(session.id, {
|
|
739
|
+
session,
|
|
740
|
+
disconnectedAt: Date.now(),
|
|
741
|
+
timeoutId
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
this.connections.delete(state.transport);
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Send response back to client.
|
|
749
|
+
*/
|
|
750
|
+
async sendResponse(transport, response) {
|
|
751
|
+
const payload = serializeMessage(response);
|
|
752
|
+
await transport.send({
|
|
753
|
+
type: FrameType.JSON,
|
|
754
|
+
reqId: Number.parseInt(response.id, 10),
|
|
755
|
+
payload
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Send event to client.
|
|
760
|
+
*/
|
|
761
|
+
async sendEvent(transport, event) {
|
|
762
|
+
const payload = serializeMessage(event);
|
|
763
|
+
await transport.send({
|
|
764
|
+
type: FrameType.JSON,
|
|
765
|
+
reqId: 0,
|
|
766
|
+
payload
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Get all active sessions.
|
|
771
|
+
*/
|
|
772
|
+
getSessions() {
|
|
773
|
+
return Array.from(this.sessions.values());
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Close the session host and all sessions.
|
|
777
|
+
*/
|
|
778
|
+
async close() {
|
|
779
|
+
for (const state of this.connections.values()) if (state.heartbeatTimer) clearInterval(state.heartbeatTimer);
|
|
780
|
+
for (const disconnected of this.disconnectedSessions.values()) clearTimeout(disconnected.timeoutId);
|
|
781
|
+
for (const state of this.connections.values()) await state.transport.close();
|
|
782
|
+
this.connections.clear();
|
|
783
|
+
this.sessions.clear();
|
|
784
|
+
this.namespaces.clear();
|
|
785
|
+
this.sessionToConnection.clear();
|
|
786
|
+
this.disconnectedSessions.clear();
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
//#endregion
|
|
791
|
+
//#region src/transport/types.ts
|
|
792
|
+
/**
|
|
793
|
+
* Default socket path
|
|
794
|
+
*/
|
|
795
|
+
const DEFAULT_SOCKET_PATH = "~/.afs/observer.sock";
|
|
796
|
+
/**
|
|
797
|
+
* Default WebSocket port
|
|
798
|
+
*/
|
|
799
|
+
const DEFAULT_WS_PORT = 9999;
|
|
800
|
+
|
|
801
|
+
//#endregion
|
|
802
|
+
//#region src/transport/unix-socket.ts
|
|
803
|
+
/**
|
|
804
|
+
* Expand ~ to home directory
|
|
805
|
+
*/
|
|
806
|
+
function expandPath(p) {
|
|
807
|
+
if (p.startsWith("~/")) {
|
|
808
|
+
const home = process.env.HOME || process.env.USERPROFILE || "/";
|
|
809
|
+
return path.join(home, p.slice(2));
|
|
810
|
+
}
|
|
811
|
+
return p;
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Error for Unix socket operations
|
|
815
|
+
*/
|
|
816
|
+
var UnixSocketError = class extends Error {
|
|
817
|
+
constructor(code, message) {
|
|
818
|
+
super(message);
|
|
819
|
+
this.code = code;
|
|
820
|
+
this.name = "UnixSocketError";
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
/**
|
|
824
|
+
* Unix socket transport implementation.
|
|
825
|
+
*/
|
|
826
|
+
var UnixSocketTransport = class {
|
|
827
|
+
socket;
|
|
828
|
+
decoder = new FrameDecoder();
|
|
829
|
+
handlers = /* @__PURE__ */ new Map();
|
|
830
|
+
_connected = false;
|
|
831
|
+
constructor(socket) {
|
|
832
|
+
this.socket = socket;
|
|
833
|
+
this._connected = true;
|
|
834
|
+
this.setupHandlers();
|
|
835
|
+
}
|
|
836
|
+
setupHandlers() {
|
|
837
|
+
this.socket.on("data", (data) => {
|
|
838
|
+
try {
|
|
839
|
+
const frames = this.decoder.push(new Uint8Array(data));
|
|
840
|
+
for (const frame of frames) this.emit("frame", frame);
|
|
841
|
+
} catch (error) {
|
|
842
|
+
this.emit("error", error);
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
this.socket.on("close", () => {
|
|
846
|
+
this._connected = false;
|
|
847
|
+
this.emit("close");
|
|
848
|
+
});
|
|
849
|
+
this.socket.on("error", (error) => {
|
|
850
|
+
this.emit("error", error);
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
async send(frame) {
|
|
854
|
+
if (!this._connected) throw new UnixSocketError("not_connected", "Transport is not connected");
|
|
855
|
+
const encoded = encodeFrame(frame.type, frame.reqId, frame.payload);
|
|
856
|
+
return new Promise((resolve, reject) => {
|
|
857
|
+
this.socket.write(Buffer.from(encoded), (err) => {
|
|
858
|
+
if (err) reject(err);
|
|
859
|
+
else resolve();
|
|
860
|
+
});
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
async close() {
|
|
864
|
+
return new Promise((resolve) => {
|
|
865
|
+
if (!this._connected) {
|
|
866
|
+
resolve();
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
this.socket.end(() => {
|
|
870
|
+
this._connected = false;
|
|
871
|
+
resolve();
|
|
872
|
+
});
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
on(event, handler) {
|
|
876
|
+
if (!this.handlers.has(event)) this.handlers.set(event, /* @__PURE__ */ new Set());
|
|
877
|
+
this.handlers.get(event).add(handler);
|
|
878
|
+
}
|
|
879
|
+
off(event, handler) {
|
|
880
|
+
this.handlers.get(event)?.delete(handler);
|
|
881
|
+
}
|
|
882
|
+
emit(event, ...args) {
|
|
883
|
+
const handlers = this.handlers.get(event);
|
|
884
|
+
if (handlers) for (const handler of handlers) handler(...args);
|
|
885
|
+
}
|
|
886
|
+
get connected() {
|
|
887
|
+
return this._connected;
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
/**
|
|
891
|
+
* Unix socket server implementation.
|
|
892
|
+
*/
|
|
893
|
+
var UnixSocketServer = class {
|
|
894
|
+
server = null;
|
|
895
|
+
socketPath;
|
|
896
|
+
maxConnections;
|
|
897
|
+
connections = /* @__PURE__ */ new Set();
|
|
898
|
+
connectionHandler;
|
|
899
|
+
_listening = false;
|
|
900
|
+
constructor(options = {}) {
|
|
901
|
+
this.socketPath = expandPath(options.socketPath || "~/.afs/observer.sock");
|
|
902
|
+
this.maxConnections = options.maxConnections ?? 100;
|
|
903
|
+
}
|
|
904
|
+
async listen() {
|
|
905
|
+
try {
|
|
906
|
+
if (fs.lstatSync(this.socketPath).isSymbolicLink()) throw new UnixSocketError("symlink_not_allowed", `Socket path is a symlink: ${this.socketPath}`);
|
|
907
|
+
} catch (e) {
|
|
908
|
+
if (e.code !== "ENOENT") {
|
|
909
|
+
if (e instanceof UnixSocketError) throw e;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
const dir = path.dirname(this.socketPath);
|
|
913
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
914
|
+
try {
|
|
915
|
+
fs.unlinkSync(this.socketPath);
|
|
916
|
+
} catch (e) {
|
|
917
|
+
if (e.code !== "ENOENT") throw new UnixSocketError("remove_failed", `Failed to remove existing socket: ${e.message}`);
|
|
918
|
+
}
|
|
919
|
+
const net = await import("node:net");
|
|
920
|
+
return new Promise((resolve, reject) => {
|
|
921
|
+
this.server = net.createServer((socket) => {
|
|
922
|
+
if (this.connections.size >= this.maxConnections) {
|
|
923
|
+
socket.end();
|
|
924
|
+
socket.destroy();
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
const transport = new UnixSocketTransport(socket);
|
|
928
|
+
this.connections.add(transport);
|
|
929
|
+
transport.on("close", () => {
|
|
930
|
+
this.connections.delete(transport);
|
|
931
|
+
});
|
|
932
|
+
this.connectionHandler?.(transport);
|
|
933
|
+
});
|
|
934
|
+
this.server.on("error", (err) => {
|
|
935
|
+
reject(err);
|
|
936
|
+
});
|
|
937
|
+
this.server.listen(this.socketPath, () => {
|
|
938
|
+
this._listening = true;
|
|
939
|
+
resolve();
|
|
940
|
+
});
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
async close() {
|
|
944
|
+
if (!this.server) return;
|
|
945
|
+
for (const conn of this.connections) await conn.close();
|
|
946
|
+
this.connections.clear();
|
|
947
|
+
return new Promise((resolve) => {
|
|
948
|
+
this.server.close(() => {
|
|
949
|
+
this._listening = false;
|
|
950
|
+
try {
|
|
951
|
+
fs.unlinkSync(this.socketPath);
|
|
952
|
+
} catch {}
|
|
953
|
+
resolve();
|
|
954
|
+
});
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
onConnection(handler) {
|
|
958
|
+
this.connectionHandler = handler;
|
|
959
|
+
}
|
|
960
|
+
get listening() {
|
|
961
|
+
return this._listening;
|
|
962
|
+
}
|
|
963
|
+
};
|
|
964
|
+
/**
|
|
965
|
+
* Unix socket client implementation.
|
|
966
|
+
*/
|
|
967
|
+
var UnixSocketClient = class {
|
|
968
|
+
socket = null;
|
|
969
|
+
transport = null;
|
|
970
|
+
socketPath;
|
|
971
|
+
handlers = /* @__PURE__ */ new Map();
|
|
972
|
+
constructor(options = {}) {
|
|
973
|
+
this.socketPath = expandPath(options.socketPath || "~/.afs/observer.sock");
|
|
974
|
+
}
|
|
975
|
+
async connect() {
|
|
976
|
+
const net = await import("node:net");
|
|
977
|
+
return new Promise((resolve, reject) => {
|
|
978
|
+
this.socket = net.connect(this.socketPath, () => {
|
|
979
|
+
this.transport = new UnixSocketTransport(this.socket);
|
|
980
|
+
this.transport.on("frame", (frame) => this.emit("frame", frame));
|
|
981
|
+
this.transport.on("error", (error) => this.emit("error", error));
|
|
982
|
+
this.transport.on("close", () => this.emit("close"));
|
|
983
|
+
resolve();
|
|
984
|
+
});
|
|
985
|
+
this.socket.on("error", (err) => {
|
|
986
|
+
reject(err);
|
|
987
|
+
});
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
async send(frame) {
|
|
991
|
+
if (!this.transport) throw new UnixSocketError("not_connected", "Client is not connected");
|
|
992
|
+
return this.transport.send(frame);
|
|
993
|
+
}
|
|
994
|
+
async close() {
|
|
995
|
+
if (this.transport) await this.transport.close();
|
|
996
|
+
this.socket = null;
|
|
997
|
+
this.transport = null;
|
|
998
|
+
}
|
|
999
|
+
on(event, handler) {
|
|
1000
|
+
if (!this.handlers.has(event)) this.handlers.set(event, /* @__PURE__ */ new Set());
|
|
1001
|
+
this.handlers.get(event).add(handler);
|
|
1002
|
+
}
|
|
1003
|
+
off(event, handler) {
|
|
1004
|
+
this.handlers.get(event)?.delete(handler);
|
|
1005
|
+
}
|
|
1006
|
+
emit(event, ...args) {
|
|
1007
|
+
const handlers = this.handlers.get(event);
|
|
1008
|
+
if (handlers) for (const handler of handlers) handler(...args);
|
|
1009
|
+
}
|
|
1010
|
+
get connected() {
|
|
1011
|
+
return this.transport?.connected ?? false;
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
|
|
1015
|
+
//#endregion
|
|
1016
|
+
//#region src/transport/websocket.ts
|
|
1017
|
+
/**
|
|
1018
|
+
* Error for WebSocket operations
|
|
1019
|
+
*/
|
|
1020
|
+
var WebSocketError = class extends Error {
|
|
1021
|
+
constructor(code, message) {
|
|
1022
|
+
super(message);
|
|
1023
|
+
this.code = code;
|
|
1024
|
+
this.name = "WebSocketError";
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
1027
|
+
/**
|
|
1028
|
+
* Constant-time string comparison to prevent timing attacks
|
|
1029
|
+
*/
|
|
1030
|
+
function secureCompare(a, b) {
|
|
1031
|
+
if (a.length !== b.length) {
|
|
1032
|
+
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(a));
|
|
1033
|
+
return false;
|
|
1034
|
+
}
|
|
1035
|
+
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Rate limiter for auth attempts
|
|
1039
|
+
*/
|
|
1040
|
+
var AuthRateLimiter = class {
|
|
1041
|
+
attempts = /* @__PURE__ */ new Map();
|
|
1042
|
+
maxAttempts = 5;
|
|
1043
|
+
windowMs = 6e4;
|
|
1044
|
+
isRateLimited(ip) {
|
|
1045
|
+
const now = Date.now();
|
|
1046
|
+
const record = this.attempts.get(ip);
|
|
1047
|
+
if (!record || record.resetAt < now) return false;
|
|
1048
|
+
return record.count >= this.maxAttempts;
|
|
1049
|
+
}
|
|
1050
|
+
recordAttempt(ip) {
|
|
1051
|
+
const now = Date.now();
|
|
1052
|
+
const record = this.attempts.get(ip);
|
|
1053
|
+
if (!record || record.resetAt < now) this.attempts.set(ip, {
|
|
1054
|
+
count: 1,
|
|
1055
|
+
resetAt: now + this.windowMs
|
|
1056
|
+
});
|
|
1057
|
+
else record.count++;
|
|
1058
|
+
}
|
|
1059
|
+
recordSuccess(ip) {
|
|
1060
|
+
this.attempts.delete(ip);
|
|
1061
|
+
}
|
|
1062
|
+
};
|
|
1063
|
+
/**
|
|
1064
|
+
* WebSocket transport for server-side (Bun WebSocket).
|
|
1065
|
+
* Wraps Bun's ServerWebSocket to provide Transport interface.
|
|
1066
|
+
*/
|
|
1067
|
+
var WebSocketTransport = class {
|
|
1068
|
+
ws;
|
|
1069
|
+
decoder = new FrameDecoder();
|
|
1070
|
+
handlers = /* @__PURE__ */ new Map();
|
|
1071
|
+
_connected = true;
|
|
1072
|
+
constructor(ws) {
|
|
1073
|
+
this.ws = ws;
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Called by server when message is received.
|
|
1077
|
+
*/
|
|
1078
|
+
handleMessage(data) {
|
|
1079
|
+
try {
|
|
1080
|
+
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
|
1081
|
+
const frames = this.decoder.push(bytes);
|
|
1082
|
+
for (const frame of frames) this.emit("frame", frame);
|
|
1083
|
+
} catch (error) {
|
|
1084
|
+
this.emit("error", error);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
/**
|
|
1088
|
+
* Called by server when connection closes.
|
|
1089
|
+
*/
|
|
1090
|
+
handleClose() {
|
|
1091
|
+
this._connected = false;
|
|
1092
|
+
this.emit("close");
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* Called by server when error occurs.
|
|
1096
|
+
*/
|
|
1097
|
+
handleError(error) {
|
|
1098
|
+
this.emit("error", error);
|
|
1099
|
+
}
|
|
1100
|
+
async send(frame) {
|
|
1101
|
+
if (!this._connected) throw new WebSocketError("not_connected", "Transport is not connected");
|
|
1102
|
+
const encoded = encodeFrame(frame.type, frame.reqId, frame.payload);
|
|
1103
|
+
this.ws.sendBinary(encoded);
|
|
1104
|
+
}
|
|
1105
|
+
async close() {
|
|
1106
|
+
if (this._connected) {
|
|
1107
|
+
this.ws.close();
|
|
1108
|
+
this._connected = false;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
on(event, handler) {
|
|
1112
|
+
if (!this.handlers.has(event)) this.handlers.set(event, /* @__PURE__ */ new Set());
|
|
1113
|
+
this.handlers.get(event).add(handler);
|
|
1114
|
+
}
|
|
1115
|
+
off(event, handler) {
|
|
1116
|
+
this.handlers.get(event)?.delete(handler);
|
|
1117
|
+
}
|
|
1118
|
+
emit(event, ...args) {
|
|
1119
|
+
const handlers = this.handlers.get(event);
|
|
1120
|
+
if (handlers) for (const handler of handlers) handler(...args);
|
|
1121
|
+
}
|
|
1122
|
+
get connected() {
|
|
1123
|
+
return this._connected;
|
|
1124
|
+
}
|
|
1125
|
+
};
|
|
1126
|
+
/**
|
|
1127
|
+
* WebSocket server implementation using Bun.serve.
|
|
1128
|
+
*/
|
|
1129
|
+
var WebSocketServer = class {
|
|
1130
|
+
server = null;
|
|
1131
|
+
host;
|
|
1132
|
+
port;
|
|
1133
|
+
authToken;
|
|
1134
|
+
maxConnections;
|
|
1135
|
+
connections = /* @__PURE__ */ new Set();
|
|
1136
|
+
wsToTransport = /* @__PURE__ */ new Map();
|
|
1137
|
+
connectionHandler;
|
|
1138
|
+
_listening = false;
|
|
1139
|
+
rateLimiter = new AuthRateLimiter();
|
|
1140
|
+
constructor(options = {}) {
|
|
1141
|
+
this.host = options.host || "localhost";
|
|
1142
|
+
this.port = options.port || 9999;
|
|
1143
|
+
this.authToken = options.authToken;
|
|
1144
|
+
this.maxConnections = options.maxConnections ?? 100;
|
|
1145
|
+
}
|
|
1146
|
+
async listen() {
|
|
1147
|
+
const self = this;
|
|
1148
|
+
this.server = Bun.serve({
|
|
1149
|
+
hostname: this.host,
|
|
1150
|
+
port: this.port,
|
|
1151
|
+
fetch(req, server) {
|
|
1152
|
+
const ip = server.requestIP(req)?.address || "unknown";
|
|
1153
|
+
if (self.authToken && self.rateLimiter.isRateLimited(ip)) return new Response("Too many auth attempts", { status: 429 });
|
|
1154
|
+
if (self.authToken) {
|
|
1155
|
+
const queryToken = new URL(req.url).searchParams.get("token");
|
|
1156
|
+
const authHeader = req.headers.get("Authorization");
|
|
1157
|
+
const providedToken = (authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null) || queryToken;
|
|
1158
|
+
if (!providedToken) {
|
|
1159
|
+
self.rateLimiter.recordAttempt(ip);
|
|
1160
|
+
return new Response("Authentication required", { status: 401 });
|
|
1161
|
+
}
|
|
1162
|
+
if (!secureCompare(providedToken, self.authToken)) {
|
|
1163
|
+
self.rateLimiter.recordAttempt(ip);
|
|
1164
|
+
return new Response("Authentication failed", { status: 403 });
|
|
1165
|
+
}
|
|
1166
|
+
self.rateLimiter.recordSuccess(ip);
|
|
1167
|
+
}
|
|
1168
|
+
if (self.connections.size >= self.maxConnections) return new Response("Too many connections", { status: 503 });
|
|
1169
|
+
if (!server.upgrade(req, { data: { ip } })) return new Response("WebSocket upgrade failed", { status: 400 });
|
|
1170
|
+
},
|
|
1171
|
+
websocket: {
|
|
1172
|
+
open(ws) {
|
|
1173
|
+
const transport = new WebSocketTransport(ws);
|
|
1174
|
+
self.connections.add(transport);
|
|
1175
|
+
self.wsToTransport.set(ws, transport);
|
|
1176
|
+
transport.on("close", () => {
|
|
1177
|
+
self.connections.delete(transport);
|
|
1178
|
+
self.wsToTransport.delete(ws);
|
|
1179
|
+
});
|
|
1180
|
+
self.connectionHandler?.(transport);
|
|
1181
|
+
},
|
|
1182
|
+
message(ws, message) {
|
|
1183
|
+
const transport = self.wsToTransport.get(ws);
|
|
1184
|
+
if (transport) {
|
|
1185
|
+
const data = typeof message === "string" ? new TextEncoder().encode(message) : message instanceof ArrayBuffer ? new Uint8Array(message) : new Uint8Array(message.buffer);
|
|
1186
|
+
transport.handleMessage(data);
|
|
1187
|
+
}
|
|
1188
|
+
},
|
|
1189
|
+
close(ws) {
|
|
1190
|
+
const transport = self.wsToTransport.get(ws);
|
|
1191
|
+
if (transport) {
|
|
1192
|
+
transport.handleClose();
|
|
1193
|
+
self.connections.delete(transport);
|
|
1194
|
+
self.wsToTransport.delete(ws);
|
|
1195
|
+
}
|
|
1196
|
+
},
|
|
1197
|
+
perMessageDeflate: false
|
|
1198
|
+
}
|
|
1199
|
+
});
|
|
1200
|
+
this._listening = true;
|
|
1201
|
+
}
|
|
1202
|
+
async close() {
|
|
1203
|
+
for (const conn of this.connections) await conn.close();
|
|
1204
|
+
this.connections.clear();
|
|
1205
|
+
this.wsToTransport.clear();
|
|
1206
|
+
if (this.server) {
|
|
1207
|
+
this.server.stop();
|
|
1208
|
+
this._listening = false;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
onConnection(handler) {
|
|
1212
|
+
this.connectionHandler = handler;
|
|
1213
|
+
}
|
|
1214
|
+
get listening() {
|
|
1215
|
+
return this._listening;
|
|
1216
|
+
}
|
|
1217
|
+
get address() {
|
|
1218
|
+
return `ws://${this.host}:${this.port}`;
|
|
1219
|
+
}
|
|
1220
|
+
};
|
|
1221
|
+
/**
|
|
1222
|
+
* WebSocket client transport (uses browser WebSocket API).
|
|
1223
|
+
*/
|
|
1224
|
+
var ClientWebSocketTransport = class {
|
|
1225
|
+
ws;
|
|
1226
|
+
decoder = new FrameDecoder();
|
|
1227
|
+
handlers = /* @__PURE__ */ new Map();
|
|
1228
|
+
_connected = false;
|
|
1229
|
+
constructor(ws) {
|
|
1230
|
+
this.ws = ws;
|
|
1231
|
+
this._connected = ws.readyState === WebSocket.OPEN;
|
|
1232
|
+
this.setupHandlers();
|
|
1233
|
+
}
|
|
1234
|
+
setupHandlers() {
|
|
1235
|
+
this.ws.onmessage = (event) => {
|
|
1236
|
+
try {
|
|
1237
|
+
const data = event.data instanceof ArrayBuffer ? new Uint8Array(event.data) : typeof event.data === "string" ? new TextEncoder().encode(event.data) : new Uint8Array(event.data);
|
|
1238
|
+
const frames = this.decoder.push(data);
|
|
1239
|
+
for (const frame of frames) this.emit("frame", frame);
|
|
1240
|
+
} catch (error) {
|
|
1241
|
+
this.emit("error", error);
|
|
1242
|
+
}
|
|
1243
|
+
};
|
|
1244
|
+
this.ws.onclose = () => {
|
|
1245
|
+
this._connected = false;
|
|
1246
|
+
this.emit("close");
|
|
1247
|
+
};
|
|
1248
|
+
this.ws.onerror = () => {
|
|
1249
|
+
this.emit("error", /* @__PURE__ */ new Error("WebSocket error"));
|
|
1250
|
+
};
|
|
1251
|
+
this.ws.onopen = () => {
|
|
1252
|
+
this._connected = true;
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
async send(frame) {
|
|
1256
|
+
if (!this._connected) throw new WebSocketError("not_connected", "Transport is not connected");
|
|
1257
|
+
const encoded = encodeFrame(frame.type, frame.reqId, frame.payload);
|
|
1258
|
+
this.ws.send(encoded);
|
|
1259
|
+
}
|
|
1260
|
+
async close() {
|
|
1261
|
+
if (this._connected) {
|
|
1262
|
+
this.ws.close();
|
|
1263
|
+
this._connected = false;
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
on(event, handler) {
|
|
1267
|
+
if (!this.handlers.has(event)) this.handlers.set(event, /* @__PURE__ */ new Set());
|
|
1268
|
+
this.handlers.get(event).add(handler);
|
|
1269
|
+
}
|
|
1270
|
+
off(event, handler) {
|
|
1271
|
+
this.handlers.get(event)?.delete(handler);
|
|
1272
|
+
}
|
|
1273
|
+
emit(event, ...args) {
|
|
1274
|
+
const handlers = this.handlers.get(event);
|
|
1275
|
+
if (handlers) for (const handler of handlers) handler(...args);
|
|
1276
|
+
}
|
|
1277
|
+
get connected() {
|
|
1278
|
+
return this._connected;
|
|
1279
|
+
}
|
|
1280
|
+
};
|
|
1281
|
+
/**
|
|
1282
|
+
* WebSocket client implementation.
|
|
1283
|
+
*/
|
|
1284
|
+
var WebSocketClient = class {
|
|
1285
|
+
ws = null;
|
|
1286
|
+
transport = null;
|
|
1287
|
+
host;
|
|
1288
|
+
port;
|
|
1289
|
+
authToken;
|
|
1290
|
+
handlers = /* @__PURE__ */ new Map();
|
|
1291
|
+
constructor(options = {}) {
|
|
1292
|
+
this.host = options.host || "localhost";
|
|
1293
|
+
this.port = options.port || 9999;
|
|
1294
|
+
this.authToken = options.authToken;
|
|
1295
|
+
}
|
|
1296
|
+
async connect() {
|
|
1297
|
+
return new Promise((resolve, reject) => {
|
|
1298
|
+
let url = `ws://${this.host}:${this.port}`;
|
|
1299
|
+
if (this.authToken) url += `?token=${encodeURIComponent(this.authToken)}`;
|
|
1300
|
+
this.ws = new WebSocket(url);
|
|
1301
|
+
this.ws.binaryType = "arraybuffer";
|
|
1302
|
+
this.ws.onopen = () => {
|
|
1303
|
+
this.transport = new ClientWebSocketTransport(this.ws);
|
|
1304
|
+
this.transport.on("frame", (frame) => this.emit("frame", frame));
|
|
1305
|
+
this.transport.on("error", (error) => this.emit("error", error));
|
|
1306
|
+
this.transport.on("close", () => this.emit("close"));
|
|
1307
|
+
resolve();
|
|
1308
|
+
};
|
|
1309
|
+
this.ws.onerror = () => {
|
|
1310
|
+
reject(new WebSocketError("connection_failed", "Failed to connect"));
|
|
1311
|
+
};
|
|
1312
|
+
this.ws.onclose = (event) => {
|
|
1313
|
+
if (!this.transport) if (event.code === 1002 || event.code === 1008) reject(new WebSocketError("auth_failed", "Authentication failed"));
|
|
1314
|
+
else reject(new WebSocketError("connection_failed", "Connection closed"));
|
|
1315
|
+
};
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
async send(frame) {
|
|
1319
|
+
if (!this.transport) throw new WebSocketError("not_connected", "Client is not connected");
|
|
1320
|
+
return this.transport.send(frame);
|
|
1321
|
+
}
|
|
1322
|
+
async close() {
|
|
1323
|
+
if (this.transport) await this.transport.close();
|
|
1324
|
+
this.ws = null;
|
|
1325
|
+
this.transport = null;
|
|
1326
|
+
}
|
|
1327
|
+
on(event, handler) {
|
|
1328
|
+
if (!this.handlers.has(event)) this.handlers.set(event, /* @__PURE__ */ new Set());
|
|
1329
|
+
this.handlers.get(event).add(handler);
|
|
1330
|
+
}
|
|
1331
|
+
off(event, handler) {
|
|
1332
|
+
this.handlers.get(event)?.delete(handler);
|
|
1333
|
+
}
|
|
1334
|
+
emit(event, ...args) {
|
|
1335
|
+
const handlers = this.handlers.get(event);
|
|
1336
|
+
if (handlers) for (const handler of handlers) handler(...args);
|
|
1337
|
+
}
|
|
1338
|
+
get connected() {
|
|
1339
|
+
return this.transport?.connected ?? false;
|
|
1340
|
+
}
|
|
1341
|
+
};
|
|
1342
|
+
|
|
1343
|
+
//#endregion
|
|
1344
|
+
export { BufferOverflowError, DEFAULT_SESSION_OPTIONS, DEFAULT_SOCKET_PATH, DEFAULT_WS_PORT, DepthLimitExceededError, FrameDecoder, FrameType, HEADER_SIZE, IncompleteFrameError, InvalidFrameTypeError, InvalidMessageError, InvalidPayloadError, InvalidReqIdError, MAX_JSON_DEPTH, MAX_JSON_SIZE, MAX_PAYLOAD_SIZE, MAX_UINT32, MessageTooLargeError, PROTOCOL_VERSION, ParseError, PayloadTooLargeError, ProtocolError, SessionError, SessionHost, UnixSocketClient, UnixSocketError, UnixSocketServer, UnixSocketTransport, WebSocketClient, WebSocketError, WebSocketServer, WebSocketTransport, decodeFrame, deserializeMessage, encodeFrame, serializeMessage };
|
|
1345
|
+
//# sourceMappingURL=index.mjs.map
|