@arvoretech/runtime-lens-mcp 1.1.0 → 1.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/agent/index.ts CHANGED
@@ -1,263 +1,332 @@
1
- import { createServer } from "node:http";
2
1
  import { createHash } from "node:crypto";
3
2
  import type { IncomingMessage } from "node:http";
3
+ import { createServer } from "node:http";
4
4
  import type { Socket } from "node:net";
5
5
 
6
6
  const PORT = parseInt(process.env.RUNTIME_LENS_PORT || "9500", 10);
7
7
 
8
8
  interface LogMessage {
9
- type: "log" | "error" | "warn" | "info" | "debug" | "result";
10
- file: string;
11
- line: number;
12
- column: number;
13
- values: string[];
14
- timestamp: number;
15
- expression?: string;
9
+ type: "log" | "error" | "warn" | "info" | "debug" | "result";
10
+ file: string;
11
+ line: number;
12
+ column: number;
13
+ values: string[];
14
+ timestamp: number;
15
+ expression?: string;
16
+ count?: number;
16
17
  }
17
18
 
18
- let client: Socket | null = null;
19
+ const clients: Set<Socket> = new Set();
19
20
  const buffer: LogMessage[] = [];
20
- const MAX_BUFFER = 500;
21
+ const MAX_BUFFER = 1000;
21
22
 
22
23
  function serialize(value: unknown, depth = 0): string {
23
- if (depth > 3) return "[...]";
24
- if (value === null) return "null";
25
- if (value === undefined) return "undefined";
26
- if (typeof value === "string") return value.length > 100 ? `"${value.slice(0, 100)}..."` : `"${value}"`;
27
- if (typeof value === "number" || typeof value === "boolean") return String(value);
28
- if (typeof value === "function") return `fn ${value.name || "anonymous"}()`;
29
- if (typeof value === "symbol") return value.toString();
30
- if (typeof value === "bigint") return `${value}n`;
31
- if (value instanceof Error) return `${value.name}: ${value.message}`;
32
- if (value instanceof Date) return value.toISOString();
33
- if (value instanceof RegExp) return value.toString();
34
- if (value instanceof Map) return `Map(${value.size})`;
35
- if (value instanceof Set) return `Set(${value.size})`;
36
- if (value instanceof Promise) return "Promise";
37
- if (Array.isArray(value)) {
38
- if (value.length === 0) return "[]";
39
- const items = value.slice(0, 5).map(v => serialize(v, depth + 1));
40
- const suffix = value.length > 5 ? `, ...+${value.length - 5}` : "";
41
- return `[${items.join(", ")}${suffix}]`;
42
- }
43
- if (typeof value === "object") {
44
- const keys = Object.keys(value);
45
- if (keys.length === 0) return "{}";
46
- const entries = keys.slice(0, 5).map(k => `${k}: ${serialize((value as Record<string, unknown>)[k], depth + 1)}`);
47
- const suffix = keys.length > 5 ? `, ...+${keys.length - 5}` : "";
48
- return `{${entries.join(", ")}${suffix}}`;
49
- }
50
- return typeof value === "object" ? Object.prototype.toString.call(value) : String(value);
24
+ if (depth > 3) return "[...]";
25
+ if (value === null) return "null";
26
+ if (value === undefined) return "undefined";
27
+ if (typeof value === "string")
28
+ return value.length > 100 ? `"${value.slice(0, 100)}..."` : `"${value}"`;
29
+ if (typeof value === "number" || typeof value === "boolean")
30
+ return String(value);
31
+ if (typeof value === "function") return `fn ${value.name || "anonymous"}()`;
32
+ if (typeof value === "symbol") return value.toString();
33
+ if (typeof value === "bigint") return `${value}n`;
34
+ if (value instanceof Error)
35
+ return `${value.name}: ${value.message}${value.stack ? "\n" + value.stack : ""}`;
36
+ if (value instanceof Date) return value.toISOString();
37
+ if (value instanceof RegExp) return value.toString();
38
+ if (value instanceof Map) return `Map(${value.size})`;
39
+ if (value instanceof Set) return `Set(${value.size})`;
40
+ if (value instanceof Promise) return "Promise";
41
+ if (Array.isArray(value)) {
42
+ if (value.length === 0) return "[]";
43
+ const items = value.slice(0, 5).map((v) => serialize(v, depth + 1));
44
+ const suffix = value.length > 5 ? `, ...+${value.length - 5}` : "";
45
+ return `[${items.join(", ")}${suffix}]`;
46
+ }
47
+ if (typeof value === "object") {
48
+ const keys = Object.keys(value);
49
+ if (keys.length === 0) return "{}";
50
+ const entries = keys
51
+ .slice(0, 5)
52
+ .map(
53
+ (k) =>
54
+ `${k}: ${serialize((value as Record<string, unknown>)[k], depth + 1)}`,
55
+ );
56
+ const suffix = keys.length > 5 ? `, ...+${keys.length - 5}` : "";
57
+ return `{${entries.join(", ")}${suffix}}`;
58
+ }
59
+ return typeof value === "object"
60
+ ? Object.prototype.toString.call(value)
61
+ : String(value);
51
62
  }
