More support for DAP (#4380)

* Fix reconnect with --watch

* Support setVariable

* Support setExpression

* Support watch variables

* Conditional and hit breakpoints

* Support exceptionInfo

* Support goto and gotoTargets

* Support completions

* Support both a URL and UNIX inspector at the same time

* Fix url

* WIP, add timeouts to figure out issue

* Fix messages being dropped from debugger.ts

* Progress

* Fix breakpoints and ref-event-loop

* More fixes

* Fix exit

* Make hovers better

* Fix --hot
This commit is contained in:
Ashcon Partovi
2023-08-29 23:44:39 -07:00
committed by GitHub
parent c028b206bc
commit f2553d2454
14 changed files with 1405 additions and 892 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,271 +0,0 @@
import type { DAP } from "../protocol";
const capabilities: DAP.Capabilities = {
/**
* The debug adapter supports the `configurationDone` request.
* @see configurationDone
*/
supportsConfigurationDoneRequest: true,
/**
* The debug adapter supports function breakpoints using the `setFunctionBreakpoints` request.
* @see setFunctionBreakpoints
*/
supportsFunctionBreakpoints: true,
/**
* The debug adapter supports conditional breakpoints.
* @see setBreakpoints
* @see setInstructionBreakpoints
* @see setFunctionBreakpoints
* @see setExceptionBreakpoints
* @see setDataBreakpoints
*/
supportsConditionalBreakpoints: true,
/**
* The debug adapter supports breakpoints that break execution after a specified number of hits.
* @see setBreakpoints
* @see setInstructionBreakpoints
* @see setFunctionBreakpoints
* @see setExceptionBreakpoints
* @see setDataBreakpoints
*/
supportsHitConditionalBreakpoints: true,
/**
* The debug adapter supports a (side effect free) `evaluate` request for data hovers.
* @see evaluate
*/
supportsEvaluateForHovers: true,
/**
* Available exception filter options for the `setExceptionBreakpoints` request.
* @see setExceptionBreakpoints
*/
exceptionBreakpointFilters: [
{
filter: "all",
label: "Caught 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"`,
},
],
/**
* The debug adapter supports stepping back via the `stepBack` and `reverseContinue` requests.
* @see stepBack
* @see reverseContinue
*/
supportsStepBack: false,
/**
* The debug adapter supports setting a variable to a value.
* @see setVariable
*/
supportsSetVariable: false,
/**
* The debug adapter supports restarting a frame.
* @see restartFrame
*/
supportsRestartFrame: false,
/**
* The debug adapter supports the `gotoTargets` request.
* @see gotoTargets
*/
supportsGotoTargetsRequest: false,
/**
* The debug adapter supports the `stepInTargets` request.
* @see stepInTargets
*/
supportsStepInTargetsRequest: false,
/**
* The debug adapter supports the `completions` request.
* @see completions
*/
supportsCompletionsRequest: false,
/**
* The set of characters that should trigger completion in a REPL.
* If not specified, the UI should assume the `.` character.
* @see completions
*/
completionTriggerCharacters: [".", "[", '"', "'"],
/**
* The debug adapter supports the `modules` request.
* @see modules
*/
supportsModulesRequest: false,
/**
* The set of additional module information exposed by the debug adapter.
* @see modules
*/
additionalModuleColumns: [],
/**
* Checksum algorithms supported by the debug adapter.
*/
supportedChecksumAlgorithms: [],
/**
* The debug adapter supports the `restart` request.
* In this case a client should not implement `restart` by terminating
* and relaunching the adapter but by calling the `restart` request.
* @see restart
*/
supportsRestartRequest: false,
/**
* The debug adapter supports `exceptionOptions` on the `setExceptionBreakpoints` request.
* @see setExceptionBreakpoints
*/
supportsExceptionOptions: false,
/**
* The debug adapter supports a `format` attribute on the `stackTrace`, `variables`, and `evaluate` requests.
* @see stackTrace
* @see variables
* @see evaluate
*/
supportsValueFormattingOptions: false,
/**
* The debug adapter supports the `exceptionInfo` request.
* @see exceptionInfo
*/
supportsExceptionInfoRequest: true,
/**
* The debug adapter supports the `terminateDebuggee` attribute on the `disconnect` request.
* @see disconnect
*/
supportTerminateDebuggee: true,
/**
* The debug adapter supports the `suspendDebuggee` attribute on the `disconnect` request.
* @see disconnect
*/
supportSuspendDebuggee: false,
/**
* The debug adapter supports the delayed loading of parts of the stack,
* which requires that both the `startFrame` and `levels` arguments and
* the `totalFrames` result of the `stackTrace` request are supported.
* @see stackTrace
*/
supportsDelayedStackTraceLoading: true,
/**
* The debug adapter supports the `loadedSources` request.
* @see loadedSources
*/
supportsLoadedSourcesRequest: true,
/**
* The debug adapter supports log points by interpreting the `logMessage` attribute of the `SourceBreakpoint`.
* @see setBreakpoints
*/
supportsLogPoints: true,
/**
* The debug adapter supports the `terminateThreads` request.
* @see terminateThreads
*/
supportsTerminateThreadsRequest: false,
/**
* The debug adapter supports the `setExpression` request.
* @see setExpression
*/
supportsSetExpression: false,
/**
* The debug adapter supports the `terminate` request.
* @see terminate
*/
supportsTerminateRequest: true,
/**
* The debug adapter supports data breakpoints.
* @see setDataBreakpoints
*/
supportsDataBreakpoints: false,
/**
* The debug adapter supports the `readMemory` request.
* @see readMemory
*/
supportsReadMemoryRequest: false,
/**
* The debug adapter supports the `writeMemory` request.
* @see writeMemory
*/
supportsWriteMemoryRequest: false,
/**
* The debug adapter supports the `disassemble` request.
* @see disassemble
*/
supportsDisassembleRequest: false,
/**
* The debug adapter supports the `cancel` request.
* @see cancel
*/
supportsCancelRequest: false,
/**
* The debug adapter supports the `breakpointLocations` request.
* @see breakpointLocations
*/
supportsBreakpointLocationsRequest: true,
/**
* The debug adapter supports the `clipboard` context value in the `evaluate` request.
* @see evaluate
*/
supportsClipboardContext: false,
/**
* The debug adapter supports stepping granularities (argument `granularity`) for the stepping requests.
* @see stepIn
*/
supportsSteppingGranularity: false,
/**
* The debug adapter supports adding breakpoints based on instruction references.
* @see setInstructionBreakpoints
*/
supportsInstructionBreakpoints: false,
/**
* The debug adapter supports `filterOptions` as an argument on the `setExceptionBreakpoints` request.
* @see setExceptionBreakpoints
*/
supportsExceptionFilterOptions: true,
/**
* The debug adapter supports the `singleThread` property on the execution requests
* (`continue`, `next`, `stepIn`, `stepOut`, `reverseContinue`, `stepBack`).
*/
supportsSingleThreadExecutionRequests: false,
};
export default capabilities;

View File

@@ -21,7 +21,7 @@ export class UnixSignal extends EventEmitter<UnixSignalEventMap> {
#server: Server;
#ready: Promise<void>;
constructor(path?: string) {
constructor(path?: string | URL) {
super();
this.#path = path ? parseUnixPath(path) : randomUnixPath();
this.#server = createServer();
@@ -74,8 +74,8 @@ export function randomUnixPath(): string {
return join(tmpdir(), `${Math.random().toString(36).slice(2)}.sock`);
}
function parseUnixPath(path: string): string {
if (path.startsWith("/")) {
function parseUnixPath(path: string | URL): string {
if (typeof path === "string" && path.startsWith("/")) {
return path;
}
try {

View File

@@ -80,7 +80,7 @@ class ActualSourceMap implements SourceMap {
const { line: gline, column: gcolumn } = lineRange;
return {
line: lineToLine(gline),
line: lineTo0BasedLine(gline),
column: columnToColumn(gcolumn),
verified: true,
};
@@ -144,9 +144,17 @@ class NoopSourceMap implements SourceMap {
const defaultSourceMap = new NoopSourceMap();
export function SourceMap(url?: string): SourceMap {
if (!url || !url.startsWith("data:")) {
if (!url) {
return defaultSourceMap;
}
if (!url.startsWith("data:")) {
const match = url.match(/\/\/[#@]\s*sourceMappingURL=(.*)$/m);
if (!match) {
return defaultSourceMap;
}
const [_, sourceMapUrl] = match;
url = sourceMapUrl;
}
try {
const [_, base64] = url.split(",", 2);
const decoded = Buffer.from(base64, "base64url").toString("utf8");

View File

@@ -52,10 +52,10 @@ export class WebSocketInspector extends EventEmitter<InspectorEventMap> implemen
// @ts-expect-error: Support both Bun and Node.js version of `headers`.
webSocket = new WebSocket(url, {
headers: {
"Ref-Event-Loop": "1",
"Ref-Event-Loop": "0",
},
finishRequest: (request: import("http").ClientRequest) => {
request.setHeader("Ref-Event-Loop", "1");
request.setHeader("Ref-Event-Loop", "0");
request.end();
},
});
@@ -67,18 +67,23 @@ export class WebSocketInspector extends EventEmitter<InspectorEventMap> implemen
webSocket.addEventListener("open", () => {
this.emit("Inspector.connected");
for (const request of this.#pendingRequests) {
for (let i = 0; i < this.#pendingRequests.length; i++) {
const request = this.#pendingRequests[i];
if (this.#send(request)) {
this.emit("Inspector.request", request);
} else {
this.#pendingRequests = this.#pendingRequests.slice(i);
break;
}
}
this.#pendingRequests.length = 0;
});
webSocket.addEventListener("message", ({ data }) => {
if (typeof data === "string") {
this.#accept(data);
} else {
this.emit("Inspector.error", new Error(`WebSocket received unexpected binary message: ${data.toString()}`));
}
});
@@ -125,8 +130,12 @@ export class WebSocketInspector extends EventEmitter<InspectorEventMap> implemen
};
return new Promise((resolve, reject) => {
let timerId: number | undefined;
const done = (result: any) => {
this.#pendingResponses.delete(id);
if (timerId) {
clearTimeout(timerId);
}
if (result instanceof Error) {
reject(result);
} else {
@@ -136,6 +145,7 @@ export class WebSocketInspector extends EventEmitter<InspectorEventMap> implemen
this.#pendingResponses.set(id, done);
if (this.#send(request)) {
timerId = +setTimeout(() => done(new Error(`Timed out: ${method}`)), 10_000);
this.emit("Inspector.request", request);
} else {
this.emit("Inspector.pendingRequest", request);
@@ -183,7 +193,6 @@ export class WebSocketInspector extends EventEmitter<InspectorEventMap> implemen
return;
}
this.#pendingResponses.delete(id);
if ("error" in data) {
const { error } = data;
const { message } = error;
@@ -218,6 +227,7 @@ export class WebSocketInspector extends EventEmitter<InspectorEventMap> implemen
resolve(error ?? new Error("WebSocket closed"));
}
this.#pendingResponses.clear();
if (error) {
this.emit("Inspector.error", error);
}

View File

@@ -5,7 +5,24 @@ describe("example", () => {
expect(1).toBe(1);
expect(1).not.toBe(2);
expect(() => {
throw new Error("error");
throw new TypeError("Oops! I did it again.");
}).toThrow();
expect(() => {
throw new Error("Parent error.", {
cause: new TypeError("Child error."),
});
}).toThrow();
expect(() => {
throw new AggregateError([new TypeError("Child error 1."), new TypeError("Child error 2.")], "Parent error.");
}).toThrow();
expect(() => {
throw "This is a string error";
}).toThrow();
expect(() => {
throw {
message: "This is an object error",
code: -1021,
};
}).toThrow();
});
});

View File

@@ -1,10 +1,14 @@
export default {
async fetch(request: Request): Promise<Response> {
a(request);
const object = {
a: "1",
b: "2",
c: new Map([[1, 2]]),
};
const coolThing: CoolThing = new SuperCoolThing();
coolThing.doCoolThing();
debugger;
return new Response("BAI BAI");
return new Response("Hello World");
},
};

View File

@@ -131,35 +131,53 @@
"description": "The path to Bun.",
"default": "bun"
},
"runtimeArgs": {
"type": "array",
"description": "The command-line arguments passed to Bun.",
"items": {
"type": "string"
},
"default": []
},
"program": {
"type": "string",
"description": "The file to debug.",
"description": "The path to a JavaScript or TypeScript file.",
"default": "${file}"
},
"args": {
"type": "array",
"description": "The command-line arguments passed to the program.",
"items": {
"type": "string"
},
"default": []
},
"cwd": {
"type": "string",
"description": "The working directory.",
"default": "${workspaceFolder}"
},
"args": {
"type": "array",
"description": "The arguments passed to Bun.",
"items": {
"type": "string"
},
"default": []
},
"env": {
"type": "object",
"description": "The environment variables passed to Bun.",
"default": {}
},
"inheritEnv": {
"strictEnv": {
"type": "boolean",
"description": "If environment variables should be inherited from the parent process.",
"default": true
"description": "If environment variables should not be inherited from the parent process.",
"default": false
},
"watch": {
"stopOnEntry": {
"type": "boolean",
"description": "If a breakpoint should be set at the first line.",
"default": false
},
"noDebug": {
"type": "boolean",
"description": "If the debugger should be disabled.",
"default": false
},
"watchMode": {
"type": ["boolean", "string"],
"description": "If the process should be restarted when files change.",
"enum": [
@@ -168,11 +186,6 @@
"hot"
],
"default": false
},
"debug": {
"type": "boolean",
"description": "If the process should be started in debug mode.",
"default": true
}
}
},
@@ -181,6 +194,16 @@
"url": {
"type": "string",
"description": "The URL of the Bun process to attach to."
},
"noDebug": {
"type": "boolean",
"description": "If the debugger should be disabled.",
"default": false
},
"stopOnEntry": {
"type": "boolean",
"description": "If a breakpoint should when the program is attached.",
"default": false
}
}
}

View File

@@ -9,7 +9,8 @@ const debugConfiguration: vscode.DebugConfiguration = {
request: "launch",
name: "Debug Bun",
program: "${file}",
watch: false,
stopOnEntry: false,
watchMode: false,
};
const runConfiguration: vscode.DebugConfiguration = {
@@ -17,8 +18,8 @@ const runConfiguration: vscode.DebugConfiguration = {
request: "launch",
name: "Run Bun",
program: "${file}",
debug: false,
watch: false,
noDebug: true,
watchMode: false,
};
const attachConfiguration: vscode.DebugConfiguration = {
@@ -48,15 +49,25 @@ export default function (context: vscode.ExtensionContext, factory?: vscode.Debu
vscode.window.registerTerminalProfileProvider("bun", new TerminalProfileProvider()),
);
const { terminalProfile } = new TerminalDebugSession();
const { options } = terminalProfile;
const terminal = vscode.window.createTerminal(options);
terminal.show();
context.subscriptions.push(terminal);
const document = getActiveDocument();
if (isJavaScript(document?.languageId)) {
vscode.workspace.findFiles("bun.lockb", "node_modules", 1).then(files => {
const { terminalProfile } = new TerminalDebugSession();
const { options } = terminalProfile;
const terminal = vscode.window.createTerminal(options);
const focus = files.length > 0;
if (focus) {
terminal.show();
}
context.subscriptions.push(terminal);
});
}
}
function RunFileCommand(resource?: vscode.Uri): void {
const path = getCurrentPath(resource);
const path = getActivePath(resource);
if (path) {
vscode.debug.startDebugging(undefined, {
...runConfiguration,
@@ -67,7 +78,7 @@ function RunFileCommand(resource?: vscode.Uri): void {
}
function DebugFileCommand(resource?: vscode.Uri): void {
const path = getCurrentPath(resource);
const path = getActivePath(resource);
if (path) {
vscode.debug.startDebugging(undefined, {
...debugConfiguration,
@@ -178,18 +189,36 @@ class TerminalDebugSession extends FileDebugSession {
return new vscode.TerminalProfile({
name: "Bun Terminal",
env: {
"BUN_INSPECT": `1${this.adapter.url}`,
"BUN_INSPECT": `${this.adapter.url}?wait=1`,
"BUN_INSPECT_NOTIFY": `${this.signal.url}`,
},
isTransient: true,
iconPath: new vscode.ThemeIcon("debug-console"),
});
}
dispose(): void {
super.dispose();
this.signal.close();
}
}
function getCurrentPath(target?: vscode.Uri): string | undefined {
if (!target && vscode.window.activeTextEditor) {
target = vscode.window.activeTextEditor.document.uri;
function getActiveDocument(): vscode.TextDocument | undefined {
return vscode.window.activeTextEditor?.document;
}
function getActivePath(target?: vscode.Uri): string | undefined {
if (!target) {
target = getActiveDocument()?.uri;
}
return target?.fsPath;
}
function isJavaScript(languageId?: string): boolean {
return (
languageId === "javascript" ||
languageId === "javascriptreact" ||
languageId === "typescript" ||
languageId === "typescriptreact"
);
}

View File

@@ -109,7 +109,7 @@ public:
globalObject->setInspectable(true);
auto& inspector = globalObject->inspectorDebuggable();
inspector.setInspectable(true);
globalObject->inspectorController().connectFrontend(*connection, true, waitingForConnection);
globalObject->inspectorController().connectFrontend(*connection, true, false); // waitingForConnection
Inspector::JSGlobalObjectDebugger* debugger = reinterpret_cast<Inspector::JSGlobalObjectDebugger*>(globalObject->debugger());
if (debugger) {
@@ -482,7 +482,7 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionCreateConnection, (JSGlobalObject * globalObj
return JSValue::encode(JSBunInspectorConnection::create(vm, JSBunInspectorConnection::createStructure(vm, globalObject, globalObject->objectPrototype()), connection));
}
extern "C" BunString Bun__startJSDebuggerThread(Zig::GlobalObject* debuggerGlobalObject, ScriptExecutionContextIdentifier scriptId, BunString* portOrPathString)
extern "C" void Bun__startJSDebuggerThread(Zig::GlobalObject* debuggerGlobalObject, ScriptExecutionContextIdentifier scriptId, BunString* portOrPathString)
{
if (!debuggerScriptExecutionContext)
debuggerScriptExecutionContext = debuggerGlobalObject->scriptExecutionContext();
@@ -498,12 +498,7 @@ extern "C" BunString Bun__startJSDebuggerThread(Zig::GlobalObject* debuggerGloba
arguments.append(JSFunction::create(vm, debuggerGlobalObject, 1, String("send"_s), jsFunctionSend, ImplementationVisibility::Public));
arguments.append(JSFunction::create(vm, debuggerGlobalObject, 0, String("disconnect"_s), jsFunctionDisconnect, ImplementationVisibility::Public));
JSValue serverURLValue = JSC::call(debuggerGlobalObject, debuggerDefaultFn, arguments, "Bun__initJSDebuggerThread - debuggerDefaultFn"_s);
if (serverURLValue.isUndefinedOrNull())
return BunStringEmpty;
return Bun::toStringRef(debuggerGlobalObject, serverURLValue);
JSC::call(debuggerGlobalObject, debuggerDefaultFn, arguments, "Bun__initJSDebuggerThread - debuggerDefaultFn"_s);
}
enum class AsyncCallTypeUint8 : uint8_t {

View File

@@ -502,6 +502,7 @@ pub const VirtualMachine = struct {
worker: ?*JSC.WebWorker = null,
debugger: ?Debugger = null,
has_started_debugger: bool = false,
pub const OnUnhandledRejection = fn (*VirtualMachine, globalObject: *JSC.JSGlobalObject, JSC.JSValue) void;
@@ -794,7 +795,8 @@ pub const VirtualMachine = struct {
pub var has_created_debugger: bool = false;
pub const Debugger = struct {
path_or_port: []const u8 = "",
path_or_port: ?[]const u8 = null,
unix: []const u8 = "",
script_execution_context_id: u32 = 0,
next_debugger_id: u64 = 1,
poll_ref: JSC.PollRef = .{},
@@ -805,8 +807,7 @@ pub const VirtualMachine = struct {
extern "C" fn Bun__createJSDebugger(*JSC.JSGlobalObject) u32;
extern "C" fn Bun__ensureDebugger(u32, bool) void;
extern "C" fn Bun__startJSDebuggerThread(*JSC.JSGlobalObject, u32, *bun.String) bun.String;
var has_started_debugger_thread: bool = false;
extern "C" fn Bun__startJSDebuggerThread(*JSC.JSGlobalObject, u32, *bun.String) void;
var futex_atomic: std.atomic.Atomic(u32) = undefined;
pub fn create(this: *VirtualMachine, globalObject: *JSGlobalObject) !void {
@@ -816,8 +817,8 @@ pub const VirtualMachine = struct {
has_created_debugger = true;
var debugger = &this.debugger.?;
debugger.script_execution_context_id = Bun__createJSDebugger(globalObject);
if (!has_started_debugger_thread) {
has_started_debugger_thread = true;
if (!this.has_started_debugger) {
this.has_started_debugger = true;
futex_atomic = std.atomic.Atomic(u32).init(0);
var thread = try std.Thread.spawn(.{}, startJSDebuggerThread, .{this});
thread.detach();
@@ -865,8 +866,6 @@ pub const VirtualMachine = struct {
vm.global.vm().holdAPILock(other_vm, @ptrCast(&start));
}
pub export var Bun__debugger_server_url: bun.String = undefined;
pub export fn Debugger__didConnect() void {
var this = VirtualMachine.get();
std.debug.assert(this.debugger.?.wait_for_connection);
@@ -878,9 +877,17 @@ pub const VirtualMachine = struct {
JSC.markBinding(@src());
var this = VirtualMachine.get();
var str = bun.String.create(other_vm.debugger.?.path_or_port);
Bun__debugger_server_url = Bun__startJSDebuggerThread(this.global, other_vm.debugger.?.script_execution_context_id, &str);
Bun__debugger_server_url.toThreadSafe();
var debugger = other_vm.debugger.?;
if (debugger.unix.len > 0) {
var url = bun.String.create(debugger.unix);
Bun__startJSDebuggerThread(this.global, debugger.script_execution_context_id, &url);
}
if (debugger.path_or_port) |path_or_port| {
var url = bun.String.create(path_or_port);
Bun__startJSDebuggerThread(this.global, debugger.script_execution_context_id, &url);
}
this.global.handleRejectedPromises();
@@ -1189,13 +1196,27 @@ pub const VirtualMachine = struct {
}
fn configureDebugger(this: *VirtualMachine, debugger: bun.CLI.Command.Debugger) void {
var unix = bun.getenvZ("BUN_INSPECT") orelse "";
var set_breakpoint_on_first_line = unix.len > 0 and strings.endsWith(unix, "?break=1");
var wait_for_connection = set_breakpoint_on_first_line or (unix.len > 0 and strings.endsWith(unix, "?wait=1"));
switch (debugger) {
.unspecified => {},
.unspecified => {
if (unix.len > 0) {
this.debugger = Debugger{
.path_or_port = null,
.unix = unix,
.wait_for_connection = wait_for_connection,
.set_breakpoint_on_first_line = set_breakpoint_on_first_line,
};
}
},
.enable => {
this.debugger = Debugger{
.path_or_port = debugger.enable.path_or_port,
.wait_for_connection = debugger.enable.wait_for_connection,
.set_breakpoint_on_first_line = debugger.enable.set_breakpoint_on_first_line,
.unix = unix,
.wait_for_connection = wait_for_connection or debugger.enable.wait_for_connection,
.set_breakpoint_on_first_line = set_breakpoint_on_first_line or debugger.enable.set_breakpoint_on_first_line,
};
},
}

View File

@@ -543,15 +543,6 @@ pub const Arguments = struct {
.wait_for_connection = true,
.set_breakpoint_on_first_line = true,
} };
} else if (bun.getenvZ("BUN_INSPECT")) |inspect_value| {
ctx.runtime_options.debugger = if (inspect_value.len == 0 or inspect_value[0] == '0')
Command.Debugger{ .unspecified = {} }
else
Command.Debugger{ .enable = .{
.path_or_port = inspect_value[1..],
.wait_for_connection = inspect_value[0] == '1' or inspect_value[0] == '2',
.set_breakpoint_on_first_line = inspect_value[0] == '2',
} };
}
}

View File

@@ -1,309 +1,322 @@
import type * as BunType from "bun";
import type { Server as WebSocketServer, WebSocketHandler, ServerWebSocket, SocketHandler, Socket } from "bun";
// We want to avoid dealing with creating a prototype for the inspector class
let sendFn_, disconnectFn_;
const colors = Bun.enableANSIColors && process.env.NO_COLOR !== "1";
var debuggerCounter = 1;
class DebuggerWithMessageQueue {
debugger?: Debugger = undefined;
messageQueue: string[] = [];
count: number = debuggerCounter++;
send(msg: string) {
sendFn_.call(this.debugger, msg);
export default function (
executionContextId: string,
url: string,
createBackend: (
executionContextId: string,
refEventLoop: boolean,
receive: (...messages: string[]) => void,
) => unknown,
send: (message: string) => void,
close: () => void,
): void {
let debug: Debugger | undefined;
try {
debug = new Debugger(executionContextId, url, createBackend, send, close);
} catch (error) {
exit("Failed to start inspector:\n", error);
}
disconnect() {
disconnectFn_.call(this.debugger);
this.messageQueue.length = 0;
const { protocol, href, host, pathname } = debug.url;
if (!protocol.includes("unix")) {
console.log(dim("--------------------- Bun Inspector ---------------------"), reset());
console.log(`Listening:\n ${dim(href)}`);
if (protocol.includes("ws")) {
console.log(`Inspect in browser:\n ${link(`https://debug.bun.sh/#${host}${pathname}`)}`);
}
console.log(dim("--------------------- Bun Inspector ---------------------"), reset());
}
const unix = process.env["BUN_INSPECT_NOTIFY"];
if (unix) {
const { protocol, pathname } = parseUrl(unix);
if (protocol === "unix:") {
notify(pathname);
}
}
}
let defaultPort = 6499;
class Debugger {
#url: URL;
#createBackend: (refEventLoop: boolean, receive: (...messages: string[]) => void) => Writer;
let generatedPath: string = "";
function generatePath() {
if (!generatedPath) {
generatedPath = "/" + Math.random().toString(36).slice(2);
constructor(
executionContextId: string,
url: string,
createBackend: (
executionContextId: string,
refEventLoop: boolean,
receive: (...messages: string[]) => void,
) => unknown,
send: (message: string) => void,
close: () => void,
) {
this.#url = parseUrl(url);
this.#createBackend = (refEventLoop, receive) => {
const backend = createBackend(executionContextId, refEventLoop, receive);
return {
write: message => {
send.call(backend, message);
return true;
},
close: () => close.call(backend),
};
};
this.#listen();
}
return generatedPath;
get url(): URL {
return this.#url;
}
#listen(): void {
const { protocol, hostname, port, pathname } = this.#url;
if (protocol === "ws:" || protocol === "ws+tcp:") {
const server = Bun.serve({
hostname,
port,
fetch: this.#fetch.bind(this),
websocket: this.#websocket,
});
this.#url.hostname = server.hostname;
this.#url.port = `${server.port}`;
return;
}
if (protocol === "ws+unix:") {
Bun.serve({
unix: pathname,
fetch: this.#fetch.bind(this),
websocket: this.#websocket,
});
return;
}
throw new TypeError(`Unsupported protocol: '${protocol}' (expected 'ws:', 'ws+unix:', or 'unix:')`);
}
get #websocket(): WebSocketHandler<Connection> {
return {
idleTimeout: 0,
closeOnBackpressureLimit: false,
open: ws => this.#open(ws, webSocketWriter(ws)),
message: (ws, message) => {
if (typeof message === "string") {
this.#message(ws, message);
} else {
this.#error(ws, new Error(`Unexpected binary message: ${message.toString()}`));
}
},
drain: ws => this.#drain(ws),
close: ws => this.#close(ws),
};
}
#fetch(request: Request, server: WebSocketServer): Response | undefined {
const { method, url, headers } = request;
const { pathname } = new URL(url);
if (method !== "GET") {
return new Response(null, {
status: 405, // Method Not Allowed
});
}
switch (pathname) {
case "/json/version":
return Response.json(versionInfo());
case "/json":
case "/json/list":
// TODO?
}
if (!this.#url.protocol.includes("unix") && this.#url.pathname !== pathname) {
return new Response(null, {
status: 404, // Not Found
});
}
const data: Connection = {
refEventLoop: headers.get("Ref-Event-Loop") === "0",
};
if (!server.upgrade(request, { data })) {
return new Response(null, {
status: 426, // Upgrade Required
headers: {
"Upgrade": "websocket",
},
});
}
}
get #socket(): SocketHandler<Connection> {
return {
open: socket => this.#open(socket, socketWriter(socket)),
data: (socket, message) => this.#message(socket, message.toString()),
drain: socket => this.#drain(socket),
close: socket => this.#close(socket),
error: (socket, error) => this.#error(socket, error),
connectError: (_, error) => exit("Failed to start inspector:\n", error),
};
}
#open(connection: ConnectionOwner, writer: Writer): void {
const { data } = connection;
const { refEventLoop } = data;
const client = bufferedWriter(writer);
const backend = this.#createBackend(refEventLoop, (...messages: string[]) => {
for (const message of messages) {
client.write(message);
}
});
data.client = client;
data.backend = backend;
}
#message(connection: ConnectionOwner, message: string): void {
const { data } = connection;
const { backend } = data;
backend?.write(message);
}
#drain(connection: ConnectionOwner): void {
const { data } = connection;
const { client } = data;
client?.drain?.();
}
#close(connection: ConnectionOwner): void {
const { data } = connection;
const { backend } = data;
backend?.close();
}
#error(connection: ConnectionOwner, error: Error): void {
const { data } = connection;
const { backend } = data;
console.error(error);
backend?.close();
}
}
function terminalLink(url) {
if (colors) {
// bold + hyperlink + reset
return "\x1b[1m\x1b]8;;" + url + "\x1b\\" + url + "\x1b]8;;\x1b\\" + "\x1b[22m";
}
function versionInfo(): unknown {
return {
"Protocol-Version": "1.3",
"Browser": "Bun",
// @ts-ignore: Missing types for `navigator`
"User-Agent": navigator.userAgent,
"WebKit-Version": process.versions.webkit,
"Bun-Version": Bun.version,
"Bun-Revision": Bun.revision,
};
}
function webSocketWriter(ws: ServerWebSocket<unknown>): Writer {
return {
write: message => !!ws.sendText(message),
close: () => ws.close(),
};
}
function socketWriter(socket: Socket<unknown>): Writer {
return {
write: message => !!socket.write(message),
close: () => socket.end(),
};
}
function bufferedWriter(writer: Writer): Writer {
let draining = false;
let pendingMessages: string[] = [];
return {
write: message => {
if (draining || !writer.write(message)) {
pendingMessages.push(message);
}
return true;
},
drain: () => {
draining = true;
try {
for (let i = 0; i < pendingMessages.length; i++) {
if (!writer.write(pendingMessages[i])) {
pendingMessages = pendingMessages.slice(i);
return;
}
}
} finally {
draining = false;
}
},
close: () => {
writer.close();
pendingMessages.length = 0;
},
};
}
const defaultHostname = "localhost";
const defaultPort = 6499;
function parseUrl(url: string): URL {
try {
if (!url) {
return new URL(randomId(), `ws://${defaultHostname}:${defaultPort}/`);
} else if (url.startsWith("/")) {
return new URL(url, `ws://${defaultHostname}:${defaultPort}/`);
} else if (/^[a-z+]+:\/\//i.test(url)) {
return new URL(url);
} else if (/^\d+$/.test(url)) {
return new URL(randomId(), `ws://${defaultHostname}:${url}/`);
} else if (!url.includes("/") && url.includes(":")) {
return new URL(randomId(), `ws://${url}/`);
} else if (!url.includes(":")) {
const [hostname, pathname] = url.split("/", 2);
return new URL(`ws://${hostname}:${defaultPort}/${pathname}`);
} else {
return new URL(randomId(), `ws://${url}`);
}
} catch {
throw new TypeError(`Invalid hostname or URL: '${url}'`);
}
}
function randomId() {
return Math.random().toString(36).slice(2);
}
const { enableANSIColors } = Bun;
function dim(string: string): string {
if (enableANSIColors) {
return `\x1b[2m${string}\x1b[22m`;
}
return string;
}
function link(url: string): string {
if (enableANSIColors) {
return `\x1b[1m\x1b]8;;${url}\x1b\\${url}\x1b]8;;\x1b\\\x1b[22m`;
}
return url;
}
function dim(text) {
if (colors) {
return "\x1b[2m" + text + "\x1b[22m";
function reset(): string {
if (enableANSIColors) {
return "\x1b[49m";
}
return text;
return "";
}
class WebSocketListener {
server: BunType.Server;
url: string = "";
createInspectorConnection;
scriptExecutionContextId: number = 0;
activeConnections: Set<BunType.ServerWebSocket<DebuggerWithMessageQueue>> = new Set();
constructor(scriptExecutionContextId: number = 0, url: string, createInspectorConnection) {
this.scriptExecutionContextId = scriptExecutionContextId;
this.createInspectorConnection = createInspectorConnection;
this.server = this.start(url);
}
start(url: string): BunType.Server {
let defaultHostname = "localhost";
let usingDefaultPort = false;
let isUnix = false;
if (url.startsWith("ws+unix://")) {
isUnix = true;
url = url.slice(10);
} else if (/^[0-9]*$/.test(url)) {
url = "ws://" + defaultHostname + ":" + url + generatePath();
} else if (!url || url.startsWith("/")) {
url = "ws://" + defaultHostname + ":" + defaultPort + generatePath();
usingDefaultPort = true;
} else if (url.includes(":") && !url.includes("://")) {
try {
const insertSlash = !url.includes("/");
url = new URL("ws://" + url).href;
if (insertSlash) {
url += generatePath().slice(1);
}
} catch (e) {
console.error("[Inspector]", "Failed to parse url", '"' + url + '"');
process.exit(1);
}
}
if (!isUnix) {
try {
var { hostname, port, pathname } = new URL(url);
this.url = pathname.toLowerCase();
} catch (e) {
console.error("[Inspector]", "Failed to parse url", '"' + url + '"');
process.exit(1);
}
}
const serveOptions: BunType.WebSocketServeOptions<DebuggerWithMessageQueue> = {
...(isUnix ? { unix: url } : { hostname }),
development: false,
// @ts-ignore
reusePort: false,
websocket: {
idleTimeout: 0,
open: socket => {
var connection = new DebuggerWithMessageQueue();
// @ts-expect-error
const shouldRefEventLoop = !!socket.data?.shouldRefEventLoop;
socket.data = connection;
this.activeConnections.add(socket);
connection.debugger = this.createInspectorConnection(
this.scriptExecutionContextId,
shouldRefEventLoop,
(...msgs: string[]) => {
if (socket.readyState > 1) {
connection.disconnect();
return;
}
if (connection.messageQueue.length > 0) {
connection.messageQueue.push(...msgs);
return;
}
for (let i = 0; i < msgs.length; i++) {
if (!socket.sendText(msgs[i])) {
if (socket.readyState < 2) {
connection.messageQueue.push(...msgs.slice(i));
}
return;
}
}
},
);
if (!isUnix) {
console.log(
"[Inspector]",
"Connection #" + connection.count + " opened",
"(" +
new Intl.DateTimeFormat(undefined, {
"timeStyle": "long",
"dateStyle": "short",
}).format(new Date()) +
")",
);
}
},
drain: socket => {
const queue = socket.data.messageQueue;
for (let i = 0; i < queue.length; i++) {
if (!socket.sendText(queue[i])) {
socket.data.messageQueue = queue.slice(i);
return;
}
}
queue.length = 0;
},
message: (socket, message) => {
if (typeof message !== "string") {
console.warn("[Inspector]", "Received non-string message");
return;
}
socket.data.send(message as string);
},
close: socket => {
socket.data.disconnect();
if (!isUnix) {
console.log(
"[Inspector]",
"Connection #" + socket.data.count + " closed",
"(" +
new Intl.DateTimeFormat(undefined, {
"timeStyle": "long",
"dateStyle": "short",
}).format(new Date()) +
")",
);
}
this.activeConnections.delete(socket);
},
},
fetch: (req, server) => {
let { pathname } = new URL(req.url);
pathname = pathname.toLowerCase();
if (pathname === "/json/version") {
return Response.json({
"Browser": navigator.userAgent,
"WebKit-Version": process.versions.webkit,
"Bun-Version": Bun.version,
"Bun-Revision": Bun.revision,
});
}
if (!this.url || pathname === this.url) {
const refHeader = req.headers.get("Ref-Event-Loop");
if (
server.upgrade(req, {
data: {
shouldRefEventLoop: !!refHeader && refHeader !== "0",
},
})
) {
return new Response();
}
return new Response("WebSocket expected", {
status: 400,
});
}
return new Response("Not found", {
status: 404,
});
},
};
if (port === "") {
port = defaultPort + "";
}
let portNumber = Number(port);
var server, lastError;
if (usingDefaultPort) {
for (let tries = 0; tries < 10 && !server; tries++) {
try {
lastError = undefined;
server = Bun.serve<DebuggerWithMessageQueue>({
...serveOptions,
port: portNumber++,
});
if (isUnix) {
notify();
}
} catch (e) {
lastError = e;
}
}
} else {
try {
server = Bun.serve<DebuggerWithMessageQueue>({
...serveOptions,
port: portNumber,
});
if (isUnix) {
notify();
}
} catch (e) {
lastError = e;
}
}
if (!server) {
console.error("[Inspector]", "Failed to start server");
if (lastError) console.error(lastError);
process.exit(1);
}
let textToWrite = "";
function writeToConsole(text) {
textToWrite += text;
}
function flushToConsole() {
console.write(textToWrite);
}
if (!this.url) {
return server;
}
// yellow foreground
writeToConsole(dim(`------------------ Bun Inspector ------------------` + "\n"));
if (colors) {
// reset background
writeToConsole("\x1b[49m");
}
writeToConsole(
"Listening at:\n " +
`ws://${hostname}:${server.port}${this.url}` +
"\n\n" +
"Inspect in browser:\n " +
terminalLink(new URL(`https://debug.bun.sh#${server.hostname}:${server.port}${this.url}`).href) +
"\n",
);
writeToConsole(dim(`------------------ Bun Inspector ------------------` + "\n"));
flushToConsole();
return server;
}
}
function notify(): void {
const unix = process.env["BUN_INSPECT_NOTIFY"];
if (!unix || !unix.startsWith("unix://")) {
return;
}
function notify(unix: string): void {
Bun.connect({
unix: unix.slice(7),
unix,
socket: {
open: socket => {
socket.end("1");
@@ -311,26 +324,27 @@ function notify(): void {
data: () => {}, // required or it errors
},
}).finally(() => {
// Do nothing
// Best-effort
});
}
interface Debugger {
send(msg: string): void;
disconnect(): void;
function exit(...args: unknown[]): never {
console.error(...args);
process.exit(1);
}
var listener: WebSocketListener;
type ConnectionOwner = {
data: Connection;
};
export default function start(debuggerId, hostOrPort, createInspectorConnection, sendFn, disconnectFn) {
try {
sendFn_ = sendFn;
disconnectFn_ = disconnectFn;
globalThis.listener = listener ||= new WebSocketListener(debuggerId, hostOrPort, createInspectorConnection);
} catch (e) {
console.error("Bun Inspector threw an exception\n", e);
process.exit(1);
}
type Connection = {
refEventLoop: boolean;
client?: Writer;
backend?: Writer;
};
return `http://${listener.server.hostname}:${listener.server.port}${listener.url}`;
}
type Writer = {
write: (message: string) => boolean;
drain?: () => void;
close: () => void;
};

File diff suppressed because one or more lines are too long