Files
bun.sh/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts
2025-07-13 21:57:44 -07:00

2804 lines
77 KiB
TypeScript

import { ChildProcess, spawn } from "node:child_process";
import { EventEmitter } from "node:events";
import { AddressInfo, createServer, Socket } from "node:net";
import * as path from "node:path";
import { remoteObjectToString, WebSocketInspector } from "../../../bun-inspector-protocol/index.ts";
import type { Inspector, InspectorEventMap } from "../../../bun-inspector-protocol/src/inspector/index.d.ts";
import { NodeSocketInspector } from "../../../bun-inspector-protocol/src/inspector/node-socket.ts";
import type { JSC } from "../../../bun-inspector-protocol/src/protocol/index.d.ts";
import type { DAP } from "../protocol/index.d.ts";
import { randomUnixPath, TCPSocketSignal, UnixSignal } from "./signal.ts";
import { Location, SourceMap } from "./sourcemap.ts";
export async function getAvailablePort(): Promise<number> {
const server = createServer();
server.listen(0);
return new Promise(resolve => {
server.on("listening", () => {
const { port } = server.address() as AddressInfo;
server.close(() => {
resolve(port);
});
});
});
}
const capabilities: DAP.Capabilities = {
supportsConfigurationDoneRequest: true,
supportsFunctionBreakpoints: true,
supportsConditionalBreakpoints: true,
supportsHitConditionalBreakpoints: true,
supportsEvaluateForHovers: true,
exceptionBreakpointFilters: [
{
filter: "all",
label: "All Exceptions",
default: false,
supportsCondition: true,
description: "Breaks on all throw errors, even if they're caught later.",
conditionDescription: `error.name == "CustomError"`,
},
{
filter: "uncaught",
label: "Uncaught Exceptions",
default: false,
supportsCondition: true,
description: "Breaks only on errors or promise rejections that are not handled.",
conditionDescription: `error.name == "CustomError"`,
},
{
filter: "debugger",
label: "Debugger Statements",
default: true,
supportsCondition: false,
description: "Breaks on `debugger` statements.",
},
{
filter: "assert",
label: "Assertion Failures",
default: false,
supportsCondition: false,
description: "Breaks on failed assertions.",
},
{
filter: "microtask",
label: "Microtasks",
default: false,
supportsCondition: false,
description: "Breaks on microtasks.",
},
],
supportsStepBack: false,
supportsSetVariable: true,
supportsRestartFrame: false,
supportsGotoTargetsRequest: true,
supportsStepInTargetsRequest: false,
supportsCompletionsRequest: true,
completionTriggerCharacters: [".", "[", '"', "'"],
supportsModulesRequest: false,
additionalModuleColumns: [],
supportedChecksumAlgorithms: [],
supportsRestartRequest: false, // TODO
supportsExceptionOptions: false, // TODO
supportsValueFormattingOptions: false,
supportsExceptionInfoRequest: true,
supportTerminateDebuggee: true,
supportSuspendDebuggee: false,
supportsDelayedStackTraceLoading: true,
supportsLoadedSourcesRequest: true,
supportsLogPoints: true,
supportsTerminateThreadsRequest: false,
supportsSetExpression: true,
supportsTerminateRequest: true,
supportsDataBreakpoints: false, // TODO
supportsReadMemoryRequest: false,
supportsWriteMemoryRequest: false,
supportsDisassembleRequest: false,
supportsCancelRequest: false,
supportsBreakpointLocationsRequest: true,
supportsClipboardContext: false,
supportsSteppingGranularity: false,
supportsInstructionBreakpoints: false,
supportsExceptionFilterOptions: false,
supportsSingleThreadExecutionRequests: false,
};
type InitializeRequest = DAP.InitializeRequest & {
supportsConfigurationDoneRequest?: boolean;
enableControlFlowProfiler?: boolean;
enableDebugger?: boolean;
enableTestReporter?: boolean;
enableConsole?: boolean | true;
} & (
| {
enableLifecycleAgentReporter?: false;
sendImmediatePreventExit?: false;
}
| {
enableLifecycleAgentReporter: true;
sendImmediatePreventExit?: boolean;
}
);
type LaunchRequest = DAP.LaunchRequest & {
runtime?: string;
runtimeArgs?: string[];
program?: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
strictEnv?: boolean;
stopOnEntry?: boolean;
noDebug?: boolean;
watchMode?: boolean | "hot";
__skipValidation?: boolean;
stdin?: string;
};
type AttachRequest = DAP.AttachRequest & {
url?: string;
noDebug?: boolean;
stopOnEntry?: boolean;
};
type DebuggerOptions = (LaunchRequest & { type: "launch" }) | (AttachRequest & { type: "attach" });
type Source = DAP.Source & {
scriptId: string;
sourceMap: SourceMap;
} & (
| {
sourceId: string;
path: string;
sourceReference?: undefined;
}
| {
sourceId: number;
path?: undefined;
sourceReference: number;
}
);
type Breakpoint = DAP.Breakpoint & {
id: number;
breakpointId: string;
request?: DAP.SourceBreakpoint;
source?: Source;
};
type FutureBreakpoint = {
url: string;
breakpoint: DAP.SourceBreakpoint;
};
type Target = (DAP.GotoTarget | DAP.StepInTarget) & {
source: Source;
};
type FunctionBreakpoint = DAP.Breakpoint & {
id: number;
name: string;
};
type StackFrame = DAP.StackFrame & {
scriptId: string;
callFrameId: string;
source?: Source;
scopes?: Scope[];
};
type Scope = DAP.Scope & {
source?: Source;
};
type Variable = DAP.Variable & {
objectId?: string;
objectGroup?: string;
type: JSC.Runtime.RemoteObject["type"] | JSC.Runtime.RemoteObject["subtype"];
};
type IDebugAdapter = {
[E in keyof DAP.EventMap]?: (event: DAP.EventMap[E]) => void | Promise<void>;
} & {
[R in keyof DAP.RequestMap]?: (
request: DAP.RequestMap[R],
) => void | DAP.ResponseMap[R] | Promise<DAP.ResponseMap[R]> | Promise<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];
"Adapter.reverseRequest": [DAP.Request];
} & {
"Process.requested": [unknown];
"Process.spawned": [ChildProcess];
"Process.exited": [number | Error | null, string | null];
"Process.stdout": [string];
"Process.stderr": [string];
};
const isDebug = process.env.NODE_ENV === "development";
const debugSilentEvents = new Set(["Adapter.event", "Inspector.event"]);
let threadId = 1;
// Add these helper functions at the top level
function normalizeSourcePath(sourcePath: string, untitledDocPath?: string, bunEvalPath?: string): string {
if (!sourcePath) return sourcePath;
// Handle eval source paths
if (sourcePath === bunEvalPath) {
return bunEvalPath!;
}
// Handle untitled documents
if (sourcePath === untitledDocPath) {
return bunEvalPath!;
}
// Handle normal file paths
return path.normalize(sourcePath);
}
export abstract class BaseDebugAdapter<T extends Inspector = Inspector>
extends EventEmitter<DebugAdapterEventMap>
implements IDebugAdapter
{
protected readonly inspector: T;
protected options?: DebuggerOptions;
#threadId: number;
#sourceId: number;
#pendingSources: Map<string, ((source: Source) => void)[]>;
#sources: Map<string | number, Source>;
#stackFrames: StackFrame[];
#stopped?: DAP.StoppedEvent["reason"];
#exception?: Variable;
#breakpoints: Map<string, Breakpoint[]>;
#futureBreakpoints: Map<string, FutureBreakpoint[]>;
#functionBreakpoints: Map<string, FunctionBreakpoint>;
#targets: Map<number, Target>;
#variableId: number;
#variables: Map<number, Variable>;
#untitledDocPath?: string;
#bunEvalPath?: string;
#initialized?: InitializeRequest;
protected constructor(inspector: T, untitledDocPath?: string, bunEvalPath?: string) {
super();
this.#untitledDocPath = untitledDocPath;
this.#bunEvalPath = bunEvalPath;
this.#threadId = threadId++;
this.inspector = inspector;
const emit = this.inspector.emit.bind(this.inspector);
this.inspector.emit = (event, ...args) => {
let sent = false;
sent ||= emit(event, ...args);
sent ||= this.emit(event as keyof JSC.EventMap, ...(args as any));
return sent;
};
this.#sourceId = 1;
this.#pendingSources = new Map();
this.#sources = new Map();
this.#stackFrames = [];
this.#stopped = undefined;
this.#breakpoints = new Map();
this.#futureBreakpoints = new Map();
this.#functionBreakpoints = new Map();
this.#targets = new Map();
this.#variableId = 1;
this.#variables = new Map();
}
/**
* Gets the inspector url. This is deprecated and exists for compat.
* @deprecated You should get the inspector directly (with .getInspector()), and if it's a WebSocketInspector you can access `.url` direclty.
*/
get url(): string {
// This code has been migrated from a time when the inspector was always a WebSocketInspector.
if (this.inspector instanceof WebSocketInspector) {
return this.inspector.url;
}
throw new Error("Inspector does not offer a URL");
}
public getInspector() {
return this.inspector;
}
abstract start(...args: unknown[]): Promise<boolean>;
/**
* 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);
}
/**
* 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 && !debugSilentEvents.has(event)) {
console.log(this.#threadId, event, ...args);
}
let sent = super.emit(event, ...(args as any));
if (!(event in this)) {
return sent;
}
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));
});
}
return sent;
}
protected emitAdapterEvent<E extends keyof DAP.EventMap>(event: E, body?: DAP.EventMap[E]): void {
this.emit("Adapter.event", {
type: "event",
seq: 0,
event,
body,
});
}
#emitAfterResponse<E extends keyof DAP.EventMap>(event: E, body?: DAP.EventMap[E]): void {
this.once("Adapter.response", () => {
process.nextTick(() => {
this.emitAdapterEvent(event, body);
});
});
}
#reverseRequest<T extends keyof DAP.RequestMap>(command: T, args?: DAP.RequestMap[T]): void {
this.emit("Adapter.reverseRequest", {
type: "request",
seq: 0,
command,
arguments: args,
});
}
async ["Adapter.request"](request: DAP.Request): Promise<void> {
const { command, arguments: args } = request;
if (!(command in this)) {
return;
}
let timerId: number | undefined;
let result: unknown;
try {
result = await Promise.race([
// @ts-ignore
this[command](args),
new Promise((_, reject) => {
timerId = +setTimeout(() => reject(new Error(`Timed out: ${command}`)), 15_000);
}),
]);
} catch (cause) {
if (cause === Cancel) {
this.emit("Adapter.response", {
type: "response",
command,
success: false,
message: "cancelled",
request_seq: request.seq,
seq: 0,
});
return;
}
const error = unknownToError(cause);
this.emit("Adapter.error", error);
const { message } = error;
this.emit("Adapter.response", {
type: "response",
command,
success: false,
message,
request_seq: request.seq,
seq: 0,
});
return;
} finally {
if (timerId) {
clearTimeout(timerId);
}
}
this.emit("Adapter.response", {
type: "response",
command,
success: true,
request_seq: request.seq,
seq: 0,
body: result,
});
}
["Adapter.event"](event: DAP.Event): void {
const { event: name, body } = event;
this.emit(`Adapter.${name}` as keyof DebugAdapterEventMap, body);
}
public initialize(request: InitializeRequest): DAP.InitializeResponse {
this.#initialized = request;
this.send("Inspector.enable");
this.send("Runtime.enable");
if (request.enableConsole ?? true) {
this.send("Console.enable");
}
if (request.enableControlFlowProfiler) {
this.send("Runtime.enableControlFlowProfiler");
}
if (request.enableLifecycleAgentReporter) {
this.send("LifecycleReporter.enable");
if (request.sendImmediatePreventExit) {
this.send("LifecycleReporter.preventExit");
}
}
if (request.enableTestReporter) {
this.send("TestReporter.enable");
}
// use !== false because by default if unspecified we want to enable the debugger
// and this option didn't exist beforehand, so we can't make it non-optional
if (request.enableDebugger !== false) {
this.send("Debugger.enable").catch(error => {
const { message } = unknownToError(error);
if (message !== "Debugger domain already enabled") {
throw error;
}
});
this.send("Debugger.setAsyncStackTraceDepth", { depth: 200 });
}
const { clientID, supportsConfigurationDoneRequest } = request;
if (!supportsConfigurationDoneRequest && clientID !== "vscode") {
this.configurationDone();
}
// Tell the client what capabilities this adapter supports.
return capabilities;
}
configurationDone(): void {
// If the client requested that `noDebug` mode be enabled,
// then we need to disable all breakpoints and pause on statements.
const active = !this.options?.noDebug;
this.send("Debugger.setBreakpointsActive", { active });
// Tell the debugger that its ready to start execution.
this.send("Inspector.initialized");
}
// Required so all implementations have a method that .terminate() always calls.
// This is useful because we don't want any implementors to forget
protected abstract exitJSProcess(): void;
terminate(): void {
this.exitJSProcess();
this.emitAdapterEvent("terminated");
}
disconnect(request: DAP.DisconnectRequest): void {
const { terminateDebuggee } = request;
if (terminateDebuggee) {
this.terminate();
}
this.close();
}
async source(request: DAP.SourceRequest): Promise<DAP.SourceResponse> {
const { source } = request;
const { scriptId } = await this.#getSource(sourceToId(source));
const { scriptSource } = await this.send("Debugger.getScriptSource", { scriptId });
return {
content: scriptSource,
};
}
async threads(): Promise<DAP.ThreadsResponse> {
return {
threads: [
{
id: this.#threadId,
name: "Main Thread",
},
],
};
}
async pause(): Promise<void> {
await this.send("Debugger.pause");
this.#stopped = "pause";
}
async continue(): Promise<void> {
await this.send("Debugger.resume");
this.#stopped = undefined;
}
async next(): Promise<void> {
await this.send("Debugger.stepNext");
this.#stopped = "step";
}
async stepIn(): Promise<void> {
await this.send("Debugger.stepInto");
this.#stopped = "step";
}
async stepOut(): Promise<void> {
await this.send("Debugger.stepOut");
this.#stopped = "step";
}
async breakpointLocations(request: DAP.BreakpointLocationsRequest): Promise<DAP.BreakpointLocationsResponse> {
const { line, endLine, column, endColumn, source: source0 } = request;
if (process.platform === "win32") {
source0.path = source0.path ? normalizeWindowsPath(source0.path) : source0.path;
}
const source = await this.#getSource(sourceToId(source0));
const { locations } = await this.send("Debugger.getBreakpointLocations", {
start: this.#generatedLocation(source, line, column),
end: this.#generatedLocation(source, endLine ?? line + 1, endColumn),
});
return {
breakpoints: locations.map(location => this.#originalLocation(source, location)),
};
}
#generatedLocation(source: Source, line?: number, column?: number): JSC.Debugger.Location {
const { sourceMap, scriptId, path } = source;
const { line: gline, column: gcolumn } = sourceMap.generatedLocation({
line: this.#lineTo0BasedLine(line),
column: this.#columnTo0BasedColumn(column),
url: path,
});
return {
scriptId,
lineNumber: gline,
columnNumber: gcolumn,
};
}
#lineTo0BasedLine(line?: number): number {
if (!numberIsValid(line)) {
return 0;
}
if (!this.#initialized?.linesStartAt1) {
return line;
}
return line - 1;
}
#columnTo0BasedColumn(column?: number): number {
if (!numberIsValid(column)) {
return 0;
}
if (!this.#initialized?.columnsStartAt1) {
return column;
}
return column - 1;
}
#originalLocation(
source: Source,
line?: number | JSC.Debugger.Location,
column?: number,
): { line: number; column: number } {
if (typeof line === "object") {
const { lineNumber, columnNumber } = line;
line = lineNumber;
column = columnNumber;
}
const { sourceMap } = source;
const { line: oline, column: ocolumn } = sourceMap.originalLocation({ line, column });
return {
line: this.#lineFrom0BasedLine(oline),
// For now, remove the column from locations because
// it can be inaccurate and causes weird rendering issues in VSCode.
column: this.#columnFrom0BasedColumn(0), // ocolumn
};
}
#lineFrom0BasedLine(line?: number): number {
if (!this.#initialized?.linesStartAt1) {
return numberIsValid(line) ? line : 0;
}
return numberIsValid(line) ? line + 1 : 1;
}
#columnFrom0BasedColumn(column?: number): number {
if (!this.#initialized?.columnsStartAt1) {
return numberIsValid(column) ? column : 0;
}
return numberIsValid(column) ? column + 1 : 1;
}
async setBreakpoints(request: DAP.SetBreakpointsRequest): Promise<DAP.SetBreakpointsResponse> {
const { source, breakpoints: requests = [] } = request;
const { path, sourceReference } = source;
let breakpoints: Breakpoint[] | undefined;
if (path) {
breakpoints = await this.#setBreakpointsByUrl(path, requests, true);
} else if (sourceReference) {
const source = this.#getSourceIfPresent(sourceReference);
if (source) {
const { scriptId } = source;
breakpoints = await this.#setBreakpointsById(scriptId, requests, true);
}
}
return {
breakpoints: breakpoints ?? [],
};
}
async #setBreakpointsByUrl(url: string, requests: DAP.SourceBreakpoint[], unsetOld?: boolean): Promise<Breakpoint[]> {
if (process.platform === "win32") {
url = url ? normalizeWindowsPath(url) : url;
}
const source = this.#getSourceIfPresent(url);
// If the source is not loaded, set a placeholder breakpoint at the start of the file.
// If the breakpoint is resolved in the future, a `Debugger.breakpointResolved` event
// will be emitted and each of these breakpoint requests can be retried.
if (!source) {
let result;
try {
result = await this.send("Debugger.setBreakpointByUrl", {
url,
lineNumber: 0,
});
} catch (error) {
return requests.map(() => invalidBreakpoint(error));
}
const { breakpointId, locations } = result;
if (locations.length) {
// TODO: Source was loaded while the breakpoint was being set?
}
return requests.map(request =>
this.#addFutureBreakpoint({
breakpointId,
url,
breakpoint: request,
}),
);
}
const oldBreakpoints = this.#getBreakpoints(sourceToId(source));
const breakpoints = await Promise.all(
requests.map(async request => {
const oldBreakpoint = this.#getBreakpointByLocation(source, request);
if (oldBreakpoint) {
return oldBreakpoint;
}
const { line, column, ...options } = request;
const location = this.#generatedLocation(source, line, column);
let result;
try {
result = await this.send("Debugger.setBreakpointByUrl", {
url,
...location,
options: breakpointOptions(options),
});
} catch (error) {
return invalidBreakpoint(error);
}
const { breakpointId, locations } = result;
const breakpoints = locations.map((location, i) =>
this.#addBreakpoint({
breakpointId,
location,
source,
request,
// It is theoretically possible for a breakpoint to resolve to multiple locations.
// In that case, send a separate `breakpoint` event for each one, excluding the first.
notify: i > 0,
}),
);
// Each breakpoint request can only be mapped to one breakpoint.
return breakpoints[0];
}),
);
if (unsetOld) {
await Promise.all(
oldBreakpoints.map(({ breakpointId }) => {
if (!breakpoints.some(({ breakpointId: id }) => breakpointId === id)) {
return this.#unsetBreakpoint(breakpointId);
}
}),
);
}
return breakpoints;
}
async #setBreakpointsById(
scriptId: string,
requests: DAP.SourceBreakpoint[],
unsetOld?: boolean,
): Promise<Breakpoint[]> {
const source = await this.#getSourceById(scriptId);
if (!source) {
return requests.map(() => invalidBreakpoint());
}
const oldBreakpoints = this.#getBreakpoints(sourceToId(source));
const breakpoints = await Promise.all(
requests.map(async request => {
const oldBreakpoint = this.#getBreakpointByLocation(source, request);
if (oldBreakpoint) {
return oldBreakpoint;
}
const { line, column, ...options } = request;
const location = this.#generatedLocation(source, line, column);
let result;
try {
result = await this.send("Debugger.setBreakpoint", {
location,
options: breakpointOptions(options),
});
} catch (error) {
return invalidBreakpoint(error);
}
const { breakpointId, actualLocation } = result;
return this.#addBreakpoint({
breakpointId,
location: actualLocation,
request,
source,
});
}),
);
if (unsetOld) {
await Promise.all(
oldBreakpoints.map(({ breakpointId }) => {
if (!breakpoints.some(({ breakpointId: id }) => breakpointId === id)) {
return this.#unsetBreakpoint(breakpointId);
}
}),
);
}
return breakpoints;
}
async #unsetBreakpoint(breakpointId: string): Promise<void> {
try {
await this.send("Debugger.removeBreakpoint", { breakpointId });
} catch {
// Ignore any errors.
}
this.#removeBreakpoint(breakpointId);
this.#removeFutureBreakpoint(breakpointId);
}
#addBreakpoint(options: {
breakpointId: string;
location?: JSC.Debugger.Location;
request?: DAP.SourceBreakpoint;
source?: Source;
notify?: boolean;
}): Breakpoint {
const { breakpointId, location, source, request, notify } = options;
let originalLocation;
if (source) {
originalLocation = this.#originalLocation(source, location);
} else {
originalLocation = {};
}
const breakpoints = this.#getBreakpointsById(breakpointId);
const breakpoint: Breakpoint = {
id: nextId(),
breakpointId,
source,
request,
...originalLocation,
verified: !!source,
};
breakpoints.push(breakpoint);
return breakpoint;
}
#addFutureBreakpoint(options: { breakpointId: string; url: string; breakpoint: DAP.SourceBreakpoint }): Breakpoint {
const { breakpointId, url, breakpoint } = options;
const breakpoints = this.#getFutureBreakpoints(breakpointId);
breakpoints.push({
url,
breakpoint,
});
return this.#addBreakpoint({
breakpointId,
request: breakpoint,
});
}
#removeBreakpoint(breakpointId: string, notify?: boolean): void {
const breakpoints = this.#breakpoints.get(breakpointId);
if (!breakpoints || !this.#breakpoints.delete(breakpointId) || !notify) {
return;
}
for (const breakpoint of breakpoints) {
this.emitAdapterEvent("breakpoint", {
reason: "removed",
breakpoint,
});
}
}
#removeFutureBreakpoint(breakpointId: string, notify?: boolean): void {
const breakpoint = this.#futureBreakpoints.get(breakpointId);
if (!breakpoint || !this.#futureBreakpoints.delete(breakpointId)) {
return;
}
this.#removeBreakpoint(breakpointId, notify);
}
#getBreakpointsById(breakpointId: string): Breakpoint[] {
let breakpoints = this.#breakpoints.get(breakpointId);
if (!breakpoints) {
this.#breakpoints.set(breakpointId, (breakpoints = []));
}
return breakpoints;
}
#getBreakpointByLocation(source: Source, location: DAP.SourceBreakpoint): Breakpoint | undefined {
if (isDebug) {
console.log("getBreakpointByLocation", {
source: sourceToId(source),
location,
ids: this.#getBreakpoints(sourceToId(source)).map(({ id }) => id),
breakpointIds: this.#getBreakpoints(sourceToId(source)).map(({ breakpointId }) => breakpointId),
lines: this.#getBreakpoints(sourceToId(source)).map(({ line }) => line),
columns: this.#getBreakpoints(sourceToId(source)).map(({ column }) => column),
});
}
let sourceId = sourceToId(source);
const untitledDocPath = this.#untitledDocPath;
if (sourceId === untitledDocPath && this.#bunEvalPath) {
sourceId = this.#bunEvalPath;
}
const [breakpoint] = this.#getBreakpoints(sourceId).filter(
({ source, request }) => source && sourceToId(source) === sourceId && request?.line === location.line,
);
return breakpoint;
}
#getBreakpoints(sourceId: string | number): Breakpoint[] {
let output = [];
let all = this.#breakpoints;
for (const breakpoints of all.values()) {
for (const breakpoint of breakpoints) {
const source = breakpoint.source;
if (source && sourceToId(source) === sourceId) {
output.push(breakpoint);
}
}
}
return output;
}
#getFutureBreakpoints(breakpointId: string): FutureBreakpoint[] {
let breakpoints = this.#futureBreakpoints.get(breakpointId);
if (!breakpoints) {
this.#futureBreakpoints.set(breakpointId, (breakpoints = []));
}
return breakpoints;
}
async setFunctionBreakpoints(
request: DAP.SetFunctionBreakpointsRequest,
): Promise<DAP.SetFunctionBreakpointsResponse> {
const { breakpoints: requests } = request;
const oldBreakpoints = this.#getFunctionBreakpoints();
const breakpoints = await Promise.all(
requests.map(async ({ name, ...options }) => {
const breakpoint = this.#getFunctionBreakpoint(name);
if (breakpoint) {
return breakpoint;
}
try {
await this.send("Debugger.addSymbolicBreakpoint", {
symbol: name,
caseSensitive: true,
isRegex: false,
options: breakpointOptions(options),
});
} catch (error) {
const { message } = unknownToError(error);
return this.#addFunctionBreakpoint({
id: nextId(),
name,
verified: false,
message,
});
}
return this.#addFunctionBreakpoint({
id: nextId(),
name,
verified: true,
});
}),
);
await Promise.all(
oldBreakpoints.map(async ({ name }) => {
const isRemoved = !breakpoints.filter(({ name: n }) => name === n).length;
if (isRemoved) {
await this.send("Debugger.removeSymbolicBreakpoint", {
symbol: name,
caseSensitive: true,
isRegex: false,
});
this.#removeFunctionBreakpoint(name);
}
}),
);
return {
breakpoints,
};
}
#getFunctionBreakpoints(): FunctionBreakpoint[] {
return [...this.#functionBreakpoints.values()];
}
#getFunctionBreakpoint(name: string): FunctionBreakpoint | undefined {
return this.#functionBreakpoints.get(name);
}
#addFunctionBreakpoint(breakpoint: FunctionBreakpoint): FunctionBreakpoint {
const { name } = breakpoint;
this.#functionBreakpoints.set(name, breakpoint);
return breakpoint;
}
#removeFunctionBreakpoint(name: string): void {
const breakpoint = this.#functionBreakpoints.get(name);
if (!breakpoint || !this.#functionBreakpoints.delete(name)) {
return;
}
this.#emitAfterResponse("breakpoint", {
reason: "removed",
breakpoint,
});
}
async setExceptionBreakpoints(
request: DAP.SetExceptionBreakpointsRequest,
): Promise<DAP.SetExceptionBreakpointsResponse> {
const { filters } = request;
let state: "all" | "uncaught" | "none";
if (filters.includes("all")) {
state = "all";
} else if (filters.includes("uncaught")) {
state = "uncaught";
} else {
state = "none";
}
await Promise.all([
this.send("Debugger.setPauseOnExceptions", { state }),
this.send("Debugger.setPauseOnAssertions", {
enabled: filters.includes("assert"),
}),
this.send("Debugger.setPauseOnDebuggerStatements", {
enabled: filters.includes("debugger"),
}),
this.send("Debugger.setPauseOnMicrotasks", {
enabled: filters.includes("microtask"),
}),
]);
return {
breakpoints: [],
};
}
async gotoTargets(request: DAP.GotoTargetsRequest): Promise<DAP.GotoTargetsResponse> {
const { source: source0 } = request;
if (process.platform === "win32") {
source0.path = source0.path ? normalizeWindowsPath(source0.path) : source0.path;
}
const source = await this.#getSource(sourceToId(source0));
const { breakpoints } = await this.breakpointLocations(request);
const targets = breakpoints.map(({ line, column }) =>
this.#addTarget({
id: this.#targets.size,
label: `${line}:${column}`,
source,
line,
column,
}),
);
return {
targets,
};
}
#addTarget<T extends DAP.GotoTarget | DAP.StepInTarget>(target: T & { source: Source }): T {
const { id } = target;
this.#targets.set(id, target);
return target;
}
#getTarget(targetId: number): Target | undefined {
return this.#targets.get(targetId);
}
async goto(request: DAP.GotoRequest): Promise<void> {
const { targetId } = request;
const target = this.#getTarget(targetId);
if (!target) {
throw new Error("No target found.");
}
const { source, line, column } = target;
const location = this.#generatedLocation(source, line, column);
await this.send("Debugger.continueToLocation", {
location,
});
}
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.evaluateInternal({
expression,
objectGroup,
callFrameId,
});
if (wasThrown) {
if (context === "hover" && isSyntaxError(result)) {
throw Cancel;
}
throw new Error(remoteObjectToString(result));
}
const { name, value, ...variable } = this.#addObject(result, { objectGroup });
return {
...variable,
result: value,
};
}
protected async evaluateInternal(options: {
expression: string;
objectGroup?: string;
callFrameId?: string;
}): Promise<JSC.Runtime.EvaluateResponse> {
const { expression, objectGroup, callFrameId } = options;
const method = callFrameId ? "Debugger.evaluateOnCallFrame" : "Runtime.evaluate";
return this.send(method, {
callFrameId,
objectGroup,
expression: sanitizeExpression(expression),
generatePreview: true,
emulateUserGesture: true,
doNotPauseOnExceptionsAndMuteConsole: true,
includeCommandLineAPI: true,
});
}
async completions(request: DAP.CompletionsRequest): Promise<DAP.CompletionsResponse> {
const { text, column, frameId } = request;
const callFrameId = this.#getCallFrameId(frameId);
const { expression, hint } = completionToExpression(text);
const { result, wasThrown } = await this.evaluateInternal({
expression: expression || "this",
callFrameId,
objectGroup: "repl",
});
if (wasThrown) {
if (isSyntaxError(result)) {
return {
targets: [],
};
}
throw new Error(remoteObjectToString(result));
}
const variable = this.#addObject(result, {
objectGroup: "repl",
evaluateName: expression,
});
const properties = await this.#getProperties(variable);
const targets = properties
.filter(({ name }) => isIdentifier(name) && (!hint || name.includes(hint)))
.sort(variablesSortBy)
.map(variableToCompletionItem);
return {
targets,
};
}
["Inspector.connected"](): void {
this.emitAdapterEvent("output", {
category: "debug console",
output: "Debugger attached.\n",
});
this.emitAdapterEvent("initialized");
}
async ["Inspector.disconnected"](error?: Error): Promise<void> {
this.emitAdapterEvent("output", {
category: "debug console",
output: "Debugger detached.\n",
});
if (error) {
const { message } = error;
this.emitAdapterEvent("output", {
category: "stderr",
output: `${message}\n`,
});
}
this.resetInternal();
}
async ["Debugger.scriptParsed"](event: JSC.Debugger.ScriptParsedEvent): Promise<void> {
const { url, scriptId, sourceMapURL } = event;
// If no url is present, the script is from a `evaluate` request.
if (!url) {
return;
}
// Sources can be retrieved in two ways:
// 1. If it has a `path`, the client retrieves the source from the file system.
// 2. If it has a `sourceReference`, the client sends a `source` request.
// Moreover, the code is usually shown in a read-only editor.
const isUserCode = path.isAbsolute(url);
const sourceMap = SourceMap(sourceMapURL);
const name = sourceName(url);
const presentationHint = sourcePresentationHint(url);
if (isUserCode) {
this.#addSource({
sourceId: url,
scriptId,
name,
path: url,
presentationHint,
sourceMap,
});
return;
}
const sourceReference = this.#sourceId++;
this.#addSource({
sourceId: sourceReference,
scriptId,
name,
sourceReference,
presentationHint,
sourceMap,
});
}
["Debugger.scriptFailedToParse"](event: JSC.Debugger.ScriptFailedToParseEvent): void {
const { url, errorMessage, errorLine } = event;
// If no url is present, the script is from an `evaluate` request.
if (!url) {
return;
}
this.emitAdapterEvent("output", {
category: "stderr",
output: errorMessage,
line: this.#lineFrom0BasedLine(errorLine),
source: {
path: url || undefined,
},
});
}
async ["Debugger.breakpointResolved"](event: JSC.Debugger.BreakpointResolvedEvent): Promise<void> {
const { breakpointId, location } = event;
const futureBreakpoints = this.#getFutureBreakpoints(breakpointId);
// If the breakpoint resolves to a placeholder breakpoint, go through
// each breakpoint request and attempt to set them again.
if (futureBreakpoints?.length) {
const [{ url }] = futureBreakpoints;
const requests = futureBreakpoints.map(({ breakpoint }) => breakpoint);
const oldBreakpoints = this.#getBreakpointsById(breakpointId);
const breakpoints = await this.#setBreakpointsByUrl(url, requests);
for (let i = 0; i < breakpoints.length; i++) {
const breakpoint = breakpoints[i];
const oldBreakpoint = oldBreakpoints[i];
this.emitAdapterEvent("breakpoint", {
reason: "changed",
breakpoint: {
...breakpoint,
id: oldBreakpoint.id,
},
});
}
// Finally, remove the placeholder breakpoint.
await this.#unsetBreakpoint(breakpointId);
return;
}
const breakpoints = this.#getBreakpointsById(breakpointId);
// This is a new breakpoint, which was likely created by another client
// connected to the same debugger.
if (!breakpoints.length) {
const { scriptId } = location;
const [url] = breakpointId.split(":");
const source = await this.#getSourceById(scriptId, url);
this.#addBreakpoint({
breakpointId,
location,
source,
notify: true,
});
return;
}
// TODO: update breakpoints?
}
["Debugger.paused"](event: JSC.Debugger.PausedEvent): void {
const { reason, callFrames, asyncStackTrace, data } = event;
this.#stackFrames.length = 0;
this.#stopped ||= stoppedReason(reason);
for (const callFrame of callFrames) {
this.#addStackFrame(callFrame);
}
if (asyncStackTrace) {
this.#addAsyncStackTrace(asyncStackTrace);
}
// 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;
this.#exception = this.#addObject(remoteObject, { objectGroup: "debugger" });
}
if (reason === "FunctionCall") {
const { name } = data as { name: string };
const breakpoint = this.#getFunctionBreakpoint(name);
if (breakpoint) {
const { id } = breakpoint;
hitBreakpointIds = [id];
}
}
if (reason === "Breakpoint") {
const { breakpointId } = data as JSC.Debugger.BreakpointPauseReason;
const futureBreakpoints = this.#getFutureBreakpoints(breakpointId);
if (futureBreakpoints.length) {
this.send("Debugger.resume");
return;
}
const breakpoints = this.#getBreakpointsById(breakpointId);
if (breakpoints.length) {
hitBreakpointIds = breakpoints.map(({ id }) => id);
}
}
}
this.emitAdapterEvent("stopped", {
threadId: this.#threadId,
reason: this.#stopped,
hitBreakpointIds,
});
}
["Debugger.resumed"](): void {
this.#stackFrames.length = 0;
this.#stopped = undefined;
this.#exception = undefined;
for (const { variablesReference, objectGroup } of this.#variables.values()) {
if (objectGroup === "debugger") {
this.#variables.delete(variablesReference);
}
}
this.emitAdapterEvent("continued", {
threadId: this.#threadId,
});
}
["Process.stdout"](output: string): void {
this.emitAdapterEvent("output", {
category: "debug console",
output,
});
}
["Process.stderr"](output: string): void {
this.emitAdapterEvent("output", {
category: "debug console",
output,
});
}
["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 {
let { sourceId, scriptId, path } = source;
// Normalize the source path
if (path) {
path = source.path = normalizeSourcePath(path, this.#untitledDocPath, this.#bunEvalPath);
}
const oldSource = this.#getSourceIfPresent(sourceId);
if (oldSource) {
const { scriptId, path: oldPath } = oldSource;
// For now, the script ID will always change.
// Could that not be the case in the future?
this.#sources.delete(scriptId);
// If the path changed or the source has a source reference,
// the old source should be marked as removed.
if (path !== oldPath /*|| sourceReference*/) {
this.emitAdapterEvent("loadedSource", {
reason: "removed",
source: oldSource,
});
}
}
this.#sources.set(sourceId, source);
this.#sources.set(scriptId, source);
this.emitAdapterEvent("loadedSource", {
// If the reason is "changed", the source will be retrieved using
// the `source` command, which is why it cannot be set when `path` is present.
reason: oldSource && !path ? "changed" : "new",
source,
});
if (!path) {
return source;
}
// If there are any pending requests for this source by its path,
// resolve them now that the source has been loaded.
const resolves = this.#pendingSources.get(path);
if (resolves) {
this.#pendingSources.delete(path);
for (const resolve of resolves) {
resolve(source);
}
}
return source;
}
loadedSources(): DAP.LoadedSourcesResponse {
const sources = new Map();
// Since there are duplicate keys for each source,
// (e.g. scriptId, path, sourceReference, etc.) it needs to be deduped.
for (const source of this.#sources.values()) {
const { sourceId } = source;
sources.set(sourceId, source);
}
return {
sources: [...sources.values()],
};
}
#getSourceIfPresent(sourceId: string | number): Source | undefined {
return this.#sources.get(sourceId);
}
async #getSource(sourceId: string | number): Promise<Source> {
const source = this.#getSourceIfPresent(sourceId);
if (source) {
return source;
}
// Normalize the source path before lookup
if (typeof sourceId === "string") {
sourceId = normalizeSourcePath(sourceId, this.#untitledDocPath, this.#bunEvalPath);
}
// If the source is not present, it may not have been loaded yet.
let resolves = this.#pendingSources.get(sourceId.toString());
if (!resolves) {
this.#pendingSources.set(sourceId.toString(), (resolves = []));
}
return new Promise(resolve => {
resolves!.push(resolve);
});
}
async #getSourceById(scriptId: string, url?: string): Promise<Source | undefined> {
const source = this.#getSourceIfPresent(scriptId);
if (source) {
return source;
}
let result;
try {
result = await this.send("Debugger.getScriptSource", { scriptId });
} catch {
return undefined;
}
const { scriptSource } = result;
const sourceMap = SourceMap(scriptSource);
const presentationHint = sourcePresentationHint(url);
if (url) {
return this.#addSource({
scriptId,
sourceId: url,
name: sourceName(url),
path: url,
sourceMap,
presentationHint,
});
}
const sourceReference = this.#sourceId++;
return this.#addSource({
scriptId,
sourceId: sourceReference,
sourceReference,
sourceMap,
presentationHint,
});
}
async stackTrace(request: DAP.StackTraceRequest): Promise<DAP.StackTraceResponse> {
const { length } = this.#stackFrames;
const { startFrame = 0, levels } = request;
const endFrame = levels ? startFrame + levels : length;
return {
totalFrames: length,
stackFrames: this.#stackFrames.slice(startFrame, endFrame),
};
}
async scopes(request: DAP.ScopesRequest): Promise<DAP.ScopesResponse> {
const { frameId } = request;
for (const stackFrame of this.#stackFrames) {
const { id, scopes } = stackFrame;
if (id !== frameId || !scopes) {
continue;
}
return {
scopes,
};
}
return {
scopes: [],
};
}
#getCallFrameId(frameId?: number): string | undefined {
for (const { id, callFrameId } of this.#stackFrames) {
if (id === frameId) {
return callFrameId;
}
}
return undefined;
}
#addStackFrame(callFrame: JSC.Debugger.CallFrame): StackFrame {
const { callFrameId, functionName, location, scopeChain, this: thisObject } = callFrame;
const { scriptId } = location;
const source = this.#getSourceIfPresent(scriptId);
let originalLocation: Location;
if (source) {
originalLocation = this.#originalLocation(source, location);
} else {
const { lineNumber, columnNumber } = location;
originalLocation = {
line: this.#lineFrom0BasedLine(lineNumber),
column: this.#columnFrom0BasedColumn(columnNumber),
};
}
const { line, column } = originalLocation;
const scopes: Scope[] = [];
const stackFrame: StackFrame = {
callFrameId,
scriptId,
id: this.#stackFrames.length,
name: functionName || "<anonymous>",
line,
column,
presentationHint: stackFramePresentationHint(source?.path),
source,
scopes,
};
this.#stackFrames.push(stackFrame);
for (const scope of scopeChain) {
const { name, type, location, object, empty } = scope;
if (empty) {
continue;
}
const { variablesReference } = this.#addObject(object, { objectGroup: "debugger" });
const presentationHint = scopePresentationHint(type);
const title = presentationHint ? titleize(presentationHint) : "Unknown";
const displayName = name ? `${title}: ${name}` : title;
let originalLocation: Location | undefined;
if (location) {
const { scriptId } = location;
const source = this.#getSourceIfPresent(scriptId);
if (source) {
originalLocation = this.#originalLocation(source, location);
} else {
const { lineNumber, columnNumber } = location;
originalLocation = {
line: this.#lineFrom0BasedLine(lineNumber),
column: this.#columnFrom0BasedColumn(columnNumber),
};
}
}
const { line, column } = originalLocation ?? {};
scopes.push({
name: displayName,
presentationHint,
expensive: presentationHint === "globals",
variablesReference,
line,
column,
source,
});
}
return stackFrame;
}
#addAsyncStackTrace(stackTrace: JSC.Console.StackTrace): void {
const { callFrames, parentStackTrace } = stackTrace;
for (const callFrame of callFrames) {
this.#addAsyncStackFrame(callFrame);
}
if (parentStackTrace) {
this.#addAsyncStackTrace(parentStackTrace);
}
}
#addAsyncStackFrame(callFrame: JSC.Console.CallFrame): StackFrame {
const { scriptId, functionName } = callFrame;
const callFrameId = callFrameToId(callFrame);
const source = this.#getSourceIfPresent(scriptId);
let originalLocation: Location;
if (source) {
originalLocation = this.#originalLocation(source, callFrame);
} else {
const { lineNumber, columnNumber } = callFrame;
originalLocation = {
line: this.#lineFrom0BasedLine(lineNumber),
column: this.#columnFrom0BasedColumn(columnNumber),
};
}
const { line, column } = originalLocation;
const stackFrame: StackFrame = {
callFrameId,
scriptId,
id: this.#stackFrames.length,
name: functionName || "<anonymous>",
line,
column,
source,
presentationHint: stackFramePresentationHint(source?.path),
canRestart: false,
};
this.#stackFrames.push(stackFrame);
return stackFrame;
}
async variables(request: DAP.VariablesRequest): Promise<DAP.VariablesResponse> {
const { variablesReference, start, count } = request;
const variable = this.#getVariable(variablesReference);
let variables: Variable[];
if (!variable) {
variables = [];
} else if (Array.isArray(variable)) {
variables = variable;
} else {
variables = await this.#getProperties(variable, start, count);
}
return {
variables: variables.sort(variablesSortBy),
};
}
async setVariable(request: DAP.SetVariableRequest): Promise<DAP.SetVariableResponse> {
const { variablesReference, name, value } = request;
const variable = this.#getVariable(variablesReference);
if (!variable) {
throw new Error("Variable not found.");
}
const { objectId, objectGroup } = variable;
if (!objectId) {
throw new Error("Variable cannot be modified.");
}
const { result, wasThrown } = await this.send("Runtime.callFunctionOn", {
objectId,
functionDeclaration: `function (name) { this[name] = ${value}; return this[name]; }`,
arguments: [{ value: name }],
doNotPauseOnExceptionsAndMuteConsole: true,
});
if (wasThrown) {
throw new Error(remoteObjectToString(result));
}
return this.#addObject(result, { name, objectGroup });
}
async setExpression(request: DAP.SetExpressionRequest): Promise<DAP.SetExpressionResponse> {
const { expression, value, frameId } = request;
const callFrameId = this.#getCallFrameId(frameId);
const objectGroup = callFrameId ? "debugger" : "repl";
const { result, wasThrown } = await this.evaluateInternal({
expression: `${expression} = (${value});`,
objectGroup: "repl",
callFrameId,
});
if (wasThrown) {
throw new Error(remoteObjectToString(result));
}
return this.#addObject(result, { objectGroup });
}
#getVariable(variablesReference?: number): Variable | undefined {
if (!variablesReference) {
return undefined;
}
return this.#variables.get(variablesReference);
}
#addObject(
remoteObject: JSC.Runtime.RemoteObject,
propertyDescriptor?: Partial<JSC.Runtime.PropertyDescriptor> & { objectGroup?: string; evaluateName?: string },
): Variable {
const { objectId, type, subtype, size } = remoteObject;
const { objectGroup, evaluateName } = propertyDescriptor ?? {};
const variablesReference = objectId ? this.#variableId++ : 0;
const variable: Variable = {
objectId,
objectGroup,
variablesReference,
type: subtype || type,
value: remoteObjectToString(remoteObject),
name: propertyDescriptorToName(propertyDescriptor),
evaluateName: propertyDescriptorToEvaluateName(propertyDescriptor, evaluateName),
indexedVariables: isArrayLike(subtype) ? size : undefined,
namedVariables: isMap(subtype) ? size : undefined,
presentationHint: remoteObjectToVariablePresentationHint(remoteObject, propertyDescriptor),
};
if (variablesReference) {
this.#variables.set(variablesReference, variable);
}
return variable;
}
async #getProperties(variable: Variable, offset?: number, count?: number): Promise<Variable[]> {
const { objectId, objectGroup, type, evaluateName, indexedVariables, namedVariables } = variable;
const variables: Variable[] = [];
if (!objectId || type === "symbol") {
return variables;
}
const { properties, internalProperties } = await this.send("Runtime.getDisplayableProperties", {
objectId,
generatePreview: true,
});
for (const property of properties) {
variables.push(...this.#addProperty(property, { objectGroup, evaluateName, parentType: type }));
}
if (internalProperties) {
for (const property of internalProperties) {
variables.push(
...this.#addProperty(property, { objectGroup, evaluateName, parentType: type, isSynthetic: true }),
);
}
}
const hasEntries = type !== "array" && (indexedVariables || namedVariables);
if (hasEntries) {
const { entries } = await this.send("Runtime.getCollectionEntries", {
objectId,
fetchStart: offset,
fetchCount: count,
});
let i = 0;
for (const { key, value } of entries) {
let name = String(i++);
if (key) {
const { value, description } = key;
name = String(value ?? description);
}
variables.push(
...this.#addProperty(
{ name, value },
{
objectGroup,
evaluateName,
parentType: type,
isSynthetic: true,
},
),
);
}
}
return variables;
}
#addProperty(
propertyDescriptor: JSC.Runtime.PropertyDescriptor | JSC.Runtime.InternalPropertyDescriptor,
options?: {
objectGroup?: string;
evaluateName?: string;
isSynthetic?: boolean;
parentType?: JSC.Runtime.RemoteObject["type"] | JSC.Runtime.RemoteObject["subtype"];
},
): Variable[] {
const { value, get, set, symbol } = propertyDescriptor as JSC.Runtime.PropertyDescriptor;
const descriptor = { ...propertyDescriptor, ...options };
const variables: Variable[] = [];
if (value) {
variables.push(this.#addObject(value, descriptor));
}
if (get) {
const { type } = get;
if (type !== "undefined") {
variables.push(this.#addObject(get, descriptor));
}
}
if (set) {
const { type } = set;
if (type !== "undefined") {
variables.push(this.#addObject(set, descriptor));
}
}
if (symbol) {
variables.push(this.#addObject(symbol, descriptor));
}
return variables;
}
async exceptionInfo(): Promise<DAP.ExceptionInfoResponse> {
const exception = this.#exception;
if (!exception) {
throw new Error("No exception found.");
}
const { code, ...details } = await this.#getExceptionDetails(exception);
return {
exceptionId: code || "",
breakMode: "always",
details,
};
}
async #getExceptionDetails(variable: Variable): Promise<DAP.ExceptionDetails & { code?: string }> {
const properties = await this.#getProperties(variable);
let fullTypeName: string | undefined;
let message: string | undefined;
let code: string | undefined;
let stackTrace: string | undefined;
let innerException: DAP.ExceptionDetails[] | undefined;
for (const property of properties) {
const { name, value, type } = property;
if (name === "name") {
fullTypeName = value;
} else if (name === "message") {
message = type === "string" ? JSON.parse(value) : value;
} else if (name === "stack") {
stackTrace = type === "string" ? JSON.parse(value) : value;
} else if (name === "code") {
code = type === "string" ? JSON.parse(value) : value;
} else if (name === "cause") {
const cause = await this.#getExceptionDetails(property);
innerException = [cause];
} else if (name === "errors") {
const errors = await this.#getProperties(property);
innerException = await Promise.all(errors.map(error => this.#getExceptionDetails(error)));
}
}
if (!stackTrace) {
const { value } = variable;
stackTrace ||= value;
}
return {
fullTypeName,
message,
code,
stackTrace: stripAnsi(stackTrace),
innerException,
};
}
close(): void {
this.inspector.close();
this.resetInternal();
}
protected resetInternal(): void {
this.#pendingSources.clear();
this.#sources.clear();
this.#stackFrames.length = 0;
this.#stopped = undefined;
this.#exception = undefined;
this.#breakpoints.clear();
this.#futureBreakpoints.clear();
this.#functionBreakpoints.clear();
this.#targets.clear();
this.#variables.clear();
this.options = undefined;
}
}
/**
* Create a debug adapter that connects over a unix/tcp socket. Usually
* in the case of a reverse connection. This is used by the vscode extension.
*
* @warning This will gracefully handle socket closure, you don't need to add extra handling.
*/
export class NodeSocketDebugAdapter extends BaseDebugAdapter<NodeSocketInspector> {
public constructor(socket: Socket, untitledDocPath?: string, bunEvalPath?: string) {
super(new NodeSocketInspector(socket), untitledDocPath, bunEvalPath);
socket.once("close", () => {
this.resetInternal();
});
}
protected exitJSProcess(): void {
this.evaluateInternal({
expression: "process.exit(0)",
});
}
public async start() {
const ok = await this.inspector.start();
return ok;
}
}
/**
* The default debug adapter. Connects via WebSocket
*/
export class WebSocketDebugAdapter extends BaseDebugAdapter<WebSocketInspector> {
#process?: ChildProcess;
public constructor(url?: string | URL, untitledDocPath?: string, bunEvalPath?: string) {
super(new WebSocketInspector(url), untitledDocPath, bunEvalPath);
}
async ["Inspector.disconnected"](error?: Error): Promise<void> {
await super["Inspector.disconnected"](error);
if (this.#process?.exitCode !== null) {
this.emitAdapterEvent("terminated");
}
}
protected exitJSProcess() {
if (!this.#process?.kill()) {
this.evaluateInternal({
expression: "process.exit(0)",
});
}
}
/**
* Starts the inspector.
* @param url the inspector url, will default to the one provided in the constructor (if any). If none
* @returns if the inspector was able to connect
*/
start(url?: string): Promise<boolean> {
return this.#attach({ url });
}
close() {
this.#process?.kill();
super.close();
}
async launch(request: DAP.LaunchRequest): Promise<void> {
this.options = { ...request, type: "launch" };
try {
await this.#launch(request);
} 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.
const { message } = unknownToError(error);
this.emitAdapterEvent("output", {
category: "stderr",
output: `Failed to start debugger.\n${message}`,
});
this.terminate();
}
}
async #launch(request: LaunchRequest): Promise<void> {
const {
runtime = "bun",
runtimeArgs = [],
program,
args = [],
cwd,
env = {},
strictEnv = false,
watchMode = false,
stopOnEntry = false,
__skipValidation = false,
stdin,
} = request;
if (!__skipValidation && !program) {
throw new Error("No program specified");
}
const processArgs = [...runtimeArgs];
if (program === "-" && stdin) {
processArgs.push("--eval", stdin);
} else if (program) {
processArgs.push(program);
}
processArgs.push(...args);
if (program && isTestJavaScript(program) && !runtimeArgs.includes("test")) {
processArgs.unshift("test");
}
if (watchMode && !runtimeArgs.includes("--watch") && !runtimeArgs.includes("--hot")) {
processArgs.unshift(watchMode === "hot" ? "--hot" : "--watch");
}
const processEnv = strictEnv
? {
...env,
}
: {
...process.env,
...env,
};
if (process.platform !== "win32") {
// we're on unix
const url = `ws+unix://${randomUnixPath()}`;
const signal = new UnixSignal();
signal.on("Signal.received", () => {
this.#attach({ url });
});
this.once("Adapter.terminated", () => {
signal.close();
});
const query = stopOnEntry ? "break=1" : "wait=1";
processEnv["BUN_INSPECT"] = `${url}?${query}`;
processEnv["BUN_INSPECT_NOTIFY"] = signal.url;
// This is probably not correct, but it's the best we can do for now.
processEnv["FORCE_COLOR"] = "1";
processEnv["BUN_QUIET_DEBUG_LOGS"] = "1";
processEnv["BUN_DEBUG_QUIET_LOGS"] = "1";
const started = await this.#spawn({
command: runtime,
args: processArgs,
env: processEnv,
cwd,
isDebugee: true,
});
if (!started) {
throw new Error("Program could not be started.");
}
} else {
// we're on windows
// Create TCPSocketSignal
const url = `ws://127.0.0.1:${await getAvailablePort()}/${getRandomId()}`; // 127.0.0.1 so it resolves correctly on windows
const signal = new TCPSocketSignal(await getAvailablePort());
signal.on("Signal.received", async () => {
this.#attach({ url });
});
this.once("Adapter.terminated", () => {
signal.close();
});
const query = stopOnEntry ? "break=1" : "wait=1";
processEnv["BUN_INSPECT"] = `${url}?${query}`;
processEnv["BUN_INSPECT_NOTIFY"] = signal.url; // 127.0.0.1 so it resolves correctly on windows
// This is probably not correct, but it's the best we can do for now.
processEnv["FORCE_COLOR"] = "1";
processEnv["BUN_QUIET_DEBUG_LOGS"] = "1";
processEnv["BUN_DEBUG_QUIET_LOGS"] = "1";
const started = await this.#spawn({
command: runtime,
args: processArgs,
env: processEnv,
cwd,
isDebugee: true,
});
if (!started) {
throw new Error("Program could not be started.");
}
}
}
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;
}
subprocess.on("spawn", () => {
this.emit("Process.spawned", subprocess);
if (isDebugee) {
this.#process = subprocess;
this.emitAdapterEvent("process", {
name: `${command} ${args.join(" ")}`,
systemProcessId: subprocess.pid,
isLocalProcess: true,
startMethod: "launch",
});
}
});
subprocess.on("exit", (code, signal) => {
this.emit("Process.exited", code, signal);
if (isDebugee) {
this.#process = undefined;
this.emitAdapterEvent("exited", {
exitCode: code ?? -1,
});
this.emitAdapterEvent("terminated");
}
});
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: AttachRequest): Promise<void> {
this.options = { ...request, type: "attach" };
try {
await this.#attach(request);
} 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.
const { message } = unknownToError(error);
this.emitAdapterEvent("output", {
category: "stderr",
output: `Failed to start debugger.\n${message}`,
});
this.terminate();
}
}
async #attach(request: AttachRequest): Promise<boolean> {
const { url } = request;
for (let i = 0; i < 3; i++) {
const ok = await this.inspector.start(url);
if (ok) {
return true;
}
await new Promise(resolve => setTimeout(resolve, 100 * i));
}
return false;
}
}
export const DebugAdapter = WebSocketDebugAdapter;
function stoppedReason(reason: JSC.Debugger.PausedEvent["reason"]): DAP.StoppedEvent["reason"] {
switch (reason) {
case "Breakpoint":
return "breakpoint";
case "FunctionCall":
return "function breakpoint";
case "PauseOnNextStatement":
case "DebuggerStatement":
return "pause";
case "exception":
case "assert":
return "exception";
default:
return "breakpoint";
}
}
function titleize(name: string): string {
return name.charAt(0).toUpperCase() + name.slice(1);
}
function sourcePresentationHint(url?: string): DAP.Source["presentationHint"] {
if (!url || !path.isAbsolute(url)) {
return "deemphasize";
}
if (url.includes("/node_modules/") || url.includes("\\node_modules\\")) {
return "normal";
}
return "emphasize";
}
function sourceName(url?: string): string {
if (!url) {
return "unknown.js";
}
if (isJavaScript(url)) {
if (process.platform === "win32") {
url = url.replaceAll("\\", "/");
}
return url.split("/").pop() || url;
}
return `${url}.js`;
}
function stackFramePresentationHint(path?: string): DAP.StackFrame["presentationHint"] {
if (!path || path.includes("/node_modules/")) {
return "subtle";
}
return "normal";
}
function scopePresentationHint(type: JSC.Debugger.Scope["type"]): DAP.Scope["presentationHint"] {
switch (type) {
case "closure":
case "functionName":
case "with":
case "catch":
case "nestedLexical":
return "locals";
case "global":
case "globalLexicalEnvironment":
return "globals";
default:
return undefined;
}
}
function isSet(subtype: JSC.Runtime.RemoteObject["type"] | JSC.Runtime.RemoteObject["subtype"]): boolean {
return subtype === "set" || subtype === "weakset";
}
function isArrayLike(subtype: JSC.Runtime.RemoteObject["type"] | JSC.Runtime.RemoteObject["subtype"]): boolean {
return subtype === "array" || isSet(subtype);
}
function isMap(subtype: JSC.Runtime.RemoteObject["type"] | JSC.Runtime.RemoteObject["subtype"]): boolean {
return subtype === "map" || subtype === "weakmap";
}
function breakpointOptions(breakpoint: Partial<DAP.SourceBreakpoint>): JSC.Debugger.BreakpointOptions {
const { condition, hitCondition, logMessage } = breakpoint;
return {
condition,
ignoreCount: hitConditionToIgnoreCount(hitCondition),
autoContinue: !!logMessage,
actions: [
{
type: "evaluate",
data: logMessageToExpression(logMessage),
emulateUserGesture: true,
},
],
};
}
function hitConditionToIgnoreCount(hitCondition?: string): number | undefined {
if (!hitCondition) {
return undefined;
}
if (hitCondition.includes("<")) {
throw new Error("Hit condition with '<' is not supported, use '>' or '>=' instead.");
}
const count = parseInt(hitCondition.replace(/[^\d+]/g, ""));
if (isNaN(count)) {
throw new Error("Hit condition is not a number.");
}
if (hitCondition.includes(">") && !hitCondition.includes("=")) {
return Math.max(0, count);
}
return Math.max(0, count - 1);
}
function logMessageToExpression(logMessage?: string): string | undefined {
if (!logMessage) {
return undefined;
}
// Convert expressions from "hello {name}!" to "`hello ${name}!`"
return `console.log(\`${logMessage.replace(/\$?{/g, "${")}\`);`;
}
function completionToExpression(completion: string): { expression: string; hint?: string } {
const lastDot = completion.lastIndexOf(".");
const last = (s0: string, s1: string) => {
const i0 = completion.lastIndexOf(s0);
const i1 = completion.lastIndexOf(s1);
return i1 > i0 ? i1 + 1 : i0;
};
const lastIdentifier = Math.max(lastDot, last("[", "]"), last("(", ")"), last("{", "}"));
let expression: string;
let remainder: string;
if (lastIdentifier > 0) {
expression = completion.slice(0, lastIdentifier);
remainder = completion.slice(lastIdentifier);
} else {
expression = "";
remainder = completion;
}
const [hint] = completion.slice(lastIdentifier).match(/[#$a-z_][0-9a-z_$]*/gi) ?? [];
return {
expression,
hint,
};
}
function consoleMessageGroup(type: JSC.Console.ConsoleMessage["type"]): DAP.OutputEvent["group"] {
switch (type) {
case "startGroup":
return "start";
case "startGroupCollapsed":
return "startCollapsed";
case "endGroup":
return "end";
}
return undefined;
}
function sourceToId(source?: DAP.Source): string | number {
const { path, sourceReference } = source ?? {};
if (path) {
return path;
}
if (sourceReference) {
return sourceReference;
}
throw new Error("No source found.");
}
function sourceToPath(source?: DAP.Source | string): string {
if (typeof source === "string") {
return source;
}
if (source) {
const { path } = source;
if (path) {
return path;
}
}
throw new Error("No source found.");
}
function callFrameToId(callFrame: JSC.Console.CallFrame): string {
const { url, lineNumber, columnNumber } = callFrame;
return `${url}:${lineNumber}:${columnNumber}`;
}
function sanitizeExpression(expression: string): string {
expression = expression.trim();
if (expression.startsWith("{")) {
expression = `(${expression})`;
}
if (expression.startsWith("return ")) {
expression = expression.slice(7);
}
if (expression.startsWith("await ")) {
expression = expression.slice(6);
}
return expression;
}
function remoteObjectToVariablePresentationHint(
remoteObject: JSC.Runtime.RemoteObject,
propertyDescriptor?: Partial<JSC.Runtime.PropertyDescriptor> & {
isSynthetic?: boolean;
parentType?: JSC.Runtime.RemoteObject["type"] | JSC.Runtime.RemoteObject["subtype"];
},
): DAP.VariablePresentationHint {
const { type, subtype } = remoteObject;
const { name, enumerable, writable, isPrivate, isSynthetic, 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";
}
if (subtype === "class") {
kind = "class";
}
if (isSynthetic || isPrivate || hasSymbol) {
visibility = "protected";
}
if (enumerable === false || name === "__proto__") {
visibility = "internal";
}
if (type === "string") {
attributes.push("rawString");
}
if (isSynthetic || writable === false || (hasGetter && !hasSetter)) {
attributes.push("readOnly");
}
if (wasThrown || hasGetter) {
lazy = true;
attributes.push("hasSideEffects");
}
return {
kind,
visibility,
lazy,
attributes,
};
}
function propertyDescriptorToName(propertyDescriptor?: Partial<JSC.Runtime.PropertyDescriptor>): string {
if (!propertyDescriptor) {
return "";
}
const { name } = propertyDescriptor;
if (name === "__proto__") {
return "[[Prototype]]";
}
return name ?? "";
}
function propertyDescriptorToEvaluateName(
propertyDescriptor?: Partial<JSC.Runtime.PropertyDescriptor> & {
isSynthetic?: boolean;
parentType?: JSC.Runtime.RemoteObject["type"] | JSC.Runtime.RemoteObject["subtype"];
},
evaluateName?: string,
): string | undefined {
if (!propertyDescriptor) {
return evaluateName;
}
const { name: property, isSynthetic, parentType: type } = propertyDescriptor;
if (!property) {
return evaluateName;
}
if (!evaluateName) {
return property;
}
if (isSynthetic) {
if (isMap(type)) {
if (isNumeric(property)) {
return `${evaluateName}.get(${property})`;
}
return `${evaluateName}.get(${JSON.stringify(property)})`;
}
if (isSet(type)) {
return `[...${evaluateName}.values()][${property}]`;
}
}
if (isNumeric(property)) {
return `${evaluateName}[${property}]`;
}
if (isIdentifier(property)) {
return `${evaluateName}.${property}`;
}
return `${evaluateName}[${JSON.stringify(property)}]`;
}
function isNumeric(string: string): boolean {
return /^\d+$/.test(string);
}
function isIdentifier(string: string): boolean {
return /^[#$a-z_][0-9a-z_$]*$/i.test(string);
}
function unknownToError(input: unknown): Error {
if (input instanceof Error) {
return input;
}
return new Error(String(input));
}
function isJavaScript(path: string): boolean {
return /\.(c|m)?(j|t)sx?$/.test(path);
}
function isTestJavaScript(path: string): boolean {
return /\.(test|spec)\.(c|m)?(j|t)sx?$/.test(path);
}
function isSyntaxError(remoteObject: JSC.Runtime.RemoteObject): boolean {
const { className } = remoteObject;
switch (className) {
case "SyntaxError":
case "ReferenceError":
return true;
}
return false;
}
function variableToCompletionItem(variable: Variable): DAP.CompletionItem {
const { name, type } = variable;
return {
label: name,
type: variableTypeToCompletionItemType(type),
};
}
function variableTypeToCompletionItemType(type: Variable["type"]): DAP.CompletionItem["type"] {
switch (type) {
case "class":
return "class";
case "function":
return "function";
}
return "property";
}
function variablesSortBy(a: DAP.Variable, b: DAP.Variable): number {
const visibility = (variable: DAP.Variable): number => {
const { presentationHint } = variable;
switch (presentationHint?.visibility) {
case "protected":
return 1;
case "private":
return 2;
case "internal":
return 3;
}
return 0;
};
const index = (variable: DAP.Variable): number => {
const { name } = variable;
switch (name) {
case "[[Prototype]]":
case "prototype":
case "__proto__":
return Number.MAX_VALUE;
}
const index = parseInt(name);
if (isFinite(index)) {
return index;
}
switch (name[0]) {
case "_":
case "$":
return 1;
case "#":
return 2;
}
return 0;
};
const av = visibility(a);
const bv = visibility(b);
if (av > bv) return 1;
if (av < bv) return -1;
const ai = index(a);
const bi = index(b);
if (ai > bi) return 1;
if (ai < bi) return -1;
return 0;
}
function isSameLocation(a: { line?: number; column?: number }, b: { line?: number; column?: number }): boolean {
return (a.line === b.line || (!a.line && !b.line)) && (a.column === b.column || (!a.column && !b.column));
}
function consoleLevelToAnsiColor(level: JSC.Console.ConsoleMessage["level"]): string | undefined {
switch (level) {
case "warning":
return "\u001b[33m";
case "error":
return "\u001b[31m";
}
return undefined;
}
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;
}
function stripAnsi(string: string): string {
return string.replace(/\u001b\[\d+m/g, "");
}
function invalidBreakpoint(error?: unknown): Breakpoint {
const { message } = error ? unknownToError(error) : { message: undefined };
return {
id: nextId(),
breakpointId: "",
verified: false,
message,
};
}
const Cancel = Symbol("Cancel");
let sequence = 1;
function nextId(): number {
return sequence++;
}
export function getRandomId() {
return Math.random().toString(36).slice(2);
}
export function normalizeWindowsPath(winPath: string): string {
winPath = path.normalize(winPath);
if (winPath[1] === ":" && (winPath[2] === "\\" || winPath[2] === "/")) {
return (winPath.charAt(0).toUpperCase() + winPath.slice(1)).replaceAll("\\\\", "\\");
}
return winPath;
}