52
63
 
53
- function wsSend(msg: LogMessage): void {
54
- const payload = JSON.stringify(msg);
55
- if (client && !client.destroyed) {
56
- const buf = Buffer.from(payload, "utf-8");
57
- const frame = buildWsFrame(buf);
58
- client.write(frame);
59
- } else {
60
- buffer.push(msg);
61
- if (buffer.length > MAX_BUFFER) buffer.shift();
62
- }
64
+ function broadcast(msg: LogMessage): void {
65
+ const payload = JSON.stringify(msg);
66
+ const buf = Buffer.from(payload, "utf-8");
67
+ const frame = buildWsFrame(buf);
68
+
69
+ for (const client of clients) {
70
+ if (!client.destroyed) {
71
+ client.write(frame);
72
+ } else {
73
+ clients.delete(client);
74
+ }
75
+ }
76
+
77
+ // Always buffer for late-connecting clients (MCP server, extension)
78
+ buffer.push(msg);
79
+ if (buffer.length > MAX_BUFFER) buffer.shift();
63
80
  }
64
81
 
65
- function flushBuffer(): void {
66
- while (buffer.length > 0 && client && !client.destroyed) {
67
- const msg = buffer.shift();
68
- if (msg) {
69
- const buf = Buffer.from(JSON.stringify(msg), "utf-8");
70
- client.write(buildWsFrame(buf));
71
- }
72
- }
82
+ function flushBuffer(socket: Socket): void {
83
+ for (const msg of buffer) {
84
+ const buf = Buffer.from(JSON.stringify(msg), "utf-8");
85
+ socket.write(buildWsFrame(buf));
86
+ }
73
87
  }
74
88
 
75
89
  function buildWsFrame(data: Buffer): Buffer {
76
- const len = data.length;
77
- let header: Buffer;
78
- if (len < 126) {
79
- header = Buffer.alloc(2);
80
- header[0] = 0x81;
81
- header[1] = len;
82
- } else if (len < 65536) {
83
- header = Buffer.alloc(4);
84
- header[0] = 0x81;
85
- header[1] = 126;
86
- header.writeUInt16BE(len, 2);
87
- } else {
88
- header = Buffer.alloc(10);
89
- header[0] = 0x81;
90
- header[1] = 127;
91
- header.writeBigUInt64BE(BigInt(len), 2);
92
- }
93
- return Buffer.concat([header, data]);
90
+ const len = data.length;
91
+ let header: Buffer;
92
+ if (len < 126) {
93
+ header = Buffer.alloc(2);
94
+ header[0] = 0x81;
95
+ header[1] = len;
96
+ } else if (len < 65536) {
97
+ header = Buffer.alloc(4);
98
+ header[0] = 0x81;
99
+ header[1] = 126;
100
+ header.writeUInt16BE(len, 2);
101
+ } else {
102
+ header = Buffer.alloc(10);
103
+ header[0] = 0x81;
104
+ header[1] = 127;
105
+ header.writeBigUInt64BE(BigInt(len), 2);
106
+ }
107
+ return Buffer.concat([header, data]);
94
108
  }
95
109
 
96
110
  function parseWsFrame(data: Buffer): string | null {
97
- if (data.length < 2) return null;
98
- const masked = (data[1] & 0x80) !== 0;
99
- let payloadLen = data[1] & 0x7f;
100
- let offset = 2;
101
-
102
- if (payloadLen === 126) {
103
- payloadLen = data.readUInt16BE(2);
104
- offset = 4;
105
- } else if (payloadLen === 127) {
106
- payloadLen = Number(data.readBigUInt64BE(2));
107
- offset = 10;
108
- }
109
-
110
- let maskKey: Buffer | null = null;
111
- if (masked) {
112
- maskKey = data.subarray(offset, offset + 4);
113
- offset += 4;
114
- }
115
-
116
- const payload = data.subarray(offset, offset + payloadLen);
117
- if (maskKey) {
118
- for (let i = 0; i < payload.length; i++) {
119
- payload[i] ^= maskKey[i % 4];
120
- }
121
- }
122
- return payload.toString("utf-8");
111
+ if (data.length < 2) return null;
112
+ const masked = (data[1] & 0x80) !== 0;
113
+ let payloadLen = data[1] & 0x7f;
114
+ let offset = 2;
115
+
116
+ if (payloadLen === 126) {
117
+ payloadLen = data.readUInt16BE(2);
118
+ offset = 4;
119
+ } else if (payloadLen === 127) {
120
+ payloadLen = Number(data.readBigUInt64BE(2));
121
+ offset = 10;
122
+ }
123
+
124
+ let maskKey: Buffer | null = null;
125
+ if (masked) {
126
+ maskKey = data.subarray(offset, offset + 4);
127
+ offset += 4;
128
+ }
129
+
130
+ const payload = data.subarray(offset, offset + payloadLen);
131
+ if (maskKey) {
132
+ for (let i = 0; i < payload.length; i++) {
133
+ payload[i] ^= maskKey[i % 4];
134
+ }
135
+ }
136
+ return payload.toString("utf-8");
123
137
  }
124
138
 
125
139
  function extractCallSite(): { file: string; line: number; column: number } {
126
- const stack = new Error().stack || "";
127
- const lines = stack.split("\n");
128
- for (let i = 2; i < lines.length; i++) {
129
- const line = lines[i];
130
- if (line.includes("node:") || line.includes("dist/agent/index.js")) continue;
131
- const match = /\((.+):(\d+):(\d+)\)/.exec(line) || /at (.+):(\d+):(\d+)/.exec(line);
132
- if (match) {
133
- return { file: match[1], line: parseInt(match[2], 10), column: parseInt(match[3], 10) };
134
- }
135
- }
136
- return { file: "unknown", line: 0, column: 0 };
140
+ const stack = new Error().stack || "";
141
+ const lines = stack.split("\n");
142
+ for (let i = 2; i < lines.length; i++) {
143
+ const line = lines[i];
144
+ if (line.includes("node:") || line.includes("dist/agent/index.js"))
145
+ continue;
146
+ const match =
147
+ /\((.+):(\d+):(\d+)\)/.exec(line) || /at (.+):(\d+):(\d+)/.exec(line);
148
+ if (match) {
149
+ return {
150
+ file: match[1],
151
+ line: parseInt(match[2], 10),
152
+ column: parseInt(match[3], 10),
153
+ };
154
+ }
155
+ }
156
+ return { file: "unknown", line: 0, column: 0 };
137
157
  }
138
158
 
139
159
  type ConsoleMethod = "log" | "info" | "warn" | "error" | "debug";
140
160
  const originalConsole: Record<ConsoleMethod, (...args: unknown[]) => void> = {
141
- log: console.log.bind(console),
142
- info: console.info.bind(console),
143
- warn: console.warn.bind(console),
144
- error: console.error.bind(console),
145
- debug: console.debug.bind(console),
161
+ log: console.log.bind(console),
162
+ info: console.info.bind(console),
163
+ warn: console.warn.bind(console),
164
+ error: console.error.bind(console),
165
+ debug: console.debug.bind(console),
146
166
  };
147
167
 
148
168
  function patchConsole(): void {
149
- const methods: ConsoleMethod[] = ["log", "info", "warn", "error", "debug"];
150
- for (const method of methods) {
151
- console[method] = (...args: unknown[]) => {
152
- const site = extractCallSite();
153
- wsSend({
154
- type: method,
155
- file: site.file,
156
- line: site.line,
157
- column: site.column,
158
- values: args.map(a => serialize(a)),
159
- timestamp: Date.now(),
160
- });
161
- originalConsole[method](...args);
162
- };
163
- }
169
+ const methods: ConsoleMethod[] = ["log", "info", "warn", "error", "debug"];
170
+ for (const method of methods) {
171
+ console[method] = (...args: unknown[]) => {
172
+ const site = extractCallSite();
173
+ broadcast({
174
+ type: method,
175
+ file: site.file,
176
+ line: site.line,
177
+ column: site.column,
178
+ values: args.map((a) => serialize(a)),
179
+ timestamp: Date.now(),
180
+ });
181
+ originalConsole[method](...args);
182
+ };
183
+ }
164
184
  }
165
185
 
166
186
  const server = createServer((_req, res) => {
167
- res.writeHead(200, { "Content-Type": "application/json" });
168
- res.end(JSON.stringify({ status: "ok", pid: process.pid, uptime: process.uptime() }));
187
+ res.writeHead(200, { "Content-Type": "application/json" });
188
+ res.end(
189
+ JSON.stringify({
190
+ status: "ok",
191
+ pid: process.pid,
192
+ uptime: process.uptime(),
193
+ clients: clients.size,
194
+ buffered: buffer.length,
195
+ }),
196
+ );
169
197
  });
170
198
 
171
199
  server.on("upgrade", (req: IncomingMessage, socket: Socket) => {
172
- const key = req.headers["sec-websocket-key"];
173
- if (!key) {
174
- socket.destroy();
175
- return;
176
- }
177
-
178
- const acceptKey = createHash("sha1")
179
- .update(key + "258EAFA5-E914-47DA-95CA-5AB5DC11650B")
180
- .digest("base64");
181
-
182
- socket.write(
183
- "HTTP/1.1 101 Switching Protocols\r\n" +
184
- "Upgrade: websocket\r\n" +
185
- "Connection: Upgrade\r\n" +
186
- `Sec-WebSocket-Accept: ${acceptKey}\r\n` +
187
- "\r\n"
188
- );
189
-
190
- client = socket;
191
- flushBuffer();
192
-
193
- socket.on("data", (data: Buffer) => {
194
- const text = parseWsFrame(data);
195
- if (!text) return;
196
- try {
197
- const msg = JSON.parse(text);
198
- if (msg.type === "eval") {
199
- try {
200
- const fn = new Function(`return (${msg.expression})`);
201
- const result = fn();
202
- wsSend({
203
- type: "result",
204
- file: msg.file || "eval",
205
- line: msg.line || 0,
206
- column: msg.column || 0,
207
- values: [serialize(result)],
208
- timestamp: Date.now(),
209
- expression: msg.expression,
210
- });
211
- } catch (err: unknown) {
212
- wsSend({
213
- type: "error",
214
- file: msg.file || "eval",
215
- line: msg.line || 0,
216
- column: msg.column || 0,
217
- values: [err instanceof Error ? err.message : String(err)],
218
- timestamp: Date.now(),
219
- expression: msg.expression,
220
- });
221
- }
222
- }
223
- } catch {
224
- // invalid JSON
225
- }
226
- });
227
-
228
- socket.on("close", () => {
229
- client = null;
230
- });
231
-
232
- socket.on("error", () => {
233
- client = null;
234
- });
200
+ const key = req.headers["sec-websocket-key"];
201
+ if (!key) {
202
+ socket.destroy();
203
+ return;
204
+ }
205
+
206
+ const acceptKey = createHash("sha1")
207
+ .update(key + "258EAFA5-E914-47DA-95CA-5AB5DC11650B")
208
+ .digest("base64");
209
+
210
+ socket.write(
211
+ "HTTP/1.1 101 Switching Protocols\r\n" +
212
+ "Upgrade: websocket\r\n" +
213
+ "Connection: Upgrade\r\n" +
214
+ `Sec-WebSocket-Accept: ${acceptKey}\r\n` +
215
+ "\r\n",
216
+ );
217
+
218
+ clients.add(socket);
219
+ originalConsole.log(
220
+ `[runtime-lens] client connected (${clients.size} total)`,
221
+ );
222
+
223
+ flushBuffer(socket);
224
+
225
+ socket.on("data", (data: Buffer) => {
226
+ const text = parseWsFrame(data);
227
+ if (!text) return;
228
+ try {
229
+ const msg = JSON.parse(text);
230
+ if (msg.type === "eval") {
231
+ try {
232
+ const fn = new Function(`return (${msg.expression})`);
233
+ const result = fn();
234
+ broadcast({
235
+ type: "result",
236
+ file: msg.file || "eval",
237
+ line: msg.line || 0,
238
+ column: msg.column || 0,
239
+ values: [serialize(result)],
240
+ timestamp: Date.now(),
241
+ expression: msg.expression,
242
+ });
243
+ } catch (err: unknown) {
244
+ broadcast({
245
+ type: "error",
246
+ file: msg.file || "eval",
247
+ line: msg.line || 0,
248
+ column: msg.column || 0,
249
+ values: [err instanceof Error ? err.message : String(err)],
250
+ timestamp: Date.now(),
251
+ expression: msg.expression,
252
+ });
253
+ }
254
+ } else if (msg.type === "get_buffer") {
255
+ const payload = JSON.stringify({ type: "buffer", logs: buffer });
256
+ const buf = Buffer.from(payload, "utf-8");
257
+ socket.write(buildWsFrame(buf));
258
+ }
259
+ } catch {
260
+ // invalid JSON
261
+ }
262
+ });
263
+
264
+ socket.on("close", () => {
265
+ clients.delete(socket);
266
+ originalConsole.log(
267
+ `[runtime-lens] client disconnected (${clients.size} total)`,
268
+ );
269
+ });
270
+
271
+ socket.on("error", () => {
272
+ clients.delete(socket);
273
+ });
235
274
  });
236
275
 
237
276
  server.on("error", (err: NodeJS.ErrnoException) => {
238
- if (err.code === "EADDRINUSE") {
239
- originalConsole.log(`[runtime-lens] port ${PORT} in use, skipping agent server`);
240
- }
277
+ if (err.code === "EADDRINUSE") {
278
+ originalConsole.log(
279
+ `[runtime-lens] port ${PORT} in use, skipping agent server`,
280
+ );
281
+ }
241
282
  });
242
283
 
243
284
  server.listen(PORT, () => {
244
- originalConsole.log(`[runtime-lens] agent listening on ws://localhost:${PORT}`);
285
+ originalConsole.log(
286
+ `[runtime-lens] agent listening on ws://localhost:${PORT}`,
287
+ );
245
288
  });
246
289
 
247
- server.unref();
290
+ let keepAliveTimer: ReturnType<typeof setTimeout> | null = null;
291
+ const KEEP_ALIVE_MS = parseInt(
292
+ process.env.RUNTIME_LENS_KEEP_ALIVE || "30000",
293
+ 10,
294
+ );
295
+
296
+ function scheduleShutdown(): void {
297
+ if (keepAliveTimer) return;
298
+ keepAliveTimer = setTimeout(() => {
299
+ if (clients.size === 0) {
300
+ originalConsole.log(
301
+ `[runtime-lens] no clients connected, shutting down agent`,
302
+ );
303
+ server.close();
304
+ process.exit(0);
305
+ } else {
306
+ keepAliveTimer = null;
307
+ scheduleShutdown();
308
+ }
309
+ }, KEEP_ALIVE_MS);
310
+ keepAliveTimer.unref();
311
+ }
312
+
313
+ process.on("beforeExit", () => {
314
+ server.ref();
315
+ scheduleShutdown();
316
+ });
248
317
 
249
318
  patchConsole();
250
319
 
251
320
  (globalThis as Record<string, unknown>).__runtimeLens = {
252
- log: (...args: unknown[]) => {
253
- const site = extractCallSite();
254
- wsSend({
255
- type: "result",
256
- file: site.file,
257
- line: site.line,
258
- column: site.column,
259
- values: args.map(a => serialize(a)),
260
- timestamp: Date.now(),
261
- });
262
- },
321
+ log: (...args: unknown[]) => {
322
+ const site = extractCallSite();
323
+ broadcast({
324
+ type: "result",
325
+ file: site.file,
326
+ line: site.line,
327
+ column: site.column,
328
+ values: args.map((a) => serialize(a)),
329
+ timestamp: Date.now(),
330
+ });
331
+ },
263
332
  };