aboutsummaryrefslogtreecommitdiff
path: root/packages/bun-debug-adapter-protocol/src
diff options
context:
space:
mode:
authorGravatar Jarred Sumner <jarred@jarredsumner.com> 2023-08-27 09:03:15 -0700
committerGravatar GitHub <noreply@github.com> 2023-08-27 09:03:15 -0700
commited5dc5bbf922777acd3f637125a1978b6f2c391a (patch)
treee1e6ab8157ba441cbe612c7fd73348269a73c71e /packages/bun-debug-adapter-protocol/src
parent36f9d2291c71d151dceff6d7d40adbbb2c3b3809 (diff)
downloadbun-ed5dc5bbf922777acd3f637125a1978b6f2c391a.tar.gz
bun-ed5dc5bbf922777acd3f637125a1978b6f2c391a.tar.zst
bun-ed5dc5bbf922777acd3f637125a1978b6f2c391a.zip
@electroid's dap changes (#4367)
* Rework terminal, launch is still WIP * Use the proper Terminal profile API * More changes * progress --------- Co-authored-by: Ashcon Partovi <ashcon@partovi.net>
Diffstat (limited to 'packages/bun-debug-adapter-protocol/src')
-rw-r--r--packages/bun-debug-adapter-protocol/src/debugger/adapter.ts929
-rw-r--r--packages/bun-debug-adapter-protocol/src/debugger/signal.ts87
2 files changed, 577 insertions, 439 deletions
diff --git a/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts b/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts
index 33555dbb0..1996863e6 100644
--- a/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts
+++ b/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts
@@ -1,12 +1,15 @@
import type { DAP } from "../protocol";
+import type { JSC } from "../../../bun-inspector-protocol/src/protocol";
+import type { InspectorEventMap } from "../../../bun-inspector-protocol/src/inspector";
// @ts-ignore
-import type { JSC, InspectorListener, WebSocketInspectorOptions } from "../../../bun-inspector-protocol";
-import { UnixWebSocketInspector, remoteObjectToString } from "../../../bun-inspector-protocol/index";
+import { WebSocketInspector, remoteObjectToString } from "../../../bun-inspector-protocol/index";
import type { ChildProcess } from "node:child_process";
import { spawn, spawnSync } from "node:child_process";
import capabilities from "./capabilities";
import { Location, SourceMap } from "./sourcemap";
import { compare, parse } from "semver";
+import { EventEmitter } from "node:events";
+import { UnixSignal, randomUnixPath } from "./signal";
type InitializeRequest = DAP.InitializeRequest & {
supportsConfigurationDoneRequest?: boolean;
@@ -14,17 +17,22 @@ type InitializeRequest = DAP.InitializeRequest & {
type LaunchRequest = DAP.LaunchRequest & {
runtime?: string;
+ runtimeArgs?: string[];
program?: string;
- cwd?: string;
args?: string[];
+ cwd?: string;
env?: Record<string, string>;
- inheritEnv?: boolean;
+ strictEnv?: boolean;
+ stopOnEntry?: boolean;
+ noDebug?: boolean;
watch?: boolean | "hot";
- debug?: boolean;
};
type AttachRequest = DAP.AttachRequest & {
url?: string;
+ hostname?: string;
+ port?: number;
+ restart?: boolean;
};
type Source = DAP.Source & {
@@ -46,6 +54,7 @@ type Source = DAP.Source & {
type Breakpoint = DAP.Breakpoint & {
id: number;
breakpointId: string;
+ generatedLocation: JSC.Debugger.Location;
source: Source;
};
@@ -67,6 +76,7 @@ type Scope = DAP.Scope & {
type Variable = DAP.Variable & {
objectId?: string;
+ objectGroup?: string;
type: JSC.Runtime.RemoteObject["type"] | JSC.Runtime.RemoteObject["subtype"];
};
@@ -78,24 +88,28 @@ type IDebugAdapter = {
) => void | DAP.ResponseMap[R] | Promise<DAP.ResponseMap[R]> | Promise<void>;
};
-export type DebugAdapterOptions = WebSocketInspectorOptions & {
- url: string | URL;
- send(message: DAP.Request | DAP.Response | DAP.Event): Promise<void>;
- stdout?(message: string): void;
- stderr?(message: string): void;
+export type DebugAdapterEventMap = InspectorEventMap & {
+ [E in keyof DAP.EventMap as E extends string ? `Adapter.${E}` : never]: [DAP.EventMap[E]];
+} & {
+ "Adapter.request": [DAP.Request];
+ "Adapter.response": [DAP.Response];
+ "Adapter.event": [DAP.Event];
+ "Adapter.error": [Error];
+} & {
+ "Process.requested": [unknown];
+ "Process.spawned": [ChildProcess];
+ "Process.exited": [number | Error | null, string | null];
+ "Process.stdout": [string];
+ "Process.stderr": [string];
};
-// This adapter only support single-threaded debugging,
-// which means that there is only one thread at a time.
-const threadId = 1;
+let threadId = 1;
+let isDebug = process.env.NODE_ENV === "development";
-// @ts-ignore
-export class DebugAdapter implements IDebugAdapter, InspectorListener {
- #url: URL;
- #sendToAdapter: DebugAdapterOptions["send"];
- #stdout?: DebugAdapterOptions["stdout"];
- #stderr?: DebugAdapterOptions["stderr"];
- #inspector: UnixWebSocketInspector;
+export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements IDebugAdapter {
+ #threadId: number;
+ #inspector: WebSocketInspector;
+ #process?: ChildProcess;
#sourceId: number;
#pendingSources: Map<string, ((source: Source) => void)[]>;
#sources: Map<string | number, Source>;
@@ -104,19 +118,22 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
#breakpointId: number;
#breakpoints: Breakpoint[];
#functionBreakpoints: Map<string, FunctionBreakpoint>;
- #variables: (Variable | Variable[])[];
- #process?: ChildProcess;
+ #variableId: number;
+ #variables: Map<number, Variable>;
#initialized?: InitializeRequest;
#launched?: LaunchRequest;
- #connected?: boolean;
-
- constructor({ url, send, stdout, stderr, ...options }: DebugAdapterOptions) {
- this.#url = new URL(url);
- // @ts-ignore
- this.#inspector = new UnixWebSocketInspector({ ...options, url, listener: this });
- this.#stdout = stdout;
- this.#stderr = stderr;
- this.#sendToAdapter = send;
+
+ constructor(url?: string | URL) {
+ super();
+ this.#threadId = threadId++;
+ this.#inspector = new WebSocketInspector(url);
+ const emit = this.#inspector.emit.bind(this.#inspector);
+ this.#inspector.emit = (event, ...args) => {
+ let sent = false;
+ sent ||= emit(event, ...args);
+ sent ||= this.emit(event, ...(args as any));
+ return sent;
+ };
this.#sourceId = 1;
this.#pendingSources = new Map();
this.#sources = new Map();
@@ -125,87 +142,155 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
this.#breakpointId = 1;
this.#breakpoints = [];
this.#functionBreakpoints = new Map();
- this.#variables = [{ name: "", value: "", type: undefined, variablesReference: 0 }];
+ this.#variableId = 1;
+ this.#variables = new Map();
+ }
+
+ /**
+ * Gets the inspector url.
+ */
+ get url(): string {
+ return this.#inspector.url;
+ }
+
+ /**
+ * Starts the inspector.
+ * @param url the inspector url
+ * @returns if the inspector was able to connect
+ */
+ start(url?: string): Promise<boolean> {
+ return this.#inspector.start(url);
+ }
+
+ /**
+ * Sends a request to the JavaScript inspector.
+ * @param method the method name
+ * @param params the method parameters
+ * @returns the response
+ * @example
+ * const { result, wasThrown } = await adapter.send("Runtime.evaluate", {
+ * expression: "1 + 1",
+ * });
+ * console.log(result.value); // 2
+ */
+ async send<M extends keyof JSC.ResponseMap>(method: M, params?: JSC.RequestMap[M]): Promise<JSC.ResponseMap[M]> {
+ return this.#inspector.send(method, params);
}
- get inspector(): UnixWebSocketInspector {
- return this.#inspector;
- }
+ /**
+ * Emits an event. For the adapter to work, you must:
+ * - emit `Adapter.request` when the client sends a request to the adapter.
+ * - listen to `Adapter.response` to receive responses from the adapter.
+ * - listen to `Adapter.event` to receive events from the adapter.
+ * @param event the event name
+ * @param args the event arguments
+ * @returns if the event was sent to a listener
+ */
+ emit<E extends keyof DebugAdapterEventMap>(event: E, ...args: DebugAdapterEventMap[E] | []): boolean {
+ if (isDebug && event !== "Adapter.event" && event !== "Inspector.event") {
+ console.log(this.#threadId, event, ...args);
+ }
- async accept(message: DAP.Request | DAP.Response | DAP.Event): Promise<void> {
- const { type } = message;
+ let sent = super.emit(event, ...(args as any));
+
+ if (!(event in this)) {
+ return sent;
+ }
- switch (type) {
- case "request":
- return this.#acceptRequest(message);
+ let result: unknown;
+ try {
+ // @ts-ignore
+ result = this[event](...args);
+ } catch (cause) {
+ sent ||= this.emit("Adapter.error", unknownToError(cause));
+ return sent;
+ }
+
+ if (result instanceof Promise) {
+ result.catch(cause => {
+ this.emit("Adapter.error", unknownToError(cause));
+ });
}
- throw new Error(`Not supported: ${type}`);
+ return sent;
}
- async #acceptRequest(request: DAP.Request): Promise<void> {
- const { seq, command, arguments: args } = request;
+ #emit<E extends keyof DAP.EventMap>(event: E, body?: DAP.EventMap[E]): void {
+ this.emit("Adapter.event", {
+ type: "event",
+ seq: 0,
+ event,
+ body,
+ });
+ }
+
+ #reverseSend<R extends keyof DAP.RequestMap>(name: R, request: DAP.RequestMap[R]): void {
+ this.emit("Adapter.request", {
+ type: "request",
+ seq: 0,
+ command: name,
+ arguments: request,
+ });
+ }
- let response;
+ async ["Adapter.request"](request: DAP.Request): Promise<void> {
+ const { command, arguments: args } = request;
+
+ if (!(command in this)) {
+ return;
+ }
+
+ let result: unknown;
try {
- if (!(command! in this)) {
- throw new Error(`Not supported: ${command}`);
- }
- response = await this[command as keyof this](args);
- } catch (error) {
- const { message } = unknownToError(error);
- return this.#sendToAdapter({
+ // @ts-ignore
+ result = await this[command](args);
+ } catch (cause) {
+ const error = unknownToError(cause);
+ this.emit("Adapter.error", error);
+
+ const { message } = error;
+ this.emit("Adapter.response", {
type: "response",
+ command,
success: false,
message,
- request_seq: seq,
+ request_seq: request.seq,
seq: 0,
- command,
});
+ return;
}
- return this.#sendToAdapter({
+ this.emit("Adapter.response", {
type: "response",
+ command,
success: true,
- request_seq: seq,
+ request_seq: request.seq,
seq: 0,
- command,
- body: response,
+ body: result,
});
}
- async #send<M extends keyof JSC.RequestMap & keyof JSC.ResponseMap>(
- method: M,
- params?: JSC.RequestMap[M],
- ): Promise<JSC.ResponseMap[M]> {
- return this.#inspector.send(method, params);
- }
-
- async #emit<E extends keyof DAP.EventMap>(name: E, body?: DAP.EventMap[E]): Promise<void> {
- await this.#sendToAdapter({
- type: "event",
- seq: 0,
- event: name,
- body,
- });
+ ["Adapter.event"](event: DAP.Event): void {
+ const { event: name, body } = event;
+ this.emit(`Adapter.${name}` as keyof DebugAdapterEventMap, body);
}
initialize(request: InitializeRequest): DAP.InitializeResponse {
const { clientID, supportsConfigurationDoneRequest } = (this.#initialized = request);
- this.#send("Inspector.enable");
- this.#send("Runtime.enable");
- this.#send("Console.enable");
- this.#send("Debugger.enable");
- this.#send("Debugger.setAsyncStackTraceDepth", { depth: 200 });
- this.#send("Debugger.setPauseOnDebuggerStatements", { enabled: true });
- this.#send("Debugger.setBlackboxBreakpointEvaluations", { blackboxBreakpointEvaluations: true });
- this.#send("Debugger.setBreakpointsActive", { active: true });
+ this.send("Inspector.enable");
+ this.send("Runtime.enable");
+ this.send("Console.enable");
+ this.send("Debugger.enable");
+ this.send("Debugger.setAsyncStackTraceDepth", { depth: 200 });
+ this.send("Debugger.setPauseOnDebuggerStatements", { enabled: true });
+ this.send("Debugger.setBlackboxBreakpointEvaluations", { blackboxBreakpointEvaluations: true });
+ this.send("Debugger.setBreakpointsActive", { active: true });
// If the client will not send a `configurationDone` request, then we need to
// tell the debugger that everything is ready.
if (!supportsConfigurationDoneRequest && clientID !== "vscode") {
- this.#send("Inspector.initialized");
+ this.send("Inspector.initialized");
}
// Tell the client what capabilities this adapter supports.
@@ -216,16 +301,16 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
// If the client requested that `noDebug` mode be enabled,
// then we need to disable all breakpoints and pause on statements.
if (this.#launched?.noDebug) {
- this.#send("Debugger.setBreakpointsActive", { active: false });
- this.#send("Debugger.setPauseOnExceptions", { state: "none" });
- this.#send("Debugger.setPauseOnDebuggerStatements", { enabled: false });
- this.#send("Debugger.setPauseOnMicrotasks", { enabled: false });
- this.#send("Debugger.setPauseForInternalScripts", { shouldPause: false });
- this.#send("Debugger.setPauseOnAssertions", { enabled: false });
+ this.send("Debugger.setBreakpointsActive", { active: false });
+ this.send("Debugger.setPauseOnExceptions", { state: "none" });
+ this.send("Debugger.setPauseOnDebuggerStatements", { enabled: false });
+ this.send("Debugger.setPauseOnMicrotasks", { enabled: false });
+ this.send("Debugger.setPauseForInternalScripts", { shouldPause: false });
+ this.send("Debugger.setPauseOnAssertions", { enabled: false });
}
// Tell the debugger that everything is ready.
- this.#send("Inspector.initialized");
+ this.send("Inspector.initialized");
}
async launch(request: DAP.LaunchRequest): Promise<void> {
@@ -241,16 +326,24 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
category: "stderr",
output: `Failed to start debugger.\n${message}`,
});
- this.#emit("terminated");
+ this.terminate();
}
}
async #launch(request: LaunchRequest): Promise<void> {
- if (this.#process?.exitCode === null) {
- throw new Error("Another program is already running. Did you terminate the last session?");
- }
+ const {
+ runtime = "bun",
+ runtimeArgs = [],
+ program,
+ args = [],
+ cwd,
+ env = {},
+ strictEnv = false,
+ stopOnEntry = false,
+ noDebug = false,
+ watch = false,
+ } = request;
- const { program, runtime = "bun", args = [], cwd, env = {}, inheritEnv = true, watch = false } = request;
if (!program) {
throw new Error("No program specified. Did you set the 'program' property in your launch.json?");
}
@@ -259,119 +352,58 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
throw new Error("Program must be a JavaScript or TypeScript file.");
}
- const finalArgs = [...args];
- const isTest = isTestJavaScript(program);
- if (isTest) {
- finalArgs.unshift("test");
+ const processArgs = [...runtimeArgs, program, ...args];
+
+ if (isTestJavaScript(program) && !runtimeArgs.includes("test")) {
+ processArgs.unshift("test");
}
- if (watch) {
- finalArgs.push(watch === "hot" ? "--hot" : "--watch");
+ if (watch && !runtimeArgs.includes("--watch") && !runtimeArgs.includes("--hot")) {
+ processArgs.unshift(watch === "hot" ? "--hot" : "--watch");
}
- const finalEnv = inheritEnv
+ const processEnv = strictEnv
? {
- ...process.env,
...env,
}
: {
+ ...process.env,
...env,
};
- finalEnv["BUN_INSPECT"] = `1${this.#url}`;
- finalEnv["BUN_INSPECT_NOTIFY"] = `unix://${this.#inspector.unix}`;
+ const url = `ws+unix://${randomUnixPath()}`;
+ const signal = new UnixSignal();
- if (isTest) {
- finalEnv["FORCE_COLOR"] = "1";
- } else {
- // https://github.com/microsoft/vscode/issues/571
- finalEnv["NO_COLOR"] = "1";
- }
+ const i = stopOnEntry ? "2" : "1";
+ processEnv["BUN_INSPECT"] = `${i}${url}`;
+ processEnv["BUN_INSPECT_NOTIFY"] = signal.url;
+ processEnv["FORCE_COLOR"] = "1";
- const subprocess = spawn(runtime, [...finalArgs, program], {
- stdio: ["ignore", "pipe", "pipe"],
+ const started = await this.#spawn({
+ command: runtime,
+ args: processArgs,
+ env: processEnv,
cwd,
- env: finalEnv,
+ isDebugee: true,
});
- subprocess.on("spawn", () => {
- this.#process = subprocess;
- this.#emit("process", {
- name: program,
- systemProcessId: subprocess.pid,
- isLocalProcess: true,
- startMethod: "launch",
- });
- });
+ if (!started) {
+ throw new Error("Program could not be started.");
+ }
- subprocess.on("exit", (code, signal) => {
- this.#emit("exited", {
- exitCode: code ?? -1,
+ await new Promise<void>(resolve => {
+ signal.on("Signal.received", () => {
+ resolve();
});
- this.#process = undefined;
- });
-
- subprocess.stdout!.on("data", data => {
- const text = data.toString();
- this.#stdout?.(text);
-
- if (isTest) {
- this.#emit("output", {
- category: "stdout",
- output: text,
- source: {
- path: program,
- },
- });
- }
+ setTimeout(resolve, 5000);
});
- subprocess.stderr!.on("data", data => {
- const text = data.toString();
- this.#stderr?.(text);
-
- if (isTest) {
- this.#emit("output", {
- category: "stdout", // Not stderr, since VSCode will highlight it as red.
- output: text,
- source: {
- path: program,
- },
- });
- }
- });
-
- const start = new Promise<undefined>(resolve => {
- subprocess.on("spawn", () => resolve(undefined));
- });
+ const attached = await this.#attach(url);
- const exitOrError = new Promise<number | string | Error>(resolve => {
- subprocess.on("exit", (code, signal) => resolve(code ?? signal ?? -1));
- subprocess.on("error", resolve);
- });
-
- const reason = await Promise.race([start, exitOrError]);
-
- if (reason instanceof Error) {
- const { message } = reason;
- throw new Error(`Program could not be started.\n${message}`);
- }
-
- if (reason !== undefined) {
- throw new Error(`Program exited with code ${reason} before the debugger could attached.`);
- }
-
- if (await this.#start()) {
+ if (attached) {
return;
}
- if (subprocess.exitCode === null && !subprocess.kill() && !subprocess.kill("SIGKILL")) {
- this.#emit("output", {
- category: "debug console",
- output: `Failed to kill process ${subprocess.pid}\n`,
- });
- }
-
const { stdout: version } = spawnSync(runtime, ["--version"], { stdio: "pipe", encoding: "utf-8" });
const minVersion = "0.8.2";
@@ -382,26 +414,73 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
throw new Error("Program started, but the debugger could not be attached.");
}
- async #start(url?: string | URL): Promise<boolean> {
- if (url) {
- this.#url = new URL(url);
+ async #spawn(options: {
+ command: string;
+ args?: string[];
+ cwd?: string;
+ env?: Record<string, string | undefined>;
+ isDebugee?: boolean;
+ }): Promise<boolean> {
+ const { command, args = [], cwd, env, isDebugee } = options;
+ const request = { command, args, cwd, env };
+ this.emit("Process.requested", request);
+
+ let subprocess: ChildProcess;
+ try {
+ subprocess = spawn(command, args, {
+ ...request,
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ } catch (cause) {
+ this.emit("Process.exited", new Error("Failed to spawn process", { cause }), null);
+ return false;
}
- for (let i = 0; i < 5; i++) {
- const ok = await this.#inspector.start(url);
- if (ok) {
- return true;
+ subprocess.on("spawn", () => {
+ this.emit("Process.spawned", subprocess);
+
+ if (isDebugee) {
+ this.#process = subprocess;
+ this.#emit("process", {
+ name: `${command} ${args.join(" ")}`,
+ systemProcessId: subprocess.pid,
+ isLocalProcess: true,
+ startMethod: "launch",
+ });
}
+ });
- await new Promise(resolve => setTimeout(resolve, 100 * i));
- }
+ subprocess.on("exit", (code, signal) => {
+ this.emit("Process.exited", code, signal);
- return false;
+ if (isDebugee) {
+ this.#process = undefined;
+ this.#emit("exited", {
+ exitCode: code ?? -1,
+ });
+ }
+ });
+
+ subprocess.stdout?.on("data", data => {
+ this.emit("Process.stdout", data.toString());
+ });
+
+ subprocess.stderr?.on("data", data => {
+ this.emit("Process.stderr", data.toString());
+ });
+
+ return new Promise(resolve => {
+ subprocess.on("spawn", () => resolve(true));
+ subprocess.on("exit", () => resolve(false));
+ subprocess.on("error", () => resolve(false));
+ });
}
- async attach(request: DAP.AttachRequest): Promise<void> {
+ async attach(request: AttachRequest): Promise<void> {
+ const { url } = request;
+
try {
- await this.#attach(request);
+ await this.#attach(url);
} catch (error) {
// Some clients, like VSCode, will show a system-level popup when a `launch` request fails.
// Instead, we want to show the error as a sidebar notification.
@@ -410,33 +489,24 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
category: "stderr",
output: `Failed to start debugger.\n${message}`,
});
- this.#emit("terminated");
+ this.terminate();
}
}
- async #attach(request: AttachRequest): Promise<void> {
- const { url } = request;
-
- if (this.#url.href === url) {
- this.#emit("output", {
- category: "debug console",
- output: "Debugger attached.\n",
- });
-
- this.configurationDone();
- return;
- }
-
- if (await this.#start(url)) {
- this.configurationDone();
- return;
+ async #attach(url?: string | URL): Promise<boolean> {
+ for (let i = 0; i < 5; i++) {
+ const ok = await this.#inspector.start(url);
+ if (ok) {
+ return true;
+ }
+ await new Promise(resolve => setTimeout(resolve, 100 * i));
}
-
- throw new Error("Failed to attach to program.");
+ return false;
}
terminate(): void {
this.#process?.kill();
+ this.#emit("terminated");
}
disconnect(request: DAP.DisconnectRequest): void {
@@ -453,18 +523,18 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
const { source } = request;
const { scriptId } = await this.#getSource(sourceToId(source));
- const { scriptSource } = await this.#send("Debugger.getScriptSource", { scriptId });
+ const { scriptSource } = await this.send("Debugger.getScriptSource", { scriptId });
return {
content: scriptSource,
};
}
- async threads(request: DAP.ThreadsRequest): Promise<DAP.ThreadsResponse> {
+ async threads(): Promise<DAP.ThreadsResponse> {
return {
threads: [
{
- id: threadId,
+ id: this.#threadId,
name: "Main Thread",
},
],
@@ -472,27 +542,27 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
}
async pause(): Promise<void> {
- await this.#send("Debugger.pause");
+ await this.send("Debugger.pause");
this.#stopped = "pause";
}
async continue(): Promise<void> {
- await this.#send("Debugger.resume");
+ await this.send("Debugger.resume");
this.#stopped = undefined;
}
async next(): Promise<void> {
- await this.#send("Debugger.stepNext");
+ await this.send("Debugger.stepNext");
this.#stopped = "step";
}
async stepIn(): Promise<void> {
- await this.#send("Debugger.stepInto");
+ await this.send("Debugger.stepInto");
this.#stopped = "step";
}
async stepOut(): Promise<void> {
- await this.#send("Debugger.stepOut");
+ await this.send("Debugger.stepOut");
this.#stopped = "step";
}
@@ -505,7 +575,7 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
this.#generatedLocation(source, endLine ?? line + 1, endColumn),
]);
- const { locations } = await this.#send("Debugger.getBreakpointLocations", {
+ const { locations } = await this.send("Debugger.getBreakpointLocations", {
start,
end,
});
@@ -590,17 +660,19 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
const source = await this.#getSource(sourceId);
const oldBreakpoints = this.#getBreakpoints(sourceId);
-
const breakpoints = await Promise.all(
requests!.map(async ({ line, column, ...options }) => {
- const breakpoint = this.#getBreakpoint(sourceId, line, column);
- if (breakpoint) {
- return breakpoint;
+ const location = this.#generatedLocation(source, line, column);
+
+ for (const breakpoint of oldBreakpoints) {
+ const { generatedLocation } = breakpoint;
+ if (locationIsSame(generatedLocation, location)) {
+ return breakpoint;
+ }
}
- const location = this.#generatedLocation(source, line, column);
try {
- const { breakpointId, actualLocation } = await this.#send("Debugger.setBreakpoint", {
+ const { breakpointId, actualLocation } = await this.send("Debugger.setBreakpoint", {
location,
options: breakpointOptions(options),
});
@@ -611,6 +683,7 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
breakpointId,
source,
verified: true,
+ generatedLocation: location,
...originalLocation,
});
} catch (error) {
@@ -626,6 +699,7 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
source,
verified: false,
message,
+ generatedLocation: location,
});
}
}),
@@ -635,7 +709,7 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
oldBreakpoints.map(async ({ breakpointId }) => {
const isRemoved = !breakpoints.filter(({ breakpointId: id }) => breakpointId === id).length;
if (isRemoved) {
- await this.#send("Debugger.removeBreakpoint", {
+ await this.send("Debugger.removeBreakpoint", {
breakpointId,
});
this.#removeBreakpoint(breakpointId);
@@ -661,18 +735,13 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
return breakpoints;
}
- #getBreakpoint(sourceId: string | number, line?: number, column?: number): Breakpoint | undefined {
- for (const breakpoint of this.#getBreakpoints(sourceId)) {
- if (isSameLocation(breakpoint, { line, column })) {
- return breakpoint;
- }
- }
- return undefined;
- }
-
#addBreakpoint(breakpoint: Breakpoint): Breakpoint {
this.#breakpoints.push(breakpoint);
+ // For now, remove the column from breakpoints because
+ // it can be inaccurate and causes weird rendering issues in VSCode.
+ breakpoint.column = this.#lineFrom0BasedLine(0);
+
this.#emit("breakpoint", {
reason: "changed",
breakpoint,
@@ -700,7 +769,6 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
const { breakpoints: requests } = request;
const oldBreakpoints = this.#getFunctionBreakpoints();
-
const breakpoints = await Promise.all(
requests.map(async ({ name, ...options }) => {
const breakpoint = this.#getFunctionBreakpoint(name);
@@ -709,7 +777,7 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
}
try {
- await this.#send("Debugger.addSymbolicBreakpoint", {
+ await this.send("Debugger.addSymbolicBreakpoint", {
symbol: name,
caseSensitive: true,
isRegex: false,
@@ -737,7 +805,7 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
oldBreakpoints.map(async ({ name }) => {
const isRemoved = !breakpoints.filter(({ name: n }) => name === n).length;
if (isRemoved) {
- await this.#send("Debugger.removeSymbolicBreakpoint", {
+ await this.send("Debugger.removeSymbolicBreakpoint", {
symbol: name,
caseSensitive: true,
isRegex: false,
@@ -763,18 +831,22 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
#addFunctionBreakpoint(breakpoint: FunctionBreakpoint): FunctionBreakpoint {
const { name } = breakpoint;
this.#functionBreakpoints.set(name, breakpoint);
+
this.#emit("breakpoint", {
reason: "changed",
breakpoint,
});
+
return breakpoint;
}
#removeFunctionBreakpoint(name: string): void {
const breakpoint = this.#functionBreakpoints.get(name);
+
if (!breakpoint || !this.#functionBreakpoints.delete(name)) {
return;
}
+
this.#emit("breakpoint", {
reason: "removed",
breakpoint,
@@ -789,7 +861,7 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
filterIds.push(...filterOptions.map(({ filterId }) => filterId));
}
- await this.#send("Debugger.setPauseOnExceptions", {
+ await this.send("Debugger.setPauseOnExceptions", {
state: exceptionFiltersToPauseOnExceptionsState(filterIds),
});
}
@@ -797,8 +869,9 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
async evaluate(request: DAP.EvaluateRequest): Promise<DAP.EvaluateResponse> {
const { expression, frameId, context } = request;
const callFrameId = this.#getCallFrameId(frameId);
+ const objectGroup = callFrameId ? "debugger" : context;
- const { result, wasThrown } = await this.#evaluate(expression, callFrameId);
+ const { result, wasThrown } = await this.#evaluate(expression, objectGroup, callFrameId);
const { className } = result;
if (context === "hover" && wasThrown && (className === "SyntaxError" || className === "ReferenceError")) {
@@ -808,18 +881,23 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
};
}
- const { name, value, ...variable } = this.#addVariable(result);
+ const { name, value, ...variable } = this.#addObject(result, { objectGroup });
return {
...variable,
result: value,
};
}
- async #evaluate(expression: string, callFrameId?: string): Promise<JSC.Runtime.EvaluateResponse> {
+ async #evaluate(
+ expression: string,
+ objectGroup?: string,
+ callFrameId?: string,
+ ): Promise<JSC.Runtime.EvaluateResponse> {
const method = callFrameId ? "Debugger.evaluateOnCallFrame" : "Runtime.evaluate";
- return this.#send(method, {
+ return this.send(method, {
callFrameId,
+ objectGroup,
expression: sanitizeExpression(expression),
generatePreview: true,
emulateUserGesture: true,
@@ -839,13 +917,6 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
}
["Inspector.connected"](): void {
- if (this.#connected) {
- this.restart();
- return;
- }
-
- this.#connected = true;
-
this.#emit("output", {
category: "debug console",
output: "Debugger attached.\n",
@@ -855,19 +926,19 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
}
async ["Inspector.disconnected"](error?: Error): Promise<void> {
- if (this.#connected && this.#process?.exitCode === null && (await this.#start())) {
- return;
- }
-
- if (!this.#connected) {
- return;
- }
-
this.#emit("output", {
category: "debug console",
output: "Debugger detached.\n",
});
+ if (error) {
+ const { message } = error;
+ this.#emit("output", {
+ category: "stderr",
+ output: `${message}\n`,
+ });
+ }
+
this.#emit("terminated");
this.#reset();
}
@@ -915,6 +986,11 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
["Debugger.scriptFailedToParse"](event: JSC.Debugger.ScriptFailedToParseEvent): void {
const { url, errorMessage, errorLine } = event;
+ // If no url is present, the script is from a `evaluate` request.
+ if (!url) {
+ return;
+ }
+
this.#emit("output", {
category: "stderr",
output: errorMessage,
@@ -928,14 +1004,14 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
["Debugger.paused"](event: JSC.Debugger.PausedEvent): void {
const { reason, callFrames, asyncStackTrace, data } = event;
- if (reason === "PauseOnNextStatement") {
- for (const { functionName } of callFrames) {
- if (functionName === "module code") {
- this.#send("Debugger.resume");
- return;
- }
- }
- }
+ // if (reason === "PauseOnNextStatement") {
+ // for (const { functionName } of callFrames) {
+ // if (functionName === "module code") {
+ // this.send("Debugger.resume");
+ // return;
+ // }
+ // }
+ // }
this.#stackFrames.length = 0;
this.#stopped ||= stoppedReason(reason);
@@ -946,10 +1022,11 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
this.#addAsyncStackTrace(asyncStackTrace);
}
- let hitBreakpointIds: number[] | undefined;
// Depending on the reason, the `data` property is set to the reason
// why the execution was paused. For example, if the reason is "breakpoint",
// the `data` property is set to the breakpoint ID.
+ let hitBreakpointIds: number[] | undefined;
+
if (data) {
if (reason === "exception") {
const remoteObject = data as JSC.Runtime.RemoteObject;
@@ -976,7 +1053,7 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
}
this.#emit("stopped", {
- threadId,
+ threadId: this.#threadId,
reason: this.#stopped,
hitBreakpointIds,
});
@@ -985,83 +1062,93 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
["Debugger.resumed"](event: JSC.Debugger.ResumedEvent): void {
this.#stackFrames.length = 0;
this.#stopped = undefined;
+ console.log("VARIABLES BEFORE", this.#variables.size);
+ for (const { variablesReference, objectGroup } of this.#variables.values()) {
+ if (objectGroup === "debugger") {
+ this.#variables.delete(variablesReference);
+ }
+ }
+ console.log("VARIABLES AFTER", this.#variables.size);
this.#emit("continued", {
- threadId,
+ threadId: this.#threadId,
});
}
- ["Console.messageAdded"](event: JSC.Console.MessageAddedEvent): void {
- const { message } = event;
- const { type, level, text, parameters, line, column, stackTrace } = message;
-
- let output: string;
- let variablesReference: number | undefined;
-
- if (parameters?.length) {
- output = "";
-
- const variables = parameters.map((parameter, i) => {
- const variable = this.#addVariable(parameter, { name: `${i}` });
-
- output += remoteObjectToString(parameter, true) + " ";
-
- return variable;
- });
-
- if (variables.length === 1) {
- const [{ variablesReference: reference }] = variables;
- variablesReference = reference;
- } else {
- variablesReference = this.#setVariable(variables);
- }
- } else {
- output = text;
- }
-
- if (!output.endsWith("\n")) {
- output += "\n";
- }
-
- const color = consoleLevelToAnsiColor(level);
- if (color) {
- output = `${color}${output}`;
- }
-
- if (variablesReference) {
- variablesReference = this.#setVariable([
- {
- name: "",
- value: "",
- type: undefined,
- variablesReference,
- },
- ]);
- }
-
- let source: Source | undefined;
- if (stackTrace) {
- const { callFrames } = stackTrace;
- if (callFrames.length) {
- const { scriptId } = callFrames.at(-1)!;
- source = this.#getSourceIfPresent(scriptId);
- }
- }
-
- let location: Location | {} = {};
- if (source) {
- location = this.#originalLocation(source, line, column);
- }
+ ["Process.stdout"](output: string): void {
+ this.#emit("output", {
+ category: "debug console",
+ output,
+ });
+ }
+ ["Process.stderr"](output: string): void {
this.#emit("output", {
category: "debug console",
- group: consoleMessageGroup(type),
output,
- variablesReference,
- source,
- ...location,
});
}
+ ["Console.messageAdded"](event: JSC.Console.MessageAddedEvent): void {
+ // const { message } = event;
+ // const { type, level, text, parameters, line, column, stackTrace } = message;
+ // let output: string;
+ // let variablesReference: number | undefined;
+ // if (parameters?.length) {
+ // output = "";
+ // const variables = parameters.map((parameter, i) => {
+ // const variable = this.#addObject(parameter, { name: `${i}`, objectGroup: "console" });
+ // output += remoteObjectToString(parameter, true) + " ";
+ // return variable;
+ // });
+ // if (variables.length === 1) {
+ // const [{ variablesReference: reference }] = variables;
+ // variablesReference = reference;
+ // } else {
+ // variablesReference = this.#variableId++;
+ // //this.#variables.set(variablesReference, variables);
+ // }
+ // } else {
+ // output = text;
+ // }
+ // if (!output.endsWith("\n")) {
+ // output += "\n";
+ // }
+ // const color = consoleLevelToAnsiColor(level);
+ // if (color) {
+ // output = `${color}${output}`;
+ // }
+ // if (variablesReference) {
+ // const containerReference = this.#variableId++;
+ // this.#variables.set(containerReference, {
+ // name: "",
+ // value: "",
+ // type: undefined,
+ // variablesReference,
+ // });
+ // variablesReference = containerReference;
+ // }
+ // let source: Source | undefined;
+ // if (stackTrace) {
+ // const { callFrames } = stackTrace;
+ // if (callFrames.length) {
+ // const { scriptId } = callFrames.at(-1)!;
+ // source = this.#getSourceIfPresent(scriptId);
+ // }
+ // }
+ // let location: Location | {} = {};
+ // if (source) {
+ // location = this.#originalLocation(source, line, column);
+ // }
+ // this.#emit("output", {
+ // category: "debug console",
+ // group: consoleMessageGroup(type),
+ // output,
+ // variablesReference,
+ // source,
+ // ...location,
+ // });
+ }
+
#addSource(source: Source): Source {
const { sourceId, scriptId, path, sourceReference } = source;
@@ -1228,7 +1315,7 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
continue;
}
- const { variablesReference } = this.#addVariable(object);
+ const { variablesReference } = this.#addObject(object, { objectGroup: "debugger" });
const presentationHint = scopePresentationHint(type);
const title = presentationHint ? titleize(presentationHint) : "Unknown";
const displayName = name ? `${title}: ${name}` : title;
@@ -1311,7 +1398,7 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
async variables(request: DAP.VariablesRequest): Promise<DAP.VariablesResponse> {
const { variablesReference, start, count } = request;
- const variable = this.#variables[variablesReference];
+ const variable = this.#variables.get(variablesReference);
let variables: Variable[];
if (!variable) {
@@ -1319,7 +1406,7 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
} else if (Array.isArray(variable)) {
variables = variable;
} else {
- variables = await this.#getVariables(variable, start, count);
+ variables = await this.#getProperties(variable, start, count);
}
return {
@@ -1327,20 +1414,16 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
};
}
- #setVariable(variable: Variable | Variable[]): number {
- const variablesReference = this.#variables.length;
-
- this.#variables.push(variable);
-
- return variablesReference;
- }
-
- #addVariable(remoteObject: JSC.Runtime.RemoteObject, propertyDescriptor?: JSC.Runtime.PropertyDescriptor): Variable {
+ #addObject(
+ remoteObject: JSC.Runtime.RemoteObject,
+ propertyDescriptor?: Partial<JSC.Runtime.PropertyDescriptor> & { objectGroup?: string },
+ ): Variable {
const { objectId, type, subtype, size } = remoteObject;
- const variablesReference = objectId ? this.#variables.length : 0;
+ const variablesReference = objectId ? this.#variableId++ : 0;
const variable: Variable = {
objectId,
+ objectGroup: propertyDescriptor?.objectGroup,
name: propertyDescriptorToName(propertyDescriptor),
type: subtype || type,
value: remoteObjectToString(remoteObject),
@@ -1349,37 +1432,74 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
namedVariables: isNamedIndexed(subtype) ? size : undefined,
presentationHint: remoteObjectToVariablePresentationHint(remoteObject, propertyDescriptor),
};
- this.#setVariable(variable);
+
+ if (variablesReference) {
+ this.#variables.set(variablesReference, variable);
+ console.log("addObject", variablesReference, variable);
+ }
return variable;
}
- async #getVariables(variable: Variable, offset?: number, count?: number): Promise<Variable[]> {
- const { objectId, type, indexedVariables, namedVariables } = variable;
+ #addProperty(
+ propertyDescriptor:
+ | JSC.Runtime.PropertyDescriptor
+ | (JSC.Runtime.InternalPropertyDescriptor & { objectGroup?: string }),
+ ): Variable[] {
+ const { value, get, set, symbol } = propertyDescriptor as JSC.Runtime.PropertyDescriptor;
+ const variables: Variable[] = [];
+
+ if (value) {
+ variables.push(this.#addObject(value, propertyDescriptor));
+ }
+
+ if (get) {
+ const { type } = get;
+ if (type !== "undefined") {
+ variables.push(this.#addObject(get, propertyDescriptor));
+ }
+ }
+
+ if (set) {
+ const { type } = set;
+ if (type !== "undefined") {
+ variables.push(this.#addObject(set, propertyDescriptor));
+ }
+ }
+
+ if (symbol) {
+ variables.push(this.#addObject(symbol, propertyDescriptor));
+ }
+
+ return variables;
+ }
+
+ async #getProperties(variable: Variable, offset?: number, count?: number): Promise<Variable[]> {
+ const { objectId, objectGroup, type, indexedVariables, namedVariables } = variable;
+ const variables: Variable[] = [];
if (!objectId || type === "symbol") {
- return [];
+ return variables;
}
- const { properties, internalProperties } = await this.#send("Runtime.getDisplayableProperties", {
+ const { properties, internalProperties } = await this.send("Runtime.getDisplayableProperties", {
objectId,
generatePreview: true,
});
- const variables: Variable[] = [];
for (const property of properties) {
- variables.push(...this.#getVariable(property));
+ variables.push(...this.#addProperty({ ...property, objectGroup }));
}
if (internalProperties) {
for (const property of internalProperties) {
- variables.push(...this.#getVariable({ ...property, configurable: false }));
+ variables.push(...this.#addProperty({ ...property, objectGroup, configurable: false }));
}
}
const hasEntries = type !== "array" && (indexedVariables || namedVariables);
if (hasEntries) {
- const { entries } = await this.#send("Runtime.getCollectionEntries", {
+ const { entries } = await this.send("Runtime.getCollectionEntries", {
objectId,
fetchStart: offset,
fetchCount: count,
@@ -1392,48 +1512,16 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
const { value, description } = key;
name = String(value ?? description);
}
- variables.push(this.#addVariable(value, { name }));
+ variables.push(this.#addObject(value, { name, objectGroup }));
}
}
return variables;
}
- #getVariable(
- propertyDescriptor: JSC.Runtime.PropertyDescriptor | JSC.Runtime.InternalPropertyDescriptor,
- ): Variable[] {
- const { value, get, set, symbol } = propertyDescriptor as JSC.Runtime.PropertyDescriptor;
- const variables: Variable[] = [];
-
- if (value) {
- variables.push(this.#addVariable(value, propertyDescriptor));
- }
-
- if (get) {
- const { type } = get;
- if (type !== "undefined") {
- variables.push(this.#addVariable(get, propertyDescriptor));
- }
- }
-
- if (set) {
- const { type } = set;
- if (type !== "undefined") {
- variables.push(this.#addVariable(set, propertyDescriptor));
- }
- }
-
- if (symbol) {
- variables.push(this.#addVariable(symbol, propertyDescriptor));
- }
-
- return variables;
- }
-
close(): void {
this.#process?.kill();
this.#inspector.close();
- this.#reset();
}
#reset(): void {
@@ -1444,10 +1532,9 @@ export class DebugAdapter implements IDebugAdapter, InspectorListener {
this.#breakpointId = 1;
this.#breakpoints.length = 0;
this.#functionBreakpoints.clear();
- this.#variables.length = 1;
+ this.#variables.clear();
this.#launched = undefined;
this.#initialized = undefined;
- this.#connected = undefined;
}
}
@@ -1555,14 +1642,6 @@ function consoleMessageGroup(type: JSC.Console.ConsoleMessage["type"]): DAP.Outp
return undefined;
}
-function sourceToPath(source?: DAP.Source): string {
- const { path } = source ?? {};
- if (!path) {
- throw new Error("No source found.");
- }
- return path;
-}
-
function sourceToId(source?: DAP.Source): string | number {
const { path, sourceReference } = source ?? {};
if (path) {
@@ -1595,17 +1674,19 @@ function sanitizeExpression(expression: string): string {
function remoteObjectToVariablePresentationHint(
remoteObject: JSC.Runtime.RemoteObject,
- propertyDescriptor?: JSC.Runtime.PropertyDescriptor,
+ propertyDescriptor?: Partial<JSC.Runtime.PropertyDescriptor>,
): DAP.VariablePresentationHint {
const { type, subtype } = remoteObject;
const { name, configurable, writable, isPrivate, symbol, get, set, wasThrown } = propertyDescriptor ?? {};
const hasGetter = get?.type === "function";
const hasSetter = set?.type === "function";
const hasSymbol = symbol?.type === "symbol";
+
let kind: string | undefined;
let visibility: string | undefined;
let lazy: boolean | undefined;
let attributes: string[] = [];
+
if (type === "function") {
kind = "method";
}
@@ -1625,6 +1706,7 @@ function remoteObjectToVariablePresentationHint(
lazy = true;
attributes.push("hasSideEffects");
}
+
return {
kind,
visibility,
@@ -1633,7 +1715,7 @@ function remoteObjectToVariablePresentationHint(
};
}
-function propertyDescriptorToName(propertyDescriptor?: JSC.Runtime.PropertyDescriptor): string {
+function propertyDescriptorToName(propertyDescriptor?: Partial<JSC.Runtime.PropertyDescriptor>): string {
if (!propertyDescriptor) {
return "";
}
@@ -1641,7 +1723,7 @@ function propertyDescriptorToName(propertyDescriptor?: JSC.Runtime.PropertyDescr
if (name === "__proto__") {
return "[[Prototype]]";
}
- return name;
+ return name ?? "";
}
function unknownToError(input: unknown): Error {
@@ -1659,41 +1741,6 @@ function isTestJavaScript(path: string): boolean {
return /\.(test|spec)\.(c|m)?(j|t)sx?$/.test(path);
}
-function parseUrl(hostname?: string, port?: number): URL {
- hostname ||= "localhost";
- port ||= 6499;
- let url: URL;
- try {
- if (hostname.includes("://")) {
- url = new URL(hostname);
- } else if (hostname.includes(":") && !hostname.startsWith("[")) {
- url = new URL(`ws://[${hostname}]:${port}/`);
- } else {
- url = new URL(`ws://${hostname}:${port}/`);
- }
- } catch {
- throw new Error(`Invalid URL or hostname/port: ${hostname}`);
- }
- // HACK: Bun sometimes has issues connecting through "127.0.0.1"
- if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
- url.hostname = "[::1]";
- }
- return url;
-}
-
-function parseUrlMaybe(string: string): URL | undefined {
- const match = /(wss?:\/\/.*)/im.exec(string);
- if (!match) {
- return undefined;
- }
- const [_, href] = match;
- try {
- return parseUrl(href);
- } catch {
- return undefined;
- }
-}
-
function variablesSortBy(a: DAP.Variable, b: DAP.Variable): number {
const visibility = (variable: DAP.Variable): number => {
const { presentationHint } = variable;
@@ -1749,3 +1796,7 @@ function consoleLevelToAnsiColor(level: JSC.Console.ConsoleMessage["level"]): st
function numberIsValid(number?: number): number is number {
return typeof number === "number" && isFinite(number) && number >= 0;
}
+
+function locationIsSame(a: JSC.Debugger.Location, b: JSC.Debugger.Location): boolean {
+ return a.scriptId === b.scriptId && a.lineNumber === b.lineNumber && a.columnNumber === b.columnNumber;
+}
diff --git a/packages/bun-debug-adapter-protocol/src/debugger/signal.ts b/packages/bun-debug-adapter-protocol/src/debugger/signal.ts
new file mode 100644
index 000000000..3c635fb4a
--- /dev/null
+++ b/packages/bun-debug-adapter-protocol/src/debugger/signal.ts
@@ -0,0 +1,87 @@
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import type { Server } from "node:net";
+import { createServer } from "node:net";
+import { EventEmitter } from "node:events";
+
+const isDebug = process.env.NODE_ENV === "development";
+
+export type UnixSignalEventMap = {
+ "Signal.listening": [string];
+ "Signal.error": [Error];
+ "Signal.received": [string];
+ "Signal.closed": [];
+};
+
+/**
+ * Starts a server that listens for signals on a UNIX domain socket.
+ */
+export class UnixSignal extends EventEmitter<UnixSignalEventMap> {
+ #path: string;
+ #server: Server;
+ #ready: Promise<void>;
+
+ constructor(path?: string) {
+ super();
+ this.#path = path ? parseUnixPath(path) : randomUnixPath();
+ this.#server = createServer();
+ this.#server.on("listening", () => this.emit("Signal.listening", this.#path));
+ this.#server.on("error", error => this.emit("Signal.error", error));
+ this.#server.on("close", () => this.emit("Signal.closed"));
+ this.#server.on("connection", socket => {
+ socket.on("data", data => {
+ this.emit("Signal.received", data.toString());
+ });
+ });
+ this.#ready = new Promise((resolve, reject) => {
+ this.#server.on("listening", resolve);
+ this.#server.on("error", reject);
+ });
+ this.#server.listen(this.#path);
+ }
+
+ emit<E extends keyof UnixSignalEventMap>(event: E, ...args: UnixSignalEventMap[E]): boolean {
+ if (isDebug) {
+ console.log(event, ...args);
+ }
+
+ return super.emit(event, ...args);
+ }
+
+ /**
+ * The path to the UNIX domain socket.
+ */
+ get url(): string {
+ return `unix://${this.#path}`;
+ }
+
+ /**
+ * Resolves when the server is listening or rejects if an error occurs.
+ */
+ get ready(): Promise<void> {
+ return this.#ready;
+ }
+
+ /**
+ * Closes the server.
+ */
+ close(): void {
+ this.#server.close();
+ }
+}
+
+export function randomUnixPath(): string {
+ return join(tmpdir(), `${Math.random().toString(36).slice(2)}.sock`);
+}
+
+function parseUnixPath(path: string): string {
+ if (path.startsWith("/")) {
+ return path;
+ }
+ try {
+ const { pathname } = new URL(path);
+ return pathname;
+ } catch {
+ throw new Error(`Invalid UNIX path: ${path}`);
+ }
+}