@unbound-app/debugger 0.8.0 → 0.10.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.
Files changed (2) hide show
  1. package/dist/index.mjs +106 -68
  2. package/package.json +7 -6
package/dist/index.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env bun
2
2
  import { stripEscapeSequences, terminal } from "terminal-kit";
3
3
  import { join } from "node:path";
4
- import { parseMessage } from "@unbound-app/debugger-protocol";
4
+ import { CONTROLLER_ENDPOINT_MARKER, DEFAULT_BRIDGE_PORT, DEFAULT_EVAL_TIMEOUT_MS, MAX_EVAL_TIMEOUT_MS, parseMessage } from "@unbound-app/debugger-protocol";
5
5
  import os from "node:os";
6
6
  import { cli } from "cleye";
7
- //#region src/lib/history.ts
7
+ // src/lib/history.ts
8
8
  const HISTORY_PATH = join(__dirname, "..", "..", ".history.json");
9
9
  const HISTORY_FILE = Bun.file(HISTORY_PATH);
10
10
  let history = [];
@@ -25,10 +25,10 @@ function addHistoryItem(item) {
25
25
  function persistHistory() {
26
26
  HISTORY_FILE.write(JSON.stringify(history, null, 2));
27
27
  }
28
- //#endregion
29
- //#region src/lib/session.ts
28
+ // src/lib/session.ts
30
29
  const state$2 = {
31
30
  device: null,
31
+ deviceInfo: null,
32
32
  controllers: /* @__PURE__ */ new Set()
33
33
  };
34
34
  const pending = /* @__PURE__ */ new Map();
@@ -38,20 +38,20 @@ const deviceListeners = /* @__PURE__ */ new Set();
38
38
  const evalListeners = /* @__PURE__ */ new Set();
39
39
  const resultListeners = /* @__PURE__ */ new Set();
40
40
  const controllerListeners = /* @__PURE__ */ new Set();
41
- /** How long to wait for a device to answer an eval before failing it. */
42
- const EVAL_TIMEOUT_MS = 1e4;
43
41
  const PLUGIN_PUSH_TIMEOUT_MS = 6e4;
44
42
  /**
45
- * @description Registers the connected device, rejecting a second one so eval routing stays
46
- * unambiguous.
43
+ * @description Registers the connected device, replacing any earlier one so a relaunched or
44
+ * reconnecting app always takes over from a socket that died without closing.
47
45
  * @param socket The device's socket.
48
- * @returns Whether the device was accepted (false if one is already connected).
49
46
  */
50
47
  function attachDevice(socket) {
51
- if (state$2.device) return false;
48
+ const previous = state$2.device;
49
+ if (previous) {
50
+ detachDevice(previous);
51
+ previous.close();
52
+ }
52
53
  state$2.device = socket;
53
54
  broadcastDeviceStatus(true);
54
- return true;
55
55
  }
56
56
  /**
57
57
  * @description Clears the device on disconnect, notifies controllers, and fails every in-flight eval
@@ -61,6 +61,7 @@ function attachDevice(socket) {
61
61
  function detachDevice(socket) {
62
62
  if (state$2.device !== socket) return;
63
63
  state$2.device = null;
64
+ state$2.deviceInfo = null;
64
65
  for (const [id, entry] of pending) {
65
66
  clearTimeout(entry.timer);
66
67
  entry.deliver({
@@ -113,18 +114,27 @@ function detachController(socket) {
113
114
  * @description Runs code on the device on behalf of a controller socket, delivering the id-correlated
114
115
  * result back over that same socket. Sends an error frame instead if no device is connected.
115
116
  * @param socket The controller that issued the request.
116
- * @param id The controller-chosen correlation id, echoed on the result.
117
- * @param code The JavaScript to evaluate on the device.
117
+ * @param request The controller's eval, whose `id` correlates the result and whose `timeout` bounds the wait.
118
118
  */
119
- function evaluateForController(socket, id, code) {
119
+ function evaluateForController(socket, request) {
120
+ const { id, code } = request;
120
121
  const deliver = getControllerDelivery(socket);
121
122
  for (const listener of evalListeners) listener(code);
122
- dispatch(id, code, (result) => {
123
+ dispatch(id, code, resolveEvalTimeout(request.timeout), (result) => {
123
124
  for (const listener of resultListeners) listener(result);
124
125
  deliver(result);
125
126
  });
126
127
  }
127
128
  /**
129
+ * @description Picks how long an eval may run: the requested timeout when it is a positive number of
130
+ * milliseconds, capped at {@link MAX_EVAL_TIMEOUT_MS}, otherwise {@link DEFAULT_EVAL_TIMEOUT_MS}.
131
+ * @param requested The timeout the controller asked for, if any.
132
+ * @returns The timeout to apply, in milliseconds.
133
+ */
134
+ function resolveEvalTimeout(requested) {
135
+ return typeof requested === "number" && requested > 0 ? Math.min(requested, MAX_EVAL_TIMEOUT_MS) : DEFAULT_EVAL_TIMEOUT_MS;
136
+ }
137
+ /**
128
138
  * @description Forwards a controller's {@link PluginPushRequest} to the device and delivers the
129
139
  * id-correlated {@link PluginPushResult} back over that same controller's socket. Replies with an
130
140
  * error frame instead if no device is connected or it doesn't answer within {@link PLUGIN_PUSH_TIMEOUT_MS}.
@@ -161,16 +171,16 @@ function pushPluginForController(socket, request) {
161
171
  /**
162
172
  * @description Runs code on the device on behalf of an in-process caller (the terminal REPL),
163
173
  * resolving with the device's answer. Rejects if no device is connected or it doesn't answer within
164
- * {@link EVAL_TIMEOUT_MS}.
174
+ * {@link DEFAULT_EVAL_TIMEOUT_MS}.
165
175
  * @param code The JavaScript to evaluate on the device.
166
176
  * @returns The device's {@link EvalResult}.
167
177
  */
168
178
  function evaluate(code) {
169
179
  return new Promise((resolve) => {
170
- dispatch(crypto.randomUUID(), code, resolve);
180
+ dispatch(crypto.randomUUID(), code, DEFAULT_EVAL_TIMEOUT_MS, resolve);
171
181
  });
172
182
  }
173
- function dispatch(id, code, deliver) {
183
+ function dispatch(id, code, timeout, deliver) {
174
184
  if (!state$2.device) {
175
185
  deliver({
176
186
  type: "eval-result",
@@ -186,9 +196,9 @@ function dispatch(id, code, deliver) {
186
196
  type: "eval-result",
187
197
  id,
188
198
  ok: false,
189
- error: `Evaluation timed out after ${EVAL_TIMEOUT_MS}ms.`
199
+ error: `Evaluation timed out after ${timeout}ms.`
190
200
  });
191
- }, EVAL_TIMEOUT_MS);
201
+ }, timeout);
192
202
  pending.set(id, {
193
203
  deliver,
194
204
  timer
@@ -221,6 +231,11 @@ function handleDeviceMessage$1(message) {
221
231
  entry.deliver(message);
222
232
  return;
223
233
  }
234
+ if (message.type === "hello") {
235
+ state$2.deviceInfo = message.device;
236
+ broadcastDeviceStatus(true);
237
+ return;
238
+ }
224
239
  if (message.type !== "log") return;
225
240
  for (const socket of state$2.controllers) socket.send(JSON.stringify(message));
226
241
  for (const listener of logListeners) listener(message);
@@ -235,6 +250,15 @@ function onLog(listener) {
235
250
  return () => void logListeners.delete(listener);
236
251
  }
237
252
  /**
253
+ * @description Subscribes to device connect/disconnect transitions.
254
+ * @param listener Called with whether a device is now connected.
255
+ * @returns An unsubscribe function.
256
+ */
257
+ function onDevice(listener) {
258
+ deviceListeners.add(listener);
259
+ return () => void deviceListeners.delete(listener);
260
+ }
261
+ /**
238
262
  * @description Subscribes to code a controller (e.g. the MCP) sends to evaluate.
239
263
  * @param listener Called with each controller's code.
240
264
  * @returns An unsubscribe function.
@@ -270,17 +294,21 @@ function getControllerDelivery(socket) {
270
294
  return deliver;
271
295
  }
272
296
  function sendDeviceStatus(socket, connected) {
273
- socket.send(JSON.stringify({
297
+ const status = connected ? {
298
+ type: "device-status",
299
+ connected,
300
+ device: state$2.deviceInfo ?? void 0
301
+ } : {
274
302
  type: "device-status",
275
303
  connected
276
- }));
304
+ };
305
+ socket.send(JSON.stringify(status));
277
306
  }
278
307
  function broadcastDeviceStatus(connected) {
279
308
  for (const socket of state$2.controllers) sendDeviceStatus(socket, connected);
280
- for (const listener of deviceListeners) listener(connected);
309
+ for (const listener of deviceListeners) listener(connected, state$2.deviceInfo);
281
310
  }
282
- //#endregion
283
- //#region src/lib/repl.ts
311
+ // src/lib/repl.ts
284
312
  const state$1 = {
285
313
  repl: null,
286
314
  currentInput: ""
@@ -322,8 +350,7 @@ function abortCurrentReplInput({ deletePrompt = false, deleteCurrentInput = fals
322
350
  if (deletePrompt) terminal.deleteLine(-1);
323
351
  if (deleteCurrentInput) state$1.currentInput = "";
324
352
  }
325
- //#endregion
326
- //#region src/lib/format-log.ts
353
+ // src/lib/format-log.ts
327
354
  /**
328
355
  * @description Cleans a Hermes-joined console line into space-separated, unquoted output.
329
356
  * @param message The raw `nativeLoggingHook` message string.
@@ -344,7 +371,7 @@ function splitTopLevel(input) {
344
371
  let inString = false;
345
372
  let start = 0;
346
373
  for (let i = 0, len = input.length; i < len; i++) {
347
- const char = input[i];
374
+ const char = input.charAt(i);
348
375
  if (inString) {
349
376
  if (char === "\\") {
350
377
  i++;
@@ -384,8 +411,7 @@ function unquoteSegment(segment) {
384
411
  if (trimmed.length < 2 || trimmed[0] !== "'" || trimmed[trimmed.length - 1] !== "'") return trimmed;
385
412
  return trimmed.slice(1, -1).replace(/\\'/g, "'");
386
413
  }
387
- //#endregion
388
- //#region src/lib/banner.ts
414
+ // src/lib/banner.ts
389
415
  /**
390
416
  * Renders the debugger's startup banner: a pink rounded box sized to the terminal, capped on wide
391
417
  * ones, falling back to plain starred lines when the terminal is too narrow for a box.
@@ -430,27 +456,27 @@ function rowText(line) {
430
456
  function printBox(title, lines, inner) {
431
457
  const pad = " ".repeat(PADDING);
432
458
  const horizontal = "─".repeat(inner + 4);
433
- rgb(PINK, `╭${horizontal}╮\n`);
434
- rgb(PINK, `│${pad}`);
435
- boldRgb(PINK, center(title, inner));
436
- rgb(PINK, `${pad}│\n`);
437
- rgb(PINK, `├${horizontal}┤\n`);
459
+ terminal.colorRgbHex(PINK, `╭${horizontal}╮\n`);
460
+ terminal.colorRgbHex(PINK, `│${pad}`);
461
+ terminal.bold.colorRgbHex(PINK, center(title, inner));
462
+ terminal.colorRgbHex(PINK, `${pad}│\n`);
463
+ terminal.colorRgbHex(PINK, `├${horizontal}┤\n`);
438
464
  for (const line of lines) {
439
465
  const label = line.label.padEnd(8);
440
466
  const filler = " ".repeat(Math.max(inner - rowText(line).length, 0));
441
- rgb(PINK, `│${pad}`);
442
- boldRgb(PINK, label);
443
- rgb(GREY, line.value);
467
+ terminal.colorRgbHex(PINK, `│${pad}`);
468
+ terminal.bold.colorRgbHex(PINK, label);
469
+ terminal.colorRgbHex(GREY, line.value);
444
470
  terminal(filler);
445
- rgb(PINK, `${pad}│\n`);
471
+ terminal.colorRgbHex(PINK, `${pad}│\n`);
446
472
  }
447
- rgb(PINK, `╰${horizontal}╯\n`);
473
+ terminal.colorRgbHex(PINK, `╰${horizontal}╯\n`);
448
474
  }
449
475
  function printFallback(title, lines) {
450
- boldRgb(PINK, `✦ ${title}\n`);
476
+ terminal.bold.colorRgbHex(PINK, `✦ ${title}\n`);
451
477
  for (const line of lines) {
452
- rgb(PINK, ` ${line.label.padEnd(8)}`);
453
- rgb(GREY, `${line.value}\n`);
478
+ terminal.colorRgbHex(PINK, ` ${line.label.padEnd(8)}`);
479
+ terminal.colorRgbHex(GREY, `${line.value}\n`);
454
480
  }
455
481
  }
456
482
  /** The usable terminal width: terminal-kit's when it's a real TTY, else stdout's, else a default. */
@@ -466,24 +492,16 @@ function center(text, width) {
466
492
  const left = Math.floor(total / 2);
467
493
  return " ".repeat(left) + text + " ".repeat(total - left);
468
494
  }
469
- function rgb(hex, text) {
470
- terminal.colorRgbHex(hex, text);
471
- }
472
- function boldRgb(hex, text) {
473
- terminal.bold.colorRgbHex(hex, text);
474
- }
475
- //#endregion
476
- //#region src/lib/cli.ts
495
+ // src/lib/cli.ts
477
496
  const argv = cli({
478
497
  name: "ub-debugger",
479
498
  flags: { port: {
480
499
  type: Number,
481
500
  description: "The port number to host the debugger on.",
482
- default: 9090
501
+ default: DEFAULT_BRIDGE_PORT
483
502
  } }
484
503
  });
485
- //#endregion
486
- //#region src/lib/ws.ts
504
+ // src/lib/ws.ts
487
505
  const state = { isInitialized: false };
488
506
  const MESSAGE_LEVELS = {
489
507
  1: terminal.gray,
@@ -505,7 +523,24 @@ const SYSTEM_COLORS = {
505
523
  * @param text The line to print (the glyph is prepended and a trailing newline added).
506
524
  */
507
525
  function system(color, text) {
508
- terminal.bold.colorRgbHex(SYSTEM_COLORS[color], `${SYSTEM_GLYPH} ${text}\n`);
526
+ terminal.bold.colorRgbHex(SYSTEM_COLORS[color], `${timestamp()} ${SYSTEM_GLYPH} ${text}\n`);
527
+ }
528
+ /**
529
+ * @description Formats the current local time for the start of a bridge log line.
530
+ * @param now The moment to format.
531
+ * @returns The time as `HH:MM:SS.mmm`.
532
+ */
533
+ function timestamp(now = /* @__PURE__ */ new Date()) {
534
+ const pad = (value, width = 2) => String(value).padStart(width, "0");
535
+ return `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}.${pad(now.getMilliseconds(), 3)}`;
536
+ }
537
+ /**
538
+ * @description Describes a device from its hello for the bridge log.
539
+ * @param device What the device reported.
540
+ * @returns A one-line summary such as `ios, Discord 351.0, loader 1.0.0, client 0.9.0`.
541
+ */
542
+ function describeDevice(device) {
543
+ return `${device.platform}, Discord ${device.discordVersion}, loader ${device.loaderVersion}, client ${device.clientVersion}`;
509
544
  }
510
545
  /**
511
546
  * @description Boots the bridge: the phone-facing WebSocket server, the shared session routing, and
@@ -518,11 +553,10 @@ function setupWebSocket() {
518
553
  Bun.serve({
519
554
  port: argv.flags.port,
520
555
  fetch(request, server) {
521
- if (new URL(request.url).searchParams.has("mcp")) {
556
+ if (new URL(request.url).searchParams.has(CONTROLLER_ENDPOINT_MARKER)) {
522
557
  server.upgrade(request, { data: { role: "controller" } });
523
558
  return;
524
559
  }
525
- if (state$2.device) return new Response(JSON.stringify({ error: "A device is already connected." }), { status: 503 });
526
560
  server.upgrade(request, { data: { role: "device" } });
527
561
  },
528
562
  websocket: {
@@ -543,7 +577,7 @@ function setupWebSocket() {
543
577
  handleControllerMessage(ws, message);
544
578
  return;
545
579
  }
546
- handleDeviceMessage(message);
580
+ if (ws === state$2.device) handleDeviceMessage(message);
547
581
  },
548
582
  close(ws) {
549
583
  if (ws.data.role === "controller") {
@@ -561,18 +595,24 @@ function setupWebSocket() {
561
595
  });
562
596
  onLog((log) => {
563
597
  abortCurrentReplInput({ deletePrompt: true });
564
- MESSAGE_LEVELS[log.level](`« ${formatLog(stripEscapeSequences(log.message))}\n`);
598
+ MESSAGE_LEVELS[log.level](`${timestamp()} « ${formatLog(stripEscapeSequences(log.message))}\n`);
565
599
  takeReplInput();
566
600
  });
567
601
  onControllerEval((code) => {
568
602
  abortCurrentReplInput({ deletePrompt: true });
569
- terminal.magenta(`» [mcp] ${code}\n`);
603
+ terminal.magenta(`${timestamp()} » [mcp] ${code}\n`);
570
604
  takeReplInput();
571
605
  });
572
606
  onControllerResult((result) => {
573
607
  abortCurrentReplInput({ deletePrompt: true });
574
- if (result.ok) terminal.gray(`« ${result.value}\n`);
575
- else terminal.red(`« ${result.error}\n`);
608
+ if (result.ok) terminal.gray(`${timestamp()} « ${result.value}\n`);
609
+ else terminal.red(`${timestamp()} « ${result.error}\n`);
610
+ takeReplInput();
611
+ });
612
+ onDevice((connected, device) => {
613
+ if (!connected || !device) return;
614
+ abortCurrentReplInput({ deletePrompt: true });
615
+ system("green", `Device identified: ${describeDevice(device)}`);
576
616
  takeReplInput();
577
617
  });
578
618
  onController((connected, count) => {
@@ -581,12 +621,12 @@ function setupWebSocket() {
581
621
  system("pink", connected ? `MCP connected - ${count} ${plural} attached` : `MCP disconnected - ${count} ${plural} attached`);
582
622
  takeReplInput();
583
623
  });
584
- printBanner(`${getLocalAddress()}:${argv.flags.port}`, `ws://127.0.0.1:${argv.flags.port}?mcp`);
624
+ printBanner(`${getLocalAddress()}:${argv.flags.port}`, `ws://127.0.0.1:${argv.flags.port}?${CONTROLLER_ENDPOINT_MARKER}`);
585
625
  }
586
626
  function handleDeviceMessage(raw) {
587
627
  const message = parseMessage(raw);
588
628
  if (!message) {
589
- system("red", "Parsing failed on message from device");
629
+ system("red", `Parsing failed on message from device: ${JSON.stringify(raw.slice(0, 200))}`);
590
630
  return;
591
631
  }
592
632
  handleDeviceMessage$1(message);
@@ -594,7 +634,7 @@ function handleDeviceMessage(raw) {
594
634
  function handleControllerMessage(ws, raw) {
595
635
  const message = parseMessage(raw);
596
636
  if (message?.type === "eval") {
597
- evaluateForController(ws, message.id, message.code);
637
+ evaluateForController(ws, message);
598
638
  return;
599
639
  }
600
640
  if (message?.type === "plugin-push") pushPluginForController(ws, message);
@@ -610,9 +650,7 @@ function getLocalAddress() {
610
650
  for (const iface of ifaces) if (iface.family === "IPv4" && !iface.internal) return iface.address;
611
651
  }
612
652
  }
613
- //#endregion
614
- //#region src/index.ts
653
+ // src/index.ts
615
654
  await loadHistory();
616
655
  setupWebSocket();
617
- //#endregion
618
656
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unbound-app/debugger",
3
- "version": "0.8.0",
3
+ "version": "0.10.1",
4
4
  "description": "A REPL debugger for @unbound-app/client runtime instances.",
5
5
  "author": {
6
6
  "name": "Mario P.",
@@ -21,14 +21,15 @@
21
21
  "type": "module",
22
22
  "module": "./dist/index.mjs",
23
23
  "scripts": {
24
- "build": "tsdown"
24
+ "build": "tsdown",
25
+ "typecheck": "tsc --noEmit -p ."
25
26
  },
26
27
  "dependencies": {
27
- "@unbound-app/debugger-protocol": "0.8.0",
28
- "cleye": "^1.3.4",
29
- "terminal-kit": "^3.1.2"
28
+ "@unbound-app/debugger-protocol": "0.11.0",
29
+ "cleye": "^2.7.0",
30
+ "terminal-kit": "^3.1.4"
30
31
  },
31
32
  "peerDependencies": {
32
- "typescript": "^5.9.3"
33
+ "typescript": "^7.0.2"
33
34
  }
34
35
  }