initial commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
protocol/*/protocol.json linguist-generated=true
|
||||
protocol/*/index.d.ts linguist-generated=true
|
||||
@@ -0,0 +1 @@
|
||||
protocol/*.json
|
||||
@@ -0,0 +1,3 @@
|
||||
# bun-debug-adapter-protocol
|
||||
|
||||
https://microsoft.github.io/debug-adapter-protocol/overview
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "bun-debug-adapter-protocol",
|
||||
"dependencies": {
|
||||
"semver": "^7.5.4",
|
||||
"source-map-js": "^1.0.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
|
||||
"semver": ["[email protected]", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA=="],
|
||||
|
||||
"source-map-js": ["[email protected]", "", {}, "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw=="],
|
||||
|
||||
"yallist": ["[email protected]", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./src/debugger/adapter";
|
||||
export * from "./src/debugger/signal";
|
||||
export type * from "./src/protocol";
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "bun-debug-adapter-protocol",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"semver": "^7.5.4",
|
||||
"source-map-js": "^1.0.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import type { Protocol, Type } from "../src/protocol/schema";
|
||||
|
||||
run().catch(console.error);
|
||||
|
||||
async function run() {
|
||||
const cwd = new URL("../src/protocol/", import.meta.url);
|
||||
const runner = "Bun" in globalThis ? "bunx" : "npx";
|
||||
const write = (name: string, data: string) => {
|
||||
const path = new URL(name, cwd);
|
||||
writeFileSync(path, data);
|
||||
spawnSync(runner, ["prettier", "--write", path.pathname], { cwd, stdio: "ignore" });
|
||||
};
|
||||
const schema: Protocol = await download(
|
||||
"https://microsoft.github.io/debug-adapter-protocol/debugAdapterProtocol.json",
|
||||
);
|
||||
write("protocol.json", JSON.stringify(schema));
|
||||
const types = formatProtocol(schema);
|
||||
write("index.d.ts", `// GENERATED - DO NOT EDIT\n${types}`);
|
||||
}
|
||||
|
||||
function formatProtocol(protocol: Protocol, extraTs?: string): string {
|
||||
const { definitions } = protocol;
|
||||
const requestMap = new Map();
|
||||
const responseMap = new Map();
|
||||
const eventMap = new Map();
|
||||
let body = `export namespace DAP {`;
|
||||
loop: for (const [key, definition] of Object.entries(definitions)) {
|
||||
if (/[a-z]+Request$/i.test(key)) {
|
||||
continue;
|
||||
}
|
||||
if (/[a-z]+Arguments$/i.test(key)) {
|
||||
const name = key.replace(/(Request)?Arguments$/, "");
|
||||
const requestName = `${name}Request`;
|
||||
requestMap.set(toMethod(name), requestName);
|
||||
body += formatType(definition, requestName);
|
||||
continue;
|
||||
}
|
||||
if ("allOf" in definition) {
|
||||
const { allOf } = definition;
|
||||
for (const type of allOf) {
|
||||
if (type.type !== "object") {
|
||||
continue;
|
||||
}
|
||||
const { description, properties = {} } = type;
|
||||
if (/[a-z]+Event$/i.test(key)) {
|
||||
const { event, body: type = {} } = properties;
|
||||
if (!event || !("enum" in event)) {
|
||||
continue;
|
||||
}
|
||||
const [eventKey] = event.enum ?? [];
|
||||
eventMap.set(eventKey, key);
|
||||
const eventType: Type = {
|
||||
type: "object",
|
||||
description,
|
||||
...type,
|
||||
};
|
||||
body += formatType(eventType, key);
|
||||
continue loop;
|
||||
}
|
||||
if (/[a-z]+Response$/i.test(key)) {
|
||||
const { body: type = {} } = properties;
|
||||
const bodyType: Type = {
|
||||
type: "object",
|
||||
description,
|
||||
...type,
|
||||
};
|
||||
const name = key.replace(/Response$/, "");
|
||||
responseMap.set(toMethod(name), key);
|
||||
body += formatType(bodyType, key);
|
||||
continue loop;
|
||||
}
|
||||
}
|
||||
}
|
||||
body += formatType(definition, key);
|
||||
}
|
||||
for (const [key, name] of responseMap) {
|
||||
if (requestMap.has(key)) {
|
||||
continue;
|
||||
}
|
||||
const requestName = `${name.replace(/Response$/, "")}Request`;
|
||||
requestMap.set(key, requestName);
|
||||
body += formatType({ type: "object", properties: {} }, requestName);
|
||||
}
|
||||
body += formatMapType("RequestMap", requestMap);
|
||||
body += formatMapType("ResponseMap", responseMap);
|
||||
body += formatMapType("EventMap", eventMap);
|
||||
if (extraTs) {
|
||||
body += extraTs;
|
||||
}
|
||||
return body + "};";
|
||||
}
|
||||
|
||||
function formatMapType(key: string, typeMap: Map<string, string>): string {
|
||||
const type: Type = {
|
||||
type: "object",
|
||||
required: [...typeMap.keys()],
|
||||
properties: Object.fromEntries([...typeMap.entries()].map(([key, value]) => [key, { $ref: value }])),
|
||||
};
|
||||
return formatType(type, key);
|
||||
}
|
||||
|
||||
function formatType(type: Type, key?: string): string {
|
||||
const { description, type: kind } = type;
|
||||
let body = "";
|
||||
if (key) {
|
||||
if (description) {
|
||||
body += `\n${toComment(description)}\n`;
|
||||
}
|
||||
body += `export type ${key}=`;
|
||||
}
|
||||
if (kind === "boolean") {
|
||||
body += "boolean";
|
||||
} else if (kind === "number" || kind === "integer") {
|
||||
body += "number";
|
||||
} else if (kind === "string") {
|
||||
const { enum: choices } = type;
|
||||
if (choices) {
|
||||
body += choices.map(value => `"${value}"`).join("|");
|
||||
} else {
|
||||
body += "string";
|
||||
}
|
||||
} else if (kind === "array") {
|
||||
const { items } = type;
|
||||
const itemType = items ? formatType(items) : "unknown";
|
||||
body += `${itemType}[]`;
|
||||
} else if (kind === "object") {
|
||||
const { properties, required } = type;
|
||||
if (!properties || Object.keys(properties).length === 0) {
|
||||
body += "{}";
|
||||
} else {
|
||||
body += "{";
|
||||
for (const [key, { description, ...type }] of Object.entries(properties)) {
|
||||
if (description) {
|
||||
body += `\n${toComment(description)}`;
|
||||
}
|
||||
const delimit = required?.includes(key) ? ":" : "?:";
|
||||
body += `\n${key}${delimit}${formatType(type)};`;
|
||||
}
|
||||
body += "}";
|
||||
}
|
||||
} else if ("$ref" in type) {
|
||||
const { $ref: ref } = type;
|
||||
body += ref.split("/").pop() || "unknown";
|
||||
} else if ("allOf" in type) {
|
||||
const { allOf } = type;
|
||||
body += allOf.map(type => formatType(type)).join("&");
|
||||
} else {
|
||||
body += "unknown";
|
||||
}
|
||||
if (key) {
|
||||
body += ";";
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function toMethod(name: string): string {
|
||||
return `${name.substring(0, 1).toLowerCase()}${name.substring(1)}`;
|
||||
}
|
||||
|
||||
function toComment(description?: string): string {
|
||||
if (!description) {
|
||||
return "";
|
||||
}
|
||||
const lines = ["/**", ...description.split("\n").map(line => ` * ${line.trim()}`), "*/"];
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function download<T>(url: string | URL): Promise<T> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download ${url}: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
"use strict";
|
||||
export default {
|
||||
fetch(request) {
|
||||
const animal = getAnimal(request.url);
|
||||
const voice = animal.talk();
|
||||
return new Response(voice);
|
||||
},
|
||||
};
|
||||
function getAnimal(query) {
|
||||
switch (query.split("/").pop()) {
|
||||
case "dog":
|
||||
return new Dog();
|
||||
case "cat":
|
||||
return new Cat();
|
||||
}
|
||||
return new Bird();
|
||||
}
|
||||
class Dog {
|
||||
name = "dog";
|
||||
talk() {
|
||||
return "woof";
|
||||
}
|
||||
}
|
||||
class Cat {
|
||||
name = "cat";
|
||||
talk() {
|
||||
return "meow";
|
||||
}
|
||||
}
|
||||
class Bird {
|
||||
name = "bird";
|
||||
talk() {
|
||||
return "chirp";
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsicGFja2FnZXMvYnVuLWRlYnVnLWFkYXB0ZXItcHJvdG9jb2wvZGVidWdnZXIvZml4dHVyZXMvd2l0aC1zb3VyY2VtYXAudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImV4cG9ydCBkZWZhdWx0IHtcbiAgZmV0Y2gocmVxdWVzdDogUmVxdWVzdCk6IFJlc3BvbnNlIHtcbiAgICBjb25zdCBhbmltYWwgPSBnZXRBbmltYWwocmVxdWVzdC51cmwpO1xuICAgIGNvbnN0IHZvaWNlID0gYW5pbWFsLnRhbGsoKTtcbiAgICByZXR1cm4gbmV3IFJlc3BvbnNlKHZvaWNlKTtcbiAgfSxcbn07XG5cbmZ1bmN0aW9uIGdldEFuaW1hbChxdWVyeTogc3RyaW5nKTogQW5pbWFsIHtcbiAgc3dpdGNoIChxdWVyeS5zcGxpdChcIi9cIikucG9wKCkpIHtcbiAgICBjYXNlIFwiZG9nXCI6XG4gICAgICByZXR1cm4gbmV3IERvZygpO1xuICAgIGNhc2UgXCJjYXRcIjpcbiAgICAgIHJldHVybiBuZXcgQ2F0KCk7XG4gIH1cbiAgcmV0dXJuIG5ldyBCaXJkKCk7XG59XG5cbmludGVyZmFjZSBBbmltYWwge1xuICByZWFkb25seSBuYW1lOiBzdHJpbmc7XG4gIHRhbGsoKTogc3RyaW5nO1xufVxuXG5jbGFzcyBEb2cgaW1wbGVtZW50cyBBbmltYWwge1xuICBuYW1lID0gXCJkb2dcIjtcblxuICB0YWxrKCk6IHN0cmluZyB7XG4gICAgcmV0dXJuIFwid29vZlwiO1xuICB9XG59XG5cbmNsYXNzIENhdCBpbXBsZW1lbnRzIEFuaW1hbCB7XG4gIG5hbWUgPSBcImNhdFwiO1xuXG4gIHRhbGsoKTogc3RyaW5nIHtcbiAgICByZXR1cm4gXCJtZW93XCI7XG4gIH1cbn1cblxuY2xhc3MgQmlyZCBpbXBsZW1lbnRzIEFuaW1hbCB7XG4gIG5hbWUgPSBcImJpcmRcIjtcblxuICB0YWxrKCk6IHN0cmluZyB7XG4gICAgcmV0dXJuIFwiY2hpcnBcIjtcbiAgfVxufVxuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUFBLGVBQWU7QUFBQSxFQUNiLE1BQU0sU0FBNEI7QUFDaEMsVUFBTSxTQUFTLFVBQVUsUUFBUSxHQUFHO0FBQ3BDLFVBQU0sUUFBUSxPQUFPLEtBQUs7QUFDMUIsV0FBTyxJQUFJLFNBQVMsS0FBSztBQUFBLEVBQzNCO0FBQ0Y7QUFFQSxTQUFTLFVBQVUsT0FBdUI7QUFDeEMsVUFBUSxNQUFNLE1BQU0sR0FBRyxFQUFFLElBQUksR0FBRztBQUFBLElBQzlCLEtBQUs7QUFDSCxhQUFPLElBQUksSUFBSTtBQUFBLElBQ2pCLEtBQUs7QUFDSCxhQUFPLElBQUksSUFBSTtBQUFBLEVBQ25CO0FBQ0EsU0FBTyxJQUFJLEtBQUs7QUFDbEI7QUFPQSxNQUFNLElBQXNCO0FBQUEsRUFDMUIsT0FBTztBQUFBLEVBRVAsT0FBZTtBQUNiLFdBQU87QUFBQSxFQUNUO0FBQ0Y7QUFFQSxNQUFNLElBQXNCO0FBQUEsRUFDMUIsT0FBTztBQUFBLEVBRVAsT0FBZTtBQUNiLFdBQU87QUFBQSxFQUNUO0FBQ0Y7QUFFQSxNQUFNLEtBQXVCO0FBQUEsRUFDM0IsT0FBTztBQUFBLEVBRVAsT0FBZTtBQUNiLFdBQU87QUFBQSxFQUNUO0FBQ0Y7IiwKICAibmFtZXMiOiBbXQp9Cg==
|
||||
@@ -0,0 +1,46 @@
|
||||
export default {
|
||||
fetch(request: Request): Response {
|
||||
const animal = getAnimal(request.url);
|
||||
const voice = animal.talk();
|
||||
return new Response(voice);
|
||||
},
|
||||
};
|
||||
|
||||
function getAnimal(query: string): Animal {
|
||||
switch (query.split("/").pop()) {
|
||||
case "dog":
|
||||
return new Dog();
|
||||
case "cat":
|
||||
return new Cat();
|
||||
}
|
||||
return new Bird();
|
||||
}
|
||||
|
||||
interface Animal {
|
||||
readonly name: string;
|
||||
talk(): string;
|
||||
}
|
||||
|
||||
class Dog implements Animal {
|
||||
name = "dog";
|
||||
|
||||
talk(): string {
|
||||
return "woof";
|
||||
}
|
||||
}
|
||||
|
||||
class Cat implements Animal {
|
||||
name = "cat";
|
||||
|
||||
talk(): string {
|
||||
return "meow";
|
||||
}
|
||||
}
|
||||
|
||||
class Bird implements Animal {
|
||||
name = "bird";
|
||||
|
||||
talk(): string {
|
||||
return "chirp";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export default {
|
||||
fetch(request) {
|
||||
return new Response(a());
|
||||
},
|
||||
};
|
||||
|
||||
function a() {
|
||||
return b();
|
||||
}
|
||||
|
||||
function b() {
|
||||
return c();
|
||||
}
|
||||
|
||||
function c() {
|
||||
function d() {
|
||||
return "hello";
|
||||
}
|
||||
return d();
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Socket } from "node:net";
|
||||
const enum FramerState {
|
||||
WaitingForLength,
|
||||
WaitingForMessage,
|
||||
}
|
||||
|
||||
let socketFramerMessageLengthBuffer: Buffer;
|
||||
export class SocketFramer {
|
||||
state: FramerState = FramerState.WaitingForLength;
|
||||
pendingLength: number = 0;
|
||||
sizeBuffer: Buffer = Buffer.alloc(4);
|
||||
sizeBufferIndex: number = 0;
|
||||
bufferedData: Buffer = Buffer.alloc(0);
|
||||
socket: Socket;
|
||||
private onMessage: (message: string | string[]) => void;
|
||||
|
||||
constructor(socket: Socket, onMessage: (message: string | string[]) => void) {
|
||||
this.socket = socket;
|
||||
this.onMessage = onMessage;
|
||||
|
||||
if (!socketFramerMessageLengthBuffer) {
|
||||
socketFramerMessageLengthBuffer = Buffer.alloc(4);
|
||||
}
|
||||
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.state = FramerState.WaitingForLength;
|
||||
this.bufferedData = Buffer.alloc(0);
|
||||
this.sizeBufferIndex = 0;
|
||||
this.sizeBuffer = Buffer.alloc(4);
|
||||
}
|
||||
|
||||
send(data: string): void {
|
||||
socketFramerMessageLengthBuffer.writeUInt32BE(Buffer.byteLength(data), 0);
|
||||
this.socket.write(socketFramerMessageLengthBuffer);
|
||||
this.socket.write(data);
|
||||
}
|
||||
|
||||
onData(data: Buffer): void {
|
||||
this.bufferedData = this.bufferedData.length > 0 ? Buffer.concat([this.bufferedData, data]) : data;
|
||||
|
||||
let messagesToDeliver: string[] = [];
|
||||
let position = 0;
|
||||
|
||||
while (position < this.bufferedData.length) {
|
||||
// Need 4 bytes for the length
|
||||
if (this.bufferedData.length - position < 4) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Read the length prefix
|
||||
const messageLength = this.bufferedData.readUInt32BE(position);
|
||||
|
||||
// Validate message length
|
||||
if (messageLength <= 0 || messageLength > 1024 * 1024) {
|
||||
// 1MB max
|
||||
// Try to resync by looking for the next valid message
|
||||
let newPosition = position + 1;
|
||||
let found = false;
|
||||
|
||||
while (newPosition < this.bufferedData.length - 4) {
|
||||
const testLength = this.bufferedData.readUInt32BE(newPosition);
|
||||
|
||||
if (testLength > 0 && testLength <= 1024 * 1024) {
|
||||
// Verify we can read the full message
|
||||
if (this.bufferedData.length - newPosition - 4 >= testLength) {
|
||||
const testMessage = this.bufferedData.toString("utf-8", newPosition + 4, newPosition + 4 + testLength);
|
||||
|
||||
if (testMessage.startsWith('{"')) {
|
||||
position = newPosition;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newPosition++;
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
// Couldn't find a valid message, discard buffer up to this point
|
||||
this.bufferedData = this.bufferedData.slice(position + 4);
|
||||
return;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we have the complete message
|
||||
if (this.bufferedData.length - position - 4 < messageLength) {
|
||||
break;
|
||||
}
|
||||
|
||||
const message = this.bufferedData.toString("utf-8", position + 4, position + 4 + messageLength);
|
||||
if (message.startsWith('{"')) {
|
||||
messagesToDeliver.push(message);
|
||||
}
|
||||
|
||||
position += 4 + messageLength;
|
||||
}
|
||||
|
||||
if (position > 0) {
|
||||
this.bufferedData =
|
||||
position < this.bufferedData.length ? this.bufferedData.slice(position) : SocketFramer.emptyBuffer;
|
||||
}
|
||||
|
||||
if (messagesToDeliver.length === 1) {
|
||||
this.onMessage(messagesToDeliver[0]);
|
||||
} else if (messagesToDeliver.length > 1) {
|
||||
this.onMessage(messagesToDeliver);
|
||||
}
|
||||
}
|
||||
|
||||
private static emptyBuffer = Buffer.from([]);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Server, Socket } from "node:net";
|
||||
import { createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const isDebug = process.env.NODE_ENV === "development";
|
||||
|
||||
export type UnixSignalEventMap = {
|
||||
"Signal.listening": [string];
|
||||
"Signal.error": [Error];
|
||||
"Signal.received": [string];
|
||||
"Signal.closed": [];
|
||||
"Signal.Socket.closed": [socket: Socket];
|
||||
"Signal.Socket.connect": [socket: Socket];
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts a server that listens for signals on a UNIX domain socket.
|
||||
*/
|
||||
export class UnixSignal extends EventEmitter<UnixSignalEventMap> {
|
||||
#path: string;
|
||||
#server: Server;
|
||||
#ready: Promise<void>;
|
||||
|
||||
constructor(path?: string | URL | undefined) {
|
||||
super();
|
||||
this.#path = path ? parseUnixPath(path) : randomUnixPath();
|
||||
this.#server = createServer();
|
||||
this.#server.on("listening", () => this.emit("Signal.listening", this.#path));
|
||||
this.#server.on("error", error => this.emit("Signal.error", error));
|
||||
this.#server.on("close", () => this.emit("Signal.closed"));
|
||||
this.#server.on("connection", socket => {
|
||||
this.emit("Signal.Socket.connect", socket);
|
||||
socket.on("data", data => {
|
||||
this.emit("Signal.received", data.toString());
|
||||
});
|
||||
socket.on("close", () => {
|
||||
this.emit("Signal.Socket.closed", socket);
|
||||
});
|
||||
});
|
||||
this.#ready = new Promise((resolve, reject) => {
|
||||
this.#server.on("listening", resolve);
|
||||
this.#server.on("error", reject);
|
||||
});
|
||||
this.#server.listen(this.#path);
|
||||
}
|
||||
|
||||
emit<E extends keyof UnixSignalEventMap>(event: E, ...args: UnixSignalEventMap[E]): boolean {
|
||||
if (isDebug) {
|
||||
console.log(event, ...args);
|
||||
}
|
||||
|
||||
return super.emit(event, ...(args as never));
|
||||
}
|
||||
|
||||
/**
|
||||
* The path to the UNIX domain socket.
|
||||
*/
|
||||
get url(): string {
|
||||
return `unix://${this.#path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves when the server is listening or rejects if an error occurs.
|
||||
*/
|
||||
get ready(): Promise<void> {
|
||||
return this.#ready;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the server.
|
||||
*/
|
||||
close(): void {
|
||||
this.#server.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function randomUnixPath(): string {
|
||||
return join(tmpdir(), `${randomBytes(16).toString("hex")}.sock`);
|
||||
}
|
||||
|
||||
function parseUnixPath(path: string | URL): string {
|
||||
if (typeof path === "string" && path.startsWith("/")) {
|
||||
return path;
|
||||
}
|
||||
try {
|
||||
const { pathname } = new URL(path);
|
||||
return pathname;
|
||||
} catch {
|
||||
throw new Error(`Invalid UNIX path: ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
export type TCPSocketSignalEventMap = {
|
||||
"Signal.listening": [];
|
||||
"Signal.error": [Error];
|
||||
"Signal.closed": [];
|
||||
"Signal.received": [string];
|
||||
"Signal.Socket.closed": [socket: Socket];
|
||||
"Signal.Socket.connect": [socket: Socket];
|
||||
};
|
||||
|
||||
export class TCPSocketSignal extends EventEmitter {
|
||||
#port: number;
|
||||
#server: ReturnType<typeof createServer>;
|
||||
#ready: Promise<void>;
|
||||
|
||||
constructor(port: number) {
|
||||
super();
|
||||
this.#port = port;
|
||||
|
||||
this.#server = createServer((socket: Socket) => {
|
||||
this.emit("Signal.Socket.connect", socket);
|
||||
|
||||
socket.on("data", data => {
|
||||
this.emit("Signal.received", data.toString());
|
||||
});
|
||||
|
||||
socket.on("error", error => {
|
||||
this.emit("Signal.error", error);
|
||||
});
|
||||
|
||||
socket.on("close", () => {
|
||||
this.emit("Signal.Socket.closed", socket);
|
||||
});
|
||||
});
|
||||
|
||||
this.#server.on("close", () => {
|
||||
this.emit("Signal.closed");
|
||||
});
|
||||
|
||||
this.#ready = new Promise((resolve, reject) => {
|
||||
this.#server.listen(this.#port, "127.0.0.1", () => {
|
||||
const address = this.#server.address();
|
||||
if (address && typeof address === "object") {
|
||||
this.#port = address.port;
|
||||
}
|
||||
this.emit("Signal.listening");
|
||||
resolve();
|
||||
});
|
||||
this.#server.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
emit<E extends keyof TCPSocketSignalEventMap>(event: E, ...args: TCPSocketSignalEventMap[E]): boolean {
|
||||
if (isDebug) {
|
||||
console.log(event, ...args);
|
||||
}
|
||||
return super.emit(event, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* The TCP port.
|
||||
*/
|
||||
get port(): number {
|
||||
return this.#port;
|
||||
}
|
||||
|
||||
get url(): string {
|
||||
return `tcp://127.0.0.1:${this.#port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves when the server is listening or rejects if an error occurs.
|
||||
*/
|
||||
get ready(): Promise<void> {
|
||||
return this.#ready;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the server.
|
||||
*/
|
||||
close(): void {
|
||||
this.#server.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { connect } from "node:net";
|
||||
import { networkInterfaces } from "node:os";
|
||||
import { getRandomId, WebSocketDebugAdapter } from "./adapter.js";
|
||||
import { TCPSocketSignal } from "./signal.js";
|
||||
import { SourceMap } from "./sourcemap.js";
|
||||
|
||||
test("works without source map", () => {
|
||||
const sourceMap = getSourceMap("without-sourcemap.js");
|
||||
expect(sourceMap.generatedLocation({ line: 7 })).toEqual({ line: 7, column: 0, verified: true });
|
||||
expect(sourceMap.generatedLocation({ line: 7, column: 2 })).toEqual({ line: 7, column: 2, verified: true });
|
||||
expect(sourceMap.originalLocation({ line: 11 })).toEqual({ line: 11, column: 0, verified: true });
|
||||
expect(sourceMap.originalLocation({ line: 11, column: 2 })).toEqual({ line: 11, column: 2, verified: true });
|
||||
});
|
||||
|
||||
test("works with source map", () => {
|
||||
const sourceMap = getSourceMap("with-sourcemap.js");
|
||||
// FIXME: Columns don't appear to be accurate for `generatedLocation`
|
||||
expect(sourceMap.generatedLocation({ line: 3 })).toMatchObject({ line: 4, verified: true });
|
||||
expect(sourceMap.generatedLocation({ line: 27 })).toMatchObject({ line: 20, verified: true });
|
||||
expect(sourceMap.originalLocation({ line: 32 })).toEqual({ line: 43, column: 4, verified: true });
|
||||
expect(sourceMap.originalLocation({ line: 13 })).toEqual({ line: 13, column: 6, verified: true });
|
||||
});
|
||||
|
||||
function getSourceMap(filename: string): SourceMap {
|
||||
const { pathname } = new URL(`./fixtures/${filename}`, import.meta.url);
|
||||
const source = readFileSync(pathname, "utf-8");
|
||||
const match = source.match(/\/\/# sourceMappingURL=(.*)$/m);
|
||||
if (match) {
|
||||
const [, url] = match;
|
||||
return SourceMap(url);
|
||||
}
|
||||
return SourceMap();
|
||||
}
|
||||
|
||||
test("only forwards inspector events from known protocol domains to the adapter", () => {
|
||||
const adapter = new WebSocketDebugAdapter();
|
||||
|
||||
// Replace the launch request handler so a (wrongly) forwarded event is observable
|
||||
// without spawning any process.
|
||||
const launchCalls: unknown[][] = [];
|
||||
(adapter as any).launch = (...args: unknown[]) => {
|
||||
launchCalls.push(args);
|
||||
};
|
||||
|
||||
const inspector = adapter.getInspector();
|
||||
|
||||
// The WebSocket inspector re-emits any message without an "id" using the method name
|
||||
// chosen by the remote peer. An event named after a DAP request must not be forwarded
|
||||
// to the adapter, where it would be dispatched to the matching request handler.
|
||||
(inspector as any).emit("launch", {
|
||||
runtime: "/bin/sh",
|
||||
runtimeArgs: ["-c", "echo unexpected"],
|
||||
program: "example.js",
|
||||
});
|
||||
expect(launchCalls).toHaveLength(0);
|
||||
|
||||
// A genuine inspector-domain event still reaches listeners registered on the adapter.
|
||||
const heapEvents: unknown[] = [];
|
||||
adapter.on("Heap.garbageCollected", event => {
|
||||
heapEvents.push(event);
|
||||
});
|
||||
(inspector as any).emit("Heap.garbageCollected", {
|
||||
collection: { type: "full", startTime: 0, endTime: 1 },
|
||||
});
|
||||
expect(heapEvents).toEqual([{ collection: { type: "full", startTime: 0, endTime: 1 } }]);
|
||||
});
|
||||
|
||||
test("getRandomId returns a distinct 32-character lowercase hex string on every call", () => {
|
||||
const ids = new Set<string>();
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const id = getRandomId();
|
||||
expect(id).toMatch(/^[0-9a-f]{32}$/);
|
||||
ids.add(id);
|
||||
}
|
||||
expect(ids.size).toBe(256);
|
||||
});
|
||||
|
||||
test("TCPSocketSignal accepts connections only on the loopback interface", async () => {
|
||||
// Same construction the VS Code extension uses (diagnostics.ts createSignal).
|
||||
const signal = new TCPSocketSignal(0);
|
||||
await signal.ready;
|
||||
const port = signal.port;
|
||||
|
||||
try {
|
||||
// The legitimate local client connects over loopback and its payload is delivered.
|
||||
const received = new Promise<string>(resolve => signal.once("Signal.received", resolve));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const client = connect({ host: "127.0.0.1", port }, () => {
|
||||
client.end("hello");
|
||||
resolve();
|
||||
});
|
||||
client.on("error", reject);
|
||||
});
|
||||
expect(await received).toBe("hello");
|
||||
|
||||
// The same port is not reachable through a non-loopback interface address.
|
||||
let external: string | undefined;
|
||||
for (const addresses of Object.values(networkInterfaces())) {
|
||||
for (const { family, internal, address } of addresses ?? []) {
|
||||
if (family === "IPv4" && !internal) {
|
||||
external = address;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (external) break;
|
||||
}
|
||||
|
||||
if (external) {
|
||||
const externalHost = external;
|
||||
const connectError = await new Promise<Error | null>(resolve => {
|
||||
const client = connect({ host: externalHost, port }, () => {
|
||||
client.end();
|
||||
resolve(null);
|
||||
});
|
||||
client.on("error", error => resolve(error));
|
||||
});
|
||||
expect(connectError).not.toBeNull();
|
||||
}
|
||||
} finally {
|
||||
signal.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { LineRange, MappedPosition } from "source-map-js";
|
||||
import { SourceMapConsumer } from "source-map-js";
|
||||
|
||||
export type LocationRequest = {
|
||||
line?: number;
|
||||
column?: number;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
export type Location = {
|
||||
line: number; // 0-based
|
||||
column: number; // 0-based
|
||||
} & (
|
||||
| {
|
||||
verified: true;
|
||||
}
|
||||
| {
|
||||
verified?: false;
|
||||
message?: string;
|
||||
}
|
||||
);
|
||||
|
||||
export interface SourceMap {
|
||||
/**
|
||||
* Converts a location in the original source to a location in the generated source.
|
||||
* @param request A request
|
||||
*/
|
||||
generatedLocation(request: LocationRequest): Location;
|
||||
/**
|
||||
* Converts a location in the generated source to a location in the original source.
|
||||
* @param request A request
|
||||
*/
|
||||
originalLocation(request: LocationRequest): Location;
|
||||
}
|
||||
|
||||
class ActualSourceMap implements SourceMap {
|
||||
#sourceMap: SourceMapConsumer;
|
||||
#sources: string[];
|
||||
|
||||
constructor(sourceMap: SourceMapConsumer) {
|
||||
this.#sourceMap = sourceMap;
|
||||
this.#sources = (sourceMap as any)._absoluteSources;
|
||||
}
|
||||
|
||||
#getSource(url?: string): string {
|
||||
const sources = this.#sources;
|
||||
if (!sources.length) {
|
||||
return "";
|
||||
}
|
||||
if (sources.length === 1 || !url) {
|
||||
return sources[0];
|
||||
}
|
||||
for (const source of sources) {
|
||||
if (url.endsWith(source)) {
|
||||
return source;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
generatedLocation(request: LocationRequest): Location {
|
||||
const { line, column, url } = request;
|
||||
|
||||
let lineRange: LineRange;
|
||||
try {
|
||||
const source = this.#getSource(url);
|
||||
lineRange = this.#sourceMap.generatedPositionFor({
|
||||
line: lineTo1BasedLine(line),
|
||||
column: columnToColumn(column),
|
||||
source,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
line: lineToLine(line),
|
||||
column: columnToColumn(column),
|
||||
verified: false,
|
||||
message: unknownToError(error),
|
||||
};
|
||||
}
|
||||
|
||||
if (!locationIsValid(lineRange)) {
|
||||
return {
|
||||
line: lineToLine(line),
|
||||
column: columnToColumn(column),
|
||||
verified: false,
|
||||
};
|
||||
}
|
||||
|
||||
const { line: gline, column: gcolumn } = lineRange;
|
||||
return {
|
||||
line: lineTo0BasedLine(gline),
|
||||
column: columnToColumn(gcolumn),
|
||||
verified: true,
|
||||
};
|
||||
}
|
||||
|
||||
originalLocation(request: LocationRequest): Location {
|
||||
const { line, column } = request;
|
||||
|
||||
let mappedPosition: MappedPosition;
|
||||
try {
|
||||
mappedPosition = this.#sourceMap.originalPositionFor({
|
||||
line: lineTo1BasedLine(line),
|
||||
column: columnToColumn(column),
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
line: lineToLine(line),
|
||||
column: columnToColumn(column),
|
||||
verified: false,
|
||||
message: unknownToError(error),
|
||||
};
|
||||
}
|
||||
|
||||
if (!locationIsValid(mappedPosition)) {
|
||||
return {
|
||||
line: lineToLine(line),
|
||||
column: columnToColumn(column),
|
||||
verified: false,
|
||||
};
|
||||
}
|
||||
|
||||
const { line: oline, column: ocolumn } = mappedPosition;
|
||||
return {
|
||||
line: lineTo0BasedLine(oline),
|
||||
column: columnToColumn(ocolumn),
|
||||
verified: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class NoopSourceMap implements SourceMap {
|
||||
generatedLocation(request: LocationRequest): Location {
|
||||
const { line, column } = request;
|
||||
return {
|
||||
line: lineToLine(line),
|
||||
column: columnToColumn(column),
|
||||
verified: true,
|
||||
};
|
||||
}
|
||||
|
||||
originalLocation(request: LocationRequest): Location {
|
||||
const { line, column } = request;
|
||||
return {
|
||||
line: lineToLine(line),
|
||||
column: columnToColumn(column),
|
||||
verified: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const defaultSourceMap = new NoopSourceMap();
|
||||
|
||||
export function SourceMap(url?: string): SourceMap {
|
||||
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");
|
||||
const schema = JSON.parse(decoded);
|
||||
const sourceMap = new SourceMapConsumer(schema);
|
||||
return new ActualSourceMap(sourceMap);
|
||||
} catch (error) {
|
||||
console.warn("Failed to parse source map URL", url);
|
||||
}
|
||||
return defaultSourceMap;
|
||||
}
|
||||
|
||||
function lineTo1BasedLine(line?: number): number {
|
||||
return numberIsValid(line) ? line + 1 : 1;
|
||||
}
|
||||
|
||||
function lineTo0BasedLine(line?: number): number {
|
||||
return numberIsValid(line) ? line - 1 : 0;
|
||||
}
|
||||
|
||||
function lineToLine(line?: number): number {
|
||||
return numberIsValid(line) ? line : 0;
|
||||
}
|
||||
|
||||
function columnToColumn(column?: number): number {
|
||||
return numberIsValid(column) ? column : 0;
|
||||
}
|
||||
|
||||
function locationIsValid(location: Location): location is Location {
|
||||
const { line, column } = location;
|
||||
return numberIsValid(line) && numberIsValid(column);
|
||||
}
|
||||
|
||||
function numberIsValid(number?: number): number is number {
|
||||
return typeof number === "number" && isFinite(number) && number >= 0;
|
||||
}
|
||||
|
||||
function unknownToError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
const { message } = error;
|
||||
return message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
export type Protocol = {
|
||||
$schema: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: "object";
|
||||
definitions: Record<string, Type>;
|
||||
};
|
||||
|
||||
export type Type = {
|
||||
description?: string;
|
||||
} & (
|
||||
| {
|
||||
type: "number" | "integer" | "boolean";
|
||||
}
|
||||
| {
|
||||
type: "string";
|
||||
enum?: string[];
|
||||
enumDescriptions?: string[];
|
||||
}
|
||||
| {
|
||||
type: "object";
|
||||
properties?: Record<string, Type>;
|
||||
required?: string[];
|
||||
}
|
||||
| {
|
||||
type: "array";
|
||||
items?: Type;
|
||||
}
|
||||
| {
|
||||
type?: undefined;
|
||||
$ref: string;
|
||||
}
|
||||
| {
|
||||
type?: undefined;
|
||||
allOf: Type[];
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"module": "NodeNext",
|
||||
"target": "esnext",
|
||||
"moduleResolution": "nodenext",
|
||||
"moduleDetection": "force",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
// "composite": true,
|
||||
"strict": true,
|
||||
"downlevelIteration": true,
|
||||
"skipLibCheck": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"inlineSourceMap": true,
|
||||
"allowJs": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src", "scripts", "../bun-types/index.d.ts", "../bun-inspector-protocol/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user