1530 lines
54 KiB
JavaScript
Executable File
1530 lines
54 KiB
JavaScript
Executable File
#!/usr/bin/env bun
|
|
// @bun
|
|
|
|
// dap_websocket_server_bun.ts
|
|
var {spawn } = globalThis.Bun;
|
|
import { mkdtemp, writeFile, unlink, rmdir, symlink } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
var LOG_LEVEL = process.env.LOG_LEVEL || "DEBUG";
|
|
var logger = {
|
|
debug: (...args) => {
|
|
if (LOG_LEVEL === "DEBUG")
|
|
console.log("[DEBUG]", new Date().toISOString(), ...args);
|
|
},
|
|
info: (...args) => console.log("[INFO]", new Date().toISOString(), ...args),
|
|
warn: (...args) => console.warn("[WARN]", new Date().toISOString(), ...args),
|
|
error: (...args) => console.error("[ERROR]", new Date().toISOString(), ...args)
|
|
};
|
|
var WINDMILL_BASE_URL = process.env.WINDMILL_BASE_URL || process.env.BASE_INTERNAL_URL;
|
|
var REQUIRE_SIGNED_REQUESTS = process.env.REQUIRE_SIGNED_DEBUG_REQUESTS !== "false";
|
|
var cachedPublicKey = null;
|
|
var publicKeyFetchPromise = null;
|
|
async function getPublicKey() {
|
|
if (cachedPublicKey) {
|
|
return cachedPublicKey;
|
|
}
|
|
if (publicKeyFetchPromise) {
|
|
return publicKeyFetchPromise;
|
|
}
|
|
if (!WINDMILL_BASE_URL) {
|
|
logger.warn("WINDMILL_BASE_URL not set - cannot fetch public key");
|
|
return null;
|
|
}
|
|
publicKeyFetchPromise = (async () => {
|
|
try {
|
|
const jwksUrl = `${WINDMILL_BASE_URL.replace(/\/$/, "")}/api/debug/jwks`;
|
|
logger.info(`Fetching JWKS from ${jwksUrl}`);
|
|
const response = await fetch(jwksUrl);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch JWKS: ${response.status} ${response.statusText}`);
|
|
}
|
|
const jwks = await response.json();
|
|
if (!jwks.keys || jwks.keys.length === 0) {
|
|
throw new Error("No keys in JWKS");
|
|
}
|
|
const jwk = jwks.keys[0];
|
|
if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519") {
|
|
throw new Error(`Unsupported key type: ${jwk.kty}/${jwk.crv}`);
|
|
}
|
|
const publicKeyBytes = Uint8Array.from(atob(jwk.x.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
|
|
const key = await crypto.subtle.importKey("raw", publicKeyBytes, { name: "Ed25519" }, true, ["verify"]);
|
|
cachedPublicKey = key;
|
|
logger.info("Successfully loaded Ed25519 public key from JWKS");
|
|
return key;
|
|
} catch (error) {
|
|
logger.error(`Failed to fetch/parse JWKS: ${error}`);
|
|
return null;
|
|
} finally {
|
|
publicKeyFetchPromise = null;
|
|
}
|
|
})();
|
|
return publicKeyFetchPromise;
|
|
}
|
|
async function computeCodeHash(code) {
|
|
const encoder = new TextEncoder;
|
|
const data = encoder.encode(code);
|
|
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
const hashArray = new Uint8Array(hashBuffer);
|
|
return Array.from(hashArray.slice(0, 16)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
}
|
|
function base64urlDecode(str) {
|
|
const padding = "=".repeat((4 - str.length % 4) % 4);
|
|
const base64 = str.replace(/-/g, "+").replace(/_/g, "/") + padding;
|
|
const binary = atob(base64);
|
|
return Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
|
}
|
|
async function verifyDebugToken(token, code) {
|
|
const publicKey = await getPublicKey();
|
|
if (!publicKey) {
|
|
if (REQUIRE_SIGNED_REQUESTS) {
|
|
return "Public key not available but signed requests are required. Set WINDMILL_BASE_URL.";
|
|
}
|
|
logger.warn("Public key not available - signature verification disabled");
|
|
return null;
|
|
}
|
|
const parts = token.split(".");
|
|
if (parts.length !== 3) {
|
|
return "Invalid JWT format";
|
|
}
|
|
const [headerB64, claimsB64, signatureB64] = parts;
|
|
try {
|
|
const message = new TextEncoder().encode(`${headerB64}.${claimsB64}`);
|
|
const signature = base64urlDecode(signatureB64);
|
|
const isValid = await crypto.subtle.verify({ name: "Ed25519" }, publicKey, signature, message);
|
|
if (!isValid) {
|
|
return "Invalid JWT signature";
|
|
}
|
|
const claimsJson = new TextDecoder().decode(base64urlDecode(claimsB64));
|
|
const claims = JSON.parse(claimsJson);
|
|
const now = Math.floor(Date.now() / 1000);
|
|
if (now > claims.exp) {
|
|
return `Token expired: ${now - claims.exp} seconds ago`;
|
|
}
|
|
const expectedHash = await computeCodeHash(code);
|
|
if (claims.code_hash !== expectedHash) {
|
|
return "Code hash mismatch - code was modified after signing";
|
|
}
|
|
logger.info(`Verified debug token from ${claims.email} in workspace ${claims.workspace_id} (job: ${claims.job_id})`);
|
|
return null;
|
|
} catch (error) {
|
|
return `JWT verification error: ${error}`;
|
|
}
|
|
}
|
|
function removePinnedImports(code) {
|
|
const IMPORTS_VERSION = /^((?:@[^/@]+\/[^/@]+)|(?:[^/@]+))(?:@[^/]+)?(.*)$/;
|
|
const importRegex = /(?:import|export).*?from\s+['"]([^'"]+)['"]/g;
|
|
let result = code;
|
|
let match;
|
|
const imports = [];
|
|
while ((match = importRegex.exec(code)) !== null) {
|
|
imports.push(match[1]);
|
|
}
|
|
imports.sort((a, b) => b.length - a.length);
|
|
for (const importPath of imports) {
|
|
const versionMatch = IMPORTS_VERSION.exec(importPath);
|
|
if (versionMatch) {
|
|
const packageName = versionMatch[1];
|
|
const pathSuffix = versionMatch[2] || "";
|
|
const newImportPath = packageName + pathSuffix;
|
|
if (newImportPath !== importPath) {
|
|
result = result.split(`"${importPath}"`).join(`"${newImportPath}"`);
|
|
result = result.split(`'${importPath}'`).join(`'${newImportPath}'`);
|
|
logger.debug(`Removed version from import: "${importPath}" -> "${newImportPath}"`);
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
function decodeVLQ(encoded) {
|
|
const VLQ_BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
const values = [];
|
|
let shift = 0;
|
|
let value = 0;
|
|
for (const char of encoded) {
|
|
const digit = VLQ_BASE64.indexOf(char);
|
|
if (digit === -1)
|
|
continue;
|
|
const hasContinuation = digit & 32;
|
|
value += (digit & 31) << shift;
|
|
if (hasContinuation) {
|
|
shift += 5;
|
|
} else {
|
|
const isNegative = value & 1;
|
|
value = value >> 1;
|
|
values.push(isNegative ? -value : value);
|
|
value = 0;
|
|
shift = 0;
|
|
}
|
|
}
|
|
return values;
|
|
}
|
|
function parseSourceMapLineMapping(sourceMapUrl) {
|
|
const mappings = {
|
|
originalToTranspiled: new Map,
|
|
transpiledToOriginal: new Map
|
|
};
|
|
try {
|
|
const match = sourceMapUrl.match(/^data:application\/json;base64,(.+)$/);
|
|
if (!match) {
|
|
logger.warn("Source map is not a base64 data URL");
|
|
return mappings;
|
|
}
|
|
const decoded = atob(match[1]);
|
|
const sourceMap = JSON.parse(decoded);
|
|
if (!sourceMap.mappings) {
|
|
return mappings;
|
|
}
|
|
const lines = sourceMap.mappings.split(";");
|
|
let originalLine = 0;
|
|
for (let transpiledLine = 0;transpiledLine < lines.length; transpiledLine++) {
|
|
const segments = lines[transpiledLine].split(",");
|
|
for (const segment of segments) {
|
|
if (!segment)
|
|
continue;
|
|
const values = decodeVLQ(segment);
|
|
if (values.length >= 3) {
|
|
originalLine += values[2];
|
|
if (!mappings.originalToTranspiled.has(originalLine)) {
|
|
mappings.originalToTranspiled.set(originalLine, transpiledLine);
|
|
logger.debug(`Source map: original line ${originalLine} -> transpiled line ${transpiledLine}`);
|
|
}
|
|
const existing = mappings.transpiledToOriginal.get(transpiledLine);
|
|
if (existing === undefined || originalLine > existing) {
|
|
mappings.transpiledToOriginal.set(transpiledLine, originalLine);
|
|
logger.debug(`Source map: transpiled line ${transpiledLine} -> original line ${originalLine}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error("Failed to parse source map:", error);
|
|
}
|
|
return mappings;
|
|
}
|
|
|
|
class DebugSession {
|
|
ws;
|
|
seq = 1;
|
|
initialized = false;
|
|
configured = false;
|
|
running = false;
|
|
terminatedSent = false;
|
|
process = null;
|
|
inspectorWs = null;
|
|
inspectorSeq = 1;
|
|
pendingInspectorRequests = new Map;
|
|
scriptPath = null;
|
|
tempDir = null;
|
|
tempFile = null;
|
|
breakpoints = new Map;
|
|
breakpointIds = new Map;
|
|
scripts = new Map;
|
|
mainScriptId = null;
|
|
sourceMapMappings = {
|
|
originalToTranspiled: new Map,
|
|
transpiledToOriginal: new Map
|
|
};
|
|
callFrames = [];
|
|
variablesRefCounter = 1;
|
|
scopesMap = new Map;
|
|
objectsMap = new Map;
|
|
callMain = false;
|
|
mainArgs = {};
|
|
isStepping = false;
|
|
pendingConsoleOutput = [];
|
|
scriptResult = undefined;
|
|
pausedAtBreakpoint = false;
|
|
envVars = {};
|
|
nsjailConfig;
|
|
bunPath = "bun";
|
|
windmillPath;
|
|
nodeModulesPath;
|
|
constructor(ws, options) {
|
|
this.ws = ws;
|
|
this.nsjailConfig = options?.nsjailConfig;
|
|
this.bunPath = options?.bunPath || "bun";
|
|
this.windmillPath = options?.windmillPath;
|
|
}
|
|
nextSeq() {
|
|
return this.seq++;
|
|
}
|
|
nextInspectorSeq() {
|
|
return this.inspectorSeq++;
|
|
}
|
|
nextVarRef() {
|
|
return this.variablesRefCounter++;
|
|
}
|
|
sendMessage(msg) {
|
|
const data = JSON.stringify(msg);
|
|
logger.debug("Sending DAP:", data);
|
|
this.ws.send(data);
|
|
}
|
|
sendResponse(request, success = true, body = {}, message = "") {
|
|
this.sendMessage({
|
|
seq: this.nextSeq(),
|
|
type: "response",
|
|
command: request.command || "",
|
|
request_seq: request.seq,
|
|
success,
|
|
message,
|
|
body
|
|
});
|
|
}
|
|
sendEvent(event, body = {}) {
|
|
this.sendMessage({
|
|
seq: this.nextSeq(),
|
|
type: "event",
|
|
event,
|
|
body
|
|
});
|
|
}
|
|
async sendInspectorCommand(method, params = {}) {
|
|
if (!this.inspectorWs || this.inspectorWs.readyState !== WebSocket.OPEN) {
|
|
throw new Error("Inspector not connected");
|
|
}
|
|
const id = this.nextInspectorSeq();
|
|
const message = { id, method, params };
|
|
return new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
this.pendingInspectorRequests.delete(id);
|
|
reject(new Error(`Inspector command timeout: ${method}`));
|
|
}, 1e4);
|
|
this.pendingInspectorRequests.set(id, {
|
|
resolve: (value) => {
|
|
clearTimeout(timeout);
|
|
resolve(value);
|
|
},
|
|
reject: (error) => {
|
|
clearTimeout(timeout);
|
|
reject(error);
|
|
}
|
|
});
|
|
logger.debug("Sending to inspector:", JSON.stringify(message));
|
|
this.inspectorWs.send(JSON.stringify(message));
|
|
});
|
|
}
|
|
handleInspectorMessage(data) {
|
|
try {
|
|
const message = JSON.parse(data);
|
|
logger.debug("Inspector message:", data);
|
|
if (message.id !== undefined) {
|
|
const pending = this.pendingInspectorRequests.get(message.id);
|
|
if (pending) {
|
|
this.pendingInspectorRequests.delete(message.id);
|
|
if (message.error) {
|
|
pending.reject(new Error(message.error.message));
|
|
} else {
|
|
pending.resolve(message);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (message.method) {
|
|
this.handleInspectorEvent(message);
|
|
}
|
|
} catch (error) {
|
|
logger.error("Failed to parse inspector message:", error);
|
|
}
|
|
}
|
|
handleInspectorEvent(message) {
|
|
const params = message.params || {};
|
|
switch (message.method) {
|
|
case "Debugger.scriptParsed":
|
|
this.handleScriptParsed(params);
|
|
break;
|
|
case "Debugger.paused":
|
|
this.handlePaused(params);
|
|
break;
|
|
case "Debugger.resumed":
|
|
this.sendEvent("continued", { threadId: 1 });
|
|
break;
|
|
case "Runtime.consoleAPICalled":
|
|
this.handleConsoleOutput(params);
|
|
break;
|
|
case "Console.messageAdded":
|
|
this.handleConsoleMessageAdded(params);
|
|
break;
|
|
case "Runtime.exceptionThrown":
|
|
this.handleException(params);
|
|
break;
|
|
case "Debugger.breakpointResolved":
|
|
logger.info(`Breakpoint resolved: ${JSON.stringify(params)}`);
|
|
break;
|
|
default:
|
|
logger.debug("Unhandled inspector event:", message.method);
|
|
}
|
|
}
|
|
handleScriptParsed(script) {
|
|
this.scripts.set(script.scriptId, script);
|
|
if (script.url && this.scriptPath && script.url.endsWith(this.scriptPath.split("/").pop())) {
|
|
this.mainScriptId = script.scriptId;
|
|
logger.info(`Main script parsed: ${script.url} (ID: ${script.scriptId})`);
|
|
if (script.sourceMapURL) {
|
|
logger.info("Parsing source map for line number mapping...");
|
|
this.sourceMapMappings = parseSourceMapLineMapping(script.sourceMapURL);
|
|
logger.info(`Source map parsed: ${this.sourceMapMappings.originalToTranspiled.size} original->transpiled, ${this.sourceMapMappings.transpiledToOriginal.size} transpiled->original`);
|
|
}
|
|
}
|
|
}
|
|
async applyBreakpointsByUrl() {
|
|
logger.info(`applyBreakpointsByUrl called. scriptPath: ${this.scriptPath}, breakpoints: ${JSON.stringify([...this.breakpoints.entries()])}`);
|
|
if (!this.scriptPath) {
|
|
logger.warn("No scriptPath set, skipping breakpoints");
|
|
return;
|
|
}
|
|
for (const [filePath, ids] of this.breakpointIds) {
|
|
for (const id of ids) {
|
|
try {
|
|
await this.sendInspectorCommand("Debugger.removeBreakpoint", { breakpointId: id });
|
|
} catch {}
|
|
}
|
|
}
|
|
this.breakpointIds.clear();
|
|
for (const [filePath, lines] of this.breakpoints) {
|
|
logger.info(`Setting breakpoints for ${filePath}: lines ${lines}`);
|
|
const ids = [];
|
|
for (const line of lines) {
|
|
try {
|
|
const urlRegex = this.scriptPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const originalLine0Indexed = line;
|
|
let transpiledLine = originalLine0Indexed;
|
|
if (this.sourceMapMappings.originalToTranspiled.size > 0) {
|
|
const mappedLine = this.sourceMapMappings.originalToTranspiled.get(originalLine0Indexed);
|
|
if (mappedLine !== undefined) {
|
|
transpiledLine = mappedLine;
|
|
logger.info(`Source map: original line ${originalLine0Indexed} -> transpiled line ${transpiledLine}`);
|
|
} else {
|
|
logger.warn(`Line ${originalLine0Indexed} not in source map, using as-is`);
|
|
}
|
|
}
|
|
logger.info(`Setting breakpoint: user line ${line} -> original line ${originalLine0Indexed} (0-idx) -> transpiled line ${transpiledLine}`);
|
|
const response = await this.sendInspectorCommand("Debugger.setBreakpointByUrl", {
|
|
lineNumber: transpiledLine,
|
|
urlRegex,
|
|
columnNumber: 0
|
|
});
|
|
logger.info(`Breakpoint response: ${JSON.stringify(response)}`);
|
|
if (response.result?.breakpointId) {
|
|
ids.push(response.result.breakpointId);
|
|
logger.info(`Breakpoint set at transpiled line ${transpiledLine} (user line ${line}, ID: ${response.result.breakpointId})`);
|
|
} else {
|
|
logger.warn(`No breakpointId in response for line ${line}`);
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Failed to set breakpoint at line ${line}:`, error);
|
|
}
|
|
}
|
|
this.breakpointIds.set(filePath, ids);
|
|
}
|
|
logger.info(`Finished setting breakpoints. Total IDs: ${[...this.breakpointIds.values()].flat().length}`);
|
|
}
|
|
initialPauseDone = false;
|
|
async handlePaused(params) {
|
|
const callFrames = params.callFrames;
|
|
const reason = params.reason;
|
|
logger.info(`handlePaused called: reason=${reason}, lineNumber=${callFrames?.[0]?.location?.lineNumber}, scriptId=${callFrames?.[0]?.location?.scriptId}`);
|
|
if (callFrames) {
|
|
this.callFrames = callFrames;
|
|
}
|
|
const isInitialPause = !this.initialPauseDone && reason === "DebuggerStatement" && callFrames && callFrames.length > 0 && callFrames[0].location.lineNumber === 0;
|
|
logger.info(`isInitialPause=${isInitialPause}, initialPauseDone=${this.initialPauseDone}`);
|
|
if (isInitialPause) {
|
|
this.initialPauseDone = true;
|
|
logger.info("Initial pause from injected debugger statement");
|
|
logger.info("Re-applying breakpoints after script is parsed...");
|
|
await this.applyBreakpointsByUrl();
|
|
try {
|
|
await this.sendInspectorCommand("Debugger.resume", {});
|
|
logger.info("Auto-resumed after initial pause");
|
|
} catch (error) {
|
|
logger.error("Failed to auto-resume:", error);
|
|
}
|
|
return;
|
|
}
|
|
this.initialPauseDone = true;
|
|
let dapReason;
|
|
switch (reason) {
|
|
case "Breakpoint":
|
|
case "breakpoint":
|
|
dapReason = "breakpoint";
|
|
break;
|
|
case "DebuggerStatement":
|
|
dapReason = "breakpoint";
|
|
break;
|
|
case "step":
|
|
dapReason = "step";
|
|
break;
|
|
case "exception":
|
|
dapReason = "exception";
|
|
break;
|
|
case "debugCommand":
|
|
dapReason = "pause";
|
|
break;
|
|
default:
|
|
dapReason = "step";
|
|
}
|
|
let line;
|
|
if (callFrames && callFrames.length > 0) {
|
|
const transpiledLine = callFrames[0].location.lineNumber;
|
|
if (this.sourceMapMappings.transpiledToOriginal.size > 0) {
|
|
const originalLine = this.sourceMapMappings.transpiledToOriginal.get(transpiledLine);
|
|
if (originalLine !== undefined) {
|
|
line = originalLine;
|
|
logger.info(`Stopped: transpiled line ${transpiledLine} -> original line ${line}`);
|
|
} else {
|
|
line = transpiledLine;
|
|
logger.warn(`Transpiled line ${transpiledLine} not in reverse source map, using as-is`);
|
|
}
|
|
} else {
|
|
line = transpiledLine;
|
|
}
|
|
}
|
|
this.sendEvent("stopped", {
|
|
reason: dapReason,
|
|
threadId: 1,
|
|
allThreadsStopped: true,
|
|
line
|
|
});
|
|
this.isStepping = false;
|
|
for (const outputEvent of this.pendingConsoleOutput) {
|
|
this.sendEvent("output", outputEvent);
|
|
}
|
|
this.pendingConsoleOutput = [];
|
|
}
|
|
handleConsoleOutput(params) {
|
|
const type = params.type;
|
|
const args = params.args;
|
|
if (!args)
|
|
return;
|
|
const output = args.map((arg) => {
|
|
if (arg.value !== undefined)
|
|
return String(arg.value);
|
|
if (arg.description)
|
|
return arg.description;
|
|
return "";
|
|
}).join(" ");
|
|
this.sendEvent("output", {
|
|
category: type === "error" ? "stderr" : "stdout",
|
|
output: output + `
|
|
`
|
|
});
|
|
}
|
|
formatConsoleParameter(p) {
|
|
if (p.value !== undefined) {
|
|
if (typeof p.value === "string")
|
|
return p.value;
|
|
return String(p.value);
|
|
}
|
|
if (p.subtype === "null")
|
|
return "null";
|
|
if (p.type === "undefined")
|
|
return "undefined";
|
|
if (p.type === "object" && p.preview && p.preview.properties) {
|
|
const props = p.preview.properties;
|
|
const isArray = p.subtype === "array" || p.preview.subtype === "array";
|
|
if (isArray) {
|
|
const items = props.map((prop) => this.formatPreviewValue(prop));
|
|
const suffix = p.preview.overflow ? ", ..." : "";
|
|
return `[${items.join(", ")}${suffix}]`;
|
|
} else {
|
|
const items = props.map((prop) => `${prop.name}: ${this.formatPreviewValue(prop)}`);
|
|
const suffix = p.preview.overflow ? ", ..." : "";
|
|
return `{ ${items.join(", ")}${suffix} }`;
|
|
}
|
|
}
|
|
if (p.type === "function") {
|
|
return p.description || "[Function]";
|
|
}
|
|
if (p.description)
|
|
return p.description;
|
|
return "";
|
|
}
|
|
formatPreviewValue(prop) {
|
|
if (prop.subtype === "null")
|
|
return "null";
|
|
if (prop.type === "undefined")
|
|
return "undefined";
|
|
if (prop.type === "string")
|
|
return `"${prop.value ?? ""}"`;
|
|
if (prop.type === "number" || prop.type === "boolean")
|
|
return prop.value ?? "";
|
|
if (prop.type === "function")
|
|
return "[Function]";
|
|
if (prop.type === "object") {
|
|
if (prop.subtype === "array")
|
|
return prop.value || "[]";
|
|
return prop.value || "{...}";
|
|
}
|
|
return prop.value ?? "";
|
|
}
|
|
handleConsoleMessageAdded(params) {
|
|
const message = params.message;
|
|
if (!message)
|
|
return;
|
|
let output;
|
|
if (message.parameters && message.parameters.length > 0) {
|
|
output = message.parameters.map((p) => this.formatConsoleParameter(p)).join(" ");
|
|
} else {
|
|
output = message.text || "";
|
|
}
|
|
if (output.startsWith("__WINDMILL_RESULT__:")) {
|
|
try {
|
|
const resultJson = output.substring("__WINDMILL_RESULT__:".length);
|
|
this.scriptResult = JSON.parse(resultJson);
|
|
logger.info(`Captured script result: ${resultJson}`);
|
|
this.isStepping = false;
|
|
for (const pendingOutput of this.pendingConsoleOutput) {
|
|
this.sendEvent("output", pendingOutput);
|
|
}
|
|
this.pendingConsoleOutput = [];
|
|
if (!this.terminatedSent) {
|
|
this.terminatedSent = true;
|
|
this.running = false;
|
|
logger.info("Sending terminated event after result capture");
|
|
this.sendEvent("terminated", { result: this.scriptResult });
|
|
}
|
|
} catch (error) {
|
|
logger.error("Failed to parse script result:", error);
|
|
}
|
|
return;
|
|
}
|
|
const category = message.level === "error" || message.level === "warning" ? "stderr" : "stdout";
|
|
const outputEvent = {
|
|
category,
|
|
output: output + `
|
|
`
|
|
};
|
|
if (message.line !== undefined && message.url) {
|
|
outputEvent.source = {
|
|
path: message.url,
|
|
name: message.url.split("/").pop() || "script.ts"
|
|
};
|
|
outputEvent.line = message.line;
|
|
if (message.column !== undefined) {
|
|
outputEvent.column = message.column + 1;
|
|
}
|
|
}
|
|
if (this.isStepping) {
|
|
this.pendingConsoleOutput.push(outputEvent);
|
|
} else {
|
|
this.sendEvent("output", outputEvent);
|
|
}
|
|
}
|
|
handleException(params) {
|
|
const exceptionDetails = params.exceptionDetails;
|
|
if (exceptionDetails) {
|
|
const message = exceptionDetails.exception?.description || exceptionDetails.text || "Unknown exception";
|
|
this.sendEvent("output", {
|
|
category: "stderr",
|
|
output: `Exception: ${message}
|
|
`
|
|
});
|
|
}
|
|
}
|
|
async handleInitialize(request) {
|
|
const capabilities = {
|
|
supportsConfigurationDoneRequest: true,
|
|
supportsFunctionBreakpoints: false,
|
|
supportsConditionalBreakpoints: false,
|
|
supportsHitConditionalBreakpoints: false,
|
|
supportsEvaluateForHovers: true,
|
|
exceptionBreakpointFilters: [],
|
|
supportsStepBack: false,
|
|
supportsSetVariable: false,
|
|
supportsRestartFrame: false,
|
|
supportsGotoTargetsRequest: false,
|
|
supportsStepInTargetsRequest: false,
|
|
supportsCompletionsRequest: false,
|
|
supportsModulesRequest: false,
|
|
supportsExceptionOptions: false,
|
|
supportsValueFormattingOptions: false,
|
|
supportsExceptionInfoRequest: false,
|
|
supportTerminateDebuggee: true,
|
|
supportsDelayedStackTraceLoading: false,
|
|
supportsLoadedSourcesRequest: false,
|
|
supportsLogPoints: false,
|
|
supportsTerminateThreadsRequest: false,
|
|
supportsSetExpression: false,
|
|
supportsTerminateRequest: true,
|
|
supportsDataBreakpoints: false,
|
|
supportsReadMemoryRequest: false,
|
|
supportsDisassembleRequest: false,
|
|
supportsCancelRequest: false,
|
|
supportsBreakpointLocationsRequest: false
|
|
};
|
|
this.sendResponse(request, true, capabilities);
|
|
this.initialized = true;
|
|
this.sendEvent("initialized");
|
|
}
|
|
async handleSetBreakpoints(request) {
|
|
const args = request.arguments || {};
|
|
const source = args.source;
|
|
const sourcePath = source?.path || "";
|
|
const breakpointsData = args.breakpoints || [];
|
|
const verifiedBreakpoints = [];
|
|
const lineNumbers = [];
|
|
for (const bp of breakpointsData) {
|
|
const line = bp.line;
|
|
lineNumbers.push(line);
|
|
verifiedBreakpoints.push({
|
|
id: verifiedBreakpoints.length + 1,
|
|
verified: true,
|
|
line,
|
|
source: { path: sourcePath }
|
|
});
|
|
}
|
|
this.breakpoints.set(sourcePath, lineNumbers);
|
|
logger.info(`Stored breakpoints at lines ${lineNumbers} for ${sourcePath}`);
|
|
if (this.inspectorWs) {
|
|
await this.applyBreakpointsByUrl();
|
|
}
|
|
this.sendResponse(request, true, { breakpoints: verifiedBreakpoints });
|
|
}
|
|
async handleConfigurationDone(request) {
|
|
this.configured = true;
|
|
this.sendResponse(request);
|
|
}
|
|
async handleLaunch(request) {
|
|
const args = request.arguments || {};
|
|
let code = args.code;
|
|
this.scriptPath = args.program;
|
|
const cwd = args.cwd || process.cwd();
|
|
this.callMain = args.callMain || false;
|
|
this.mainArgs = args.args || {};
|
|
this.envVars = args.env || {};
|
|
if (code && REQUIRE_SIGNED_REQUESTS) {
|
|
const token = args.token;
|
|
if (!token) {
|
|
logger.error("No debug token provided but signed requests are required");
|
|
this.sendResponse(request, false, {}, "Debug token required. Ensure the debug session was signed by the backend.");
|
|
return;
|
|
}
|
|
const verificationError = await verifyDebugToken(token, code);
|
|
if (verificationError) {
|
|
logger.error(`Token verification failed: ${verificationError}`);
|
|
this.sendResponse(request, false, {}, `Token verification failed: ${verificationError}`);
|
|
return;
|
|
}
|
|
}
|
|
if (process.env.BASE_INTERNAL_URL) {
|
|
this.envVars.WM_BASE_URL = process.env.BASE_INTERNAL_URL;
|
|
}
|
|
if (Object.keys(this.envVars).length > 0) {
|
|
logger.info(`Launch with env vars: ${Object.keys(this.envVars).join(", ")}`);
|
|
}
|
|
if (!this.scriptPath && !code) {
|
|
this.sendResponse(request, false, {}, "No program or code specified");
|
|
return;
|
|
}
|
|
if (code) {
|
|
this.nodeModulesPath = await this.prepareDependencies(code) || undefined;
|
|
code = removePinnedImports(code);
|
|
}
|
|
this.initialPauseDone = false;
|
|
code = `debugger; // Auto-injected by Windmill debugger
|
|
${code || ""}`;
|
|
if (this.callMain && code) {
|
|
const argsValues = Object.values(this.mainArgs).map((v) => JSON.stringify(v)).join(", ");
|
|
code = code + `
|
|
|
|
// Auto-generated call to main entrypoint
|
|
` + `globalThis.__windmill_result__ = await main(${argsValues});
|
|
` + `console.log("__WINDMILL_RESULT__:" + JSON.stringify(globalThis.__windmill_result__));
|
|
` + `// Small delay to ensure console output is delivered via inspector before process exits
|
|
` + `await new Promise(r => setTimeout(r, 50));
|
|
`;
|
|
logger.info(`Added main() call with args: ${argsValues}`);
|
|
}
|
|
if (code && !this.scriptPath) {
|
|
try {
|
|
this.tempDir = await mkdtemp(join(tmpdir(), "windmill_debug_"));
|
|
this.tempFile = join(this.tempDir, "script.ts");
|
|
await writeFile(this.tempFile, code);
|
|
this.scriptPath = this.tempFile;
|
|
logger.info(`Wrote code to ${this.tempFile}`);
|
|
const lines = code.split(`
|
|
`);
|
|
for (let i = 24;i < Math.min(30, lines.length); i++) {
|
|
logger.info(` Line ${i} (0-idx) / ${i + 1} (1-idx): ${lines[i]?.substring(0, 80)}`);
|
|
}
|
|
if (this.nodeModulesPath) {
|
|
const targetPath = join(this.tempDir, "node_modules");
|
|
try {
|
|
await symlink(this.nodeModulesPath, targetPath);
|
|
logger.info(`Symlinked ${this.nodeModulesPath} -> ${targetPath}`);
|
|
} catch (symlinkError) {
|
|
logger.warn(`Failed to symlink node_modules: ${symlinkError}`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
this.sendResponse(request, false, {}, `Failed to create temp file: ${error}`);
|
|
return;
|
|
}
|
|
}
|
|
this.sendResponse(request);
|
|
try {
|
|
await this.startBunProcess(cwd);
|
|
} catch (error) {
|
|
this.sendEvent("output", { category: "stderr", output: `Failed to start Bun: ${error}
|
|
` });
|
|
this.sendEvent("terminated", { error: String(error) });
|
|
}
|
|
}
|
|
inspectorWsUrl = null;
|
|
inspectorWsUrlPromise = null;
|
|
async prepareDependencies(code, language = "bun") {
|
|
if (!this.windmillPath) {
|
|
logger.info("No windmill binary path configured, skipping dependency preparation");
|
|
return null;
|
|
}
|
|
logger.info(`Preparing dependencies using ${this.windmillPath}`);
|
|
try {
|
|
const input = JSON.stringify({ code, language }) + `
|
|
`;
|
|
logger.info(`prepare-deps input length: ${input.length}`);
|
|
const proc = spawn({
|
|
cmd: [this.windmillPath, "prepare-deps"],
|
|
stdin: new Blob([input]),
|
|
stdout: "pipe",
|
|
stderr: "pipe"
|
|
});
|
|
const output = await new Response(proc.stdout).text();
|
|
const stderr = await new Response(proc.stderr).text();
|
|
logger.info(`prepare-deps output: ${output.substring(0, 200)}`);
|
|
logger.info(`prepare-deps stderr: ${stderr.substring(0, 200)}`);
|
|
if (stderr) {
|
|
const filteredStderr = stderr.split(`
|
|
`).filter((line) => !line.includes("Running in standalone mode")).join(`
|
|
`).trim();
|
|
if (filteredStderr) {
|
|
logger.debug(`prepare-deps stderr: ${filteredStderr}`);
|
|
}
|
|
}
|
|
const response = JSON.parse(output.trim().split(`
|
|
`).pop() || "{}");
|
|
if (!response.success) {
|
|
logger.error(`prepare-deps failed: ${response.error}`);
|
|
this.sendEvent("output", {
|
|
category: "console",
|
|
output: `Warning: Failed to prepare dependencies: ${response.error}
|
|
`
|
|
});
|
|
return null;
|
|
}
|
|
if (response.node_modules_path) {
|
|
logger.info(`Dependencies installed at: ${response.node_modules_path}`);
|
|
this.sendEvent("output", {
|
|
category: "console",
|
|
output: `Dependencies installed at: ${response.node_modules_path}
|
|
`
|
|
});
|
|
return response.node_modules_path;
|
|
}
|
|
logger.info("No external dependencies to install");
|
|
return null;
|
|
} catch (error) {
|
|
logger.error(`Failed to prepare dependencies: ${error}`);
|
|
this.sendEvent("output", {
|
|
category: "console",
|
|
output: `Warning: Failed to prepare dependencies: ${error}
|
|
`
|
|
});
|
|
return null;
|
|
}
|
|
}
|
|
async startBunProcess(cwd) {
|
|
if (!this.scriptPath) {
|
|
throw new Error("No script path");
|
|
}
|
|
const inspectPort = 9229 + Math.floor(Math.random() * 1000);
|
|
const inspectUrl = `127.0.0.1:${inspectPort}`;
|
|
let cmd = [this.bunPath, `--inspect-wait=${inspectUrl}`, this.scriptPath];
|
|
if (this.nsjailConfig?.enabled) {
|
|
const nsjailCmd = [this.nsjailConfig.binaryPath];
|
|
if (this.nsjailConfig.configPath) {
|
|
nsjailCmd.push("--config", this.nsjailConfig.configPath);
|
|
}
|
|
if (this.nsjailConfig.extraArgs) {
|
|
nsjailCmd.push(...this.nsjailConfig.extraArgs);
|
|
}
|
|
nsjailCmd.push("--cwd", cwd);
|
|
nsjailCmd.push("--");
|
|
nsjailCmd.push(...cmd);
|
|
cmd = nsjailCmd;
|
|
logger.info(`Starting Bun with nsjail: ${cmd.join(" ")}`);
|
|
} else {
|
|
logger.info(`Starting Bun with --inspect-wait=${inspectUrl}`);
|
|
}
|
|
const wsUrlPromise = new Promise((resolve, reject) => {
|
|
this.inspectorWsUrlPromise = { resolve, reject };
|
|
setTimeout(() => {
|
|
if (this.inspectorWsUrlPromise) {
|
|
reject(new Error("Timeout waiting for inspector WebSocket URL"));
|
|
this.inspectorWsUrlPromise = null;
|
|
}
|
|
}, 1e4);
|
|
});
|
|
const envVars = {
|
|
PATH: process.env.PATH || "/usr/bin:/bin",
|
|
HOME: process.env.HOME,
|
|
...this.envVars
|
|
};
|
|
if (this.nodeModulesPath) {
|
|
envVars.NODE_PATH = this.nodeModulesPath;
|
|
logger.info(`Setting NODE_PATH=${this.nodeModulesPath}`);
|
|
}
|
|
this.process = spawn({
|
|
cmd,
|
|
cwd,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: envVars
|
|
});
|
|
this.readStderrForInspectorUrl(this.process.stderr);
|
|
const wsUrl = await wsUrlPromise;
|
|
logger.info(`Got inspector WebSocket URL: ${wsUrl}`);
|
|
await this.connectToInspector(wsUrl);
|
|
this.process.exited.then(async (exitCode) => {
|
|
logger.info(`Bun process exited with code: ${exitCode}`);
|
|
this.running = false;
|
|
if (!this.terminatedSent) {
|
|
this.terminatedSent = true;
|
|
logger.info(`Sending terminated event with result: ${JSON.stringify(this.scriptResult)}`);
|
|
try {
|
|
this.sendEvent("terminated", this.scriptResult !== undefined ? { result: this.scriptResult } : {});
|
|
logger.info("Terminated event sent successfully");
|
|
} catch (error) {
|
|
logger.error("Failed to send terminated event:", error);
|
|
}
|
|
}
|
|
await this.cleanup();
|
|
}).catch((error) => {
|
|
logger.error("Error in process.exited handler:", error);
|
|
});
|
|
}
|
|
async readStream(stream, category) {
|
|
if (!stream)
|
|
return;
|
|
const reader = stream.getReader();
|
|
const decoder = new TextDecoder;
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done)
|
|
break;
|
|
const text = decoder.decode(value);
|
|
this.sendEvent("output", { category, output: text });
|
|
}
|
|
} catch (error) {
|
|
logger.debug(`Stream ${category} ended:`, error);
|
|
}
|
|
}
|
|
async readStderrForInspectorUrl(stream) {
|
|
if (!stream)
|
|
return;
|
|
const reader = stream.getReader();
|
|
const decoder = new TextDecoder;
|
|
let buffer = "";
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done)
|
|
break;
|
|
const text = decoder.decode(value);
|
|
buffer += text;
|
|
const wsMatch = buffer.match(/ws:\/\/[\d.]+:\d+\/[a-z0-9]+/i);
|
|
if (wsMatch && this.inspectorWsUrlPromise) {
|
|
const wsUrl = wsMatch[0];
|
|
logger.info(`Found inspector WebSocket URL in stderr: ${wsUrl}`);
|
|
this.inspectorWsUrl = wsUrl;
|
|
this.inspectorWsUrlPromise.resolve(wsUrl);
|
|
this.inspectorWsUrlPromise = null;
|
|
}
|
|
if (!text.includes("Bun Inspector") && !text.includes("Listening:") && !text.includes("ws://") && !text.includes("debug.bun.sh")) {
|
|
this.sendEvent("output", { category: "stderr", output: text });
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.debug("Stderr stream ended:", error);
|
|
}
|
|
}
|
|
async connectToInspector(wsUrl) {
|
|
logger.info(`Connecting to inspector at ${wsUrl}`);
|
|
return new Promise((resolve, reject) => {
|
|
this.inspectorWs = new WebSocket(wsUrl);
|
|
const timeout = setTimeout(() => {
|
|
reject(new Error("Inspector connection timeout"));
|
|
}, 5000);
|
|
this.inspectorWs.onopen = async () => {
|
|
clearTimeout(timeout);
|
|
logger.info("Connected to inspector");
|
|
try {
|
|
await this.sendInspectorCommand("Inspector.enable", {});
|
|
await this.sendInspectorCommand("Console.enable", {});
|
|
await this.sendInspectorCommand("Debugger.enable", {});
|
|
await this.sendInspectorCommand("Runtime.enable", {});
|
|
await this.sendInspectorCommand("Debugger.setBreakpointsActive", { active: true });
|
|
await this.sendInspectorCommand("Debugger.setPauseOnDebuggerStatements", { enabled: true });
|
|
await this.sendInspectorCommand("Debugger.setPauseOnExceptions", {
|
|
state: "uncaught"
|
|
});
|
|
await this.applyBreakpointsByUrl();
|
|
this.running = true;
|
|
logger.info("Starting script execution with Inspector.initialized...");
|
|
await this.sendInspectorCommand("Inspector.initialized", {});
|
|
resolve();
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
};
|
|
this.inspectorWs.onmessage = (event) => {
|
|
this.handleInspectorMessage(event.data);
|
|
};
|
|
this.inspectorWs.onerror = (error) => {
|
|
logger.error("Inspector WebSocket error:", error);
|
|
};
|
|
this.inspectorWs.onclose = () => {
|
|
logger.info("Inspector WebSocket closed");
|
|
this.inspectorWs = null;
|
|
if (!this.terminatedSent) {
|
|
this.terminatedSent = true;
|
|
this.running = false;
|
|
logger.info(`Inspector closed - sending terminated event with result: ${JSON.stringify(this.scriptResult)}`);
|
|
try {
|
|
this.sendEvent("terminated", this.scriptResult !== undefined ? { result: this.scriptResult } : {});
|
|
logger.info("Terminated event sent successfully via inspector close");
|
|
} catch (error) {
|
|
logger.error("Failed to send terminated event on inspector close:", error);
|
|
}
|
|
}
|
|
};
|
|
});
|
|
}
|
|
async handleThreads(request) {
|
|
this.sendResponse(request, true, {
|
|
threads: [{ id: 1, name: "MainThread" }]
|
|
});
|
|
}
|
|
async handleStackTrace(request) {
|
|
logger.info(`handleStackTrace: callFrames.length=${this.callFrames.length}`);
|
|
const stackFrames = [];
|
|
for (let i = 0;i < this.callFrames.length; i++) {
|
|
const frame = this.callFrames[i];
|
|
const script = this.scripts.get(frame.location.scriptId);
|
|
logger.info(` frame ${i}: functionName="${frame.functionName}", scriptId=${frame.location.scriptId}, lineNumber=${frame.location.lineNumber}, scriptUrl=${script?.url}`);
|
|
if (!script?.url || !this.scriptPath) {
|
|
logger.info(` skipping frame ${i}: no script URL or scriptPath`);
|
|
continue;
|
|
}
|
|
const isUserScript = script.url.endsWith(this.scriptPath.split("/").pop());
|
|
if (!isUserScript) {
|
|
logger.info(` skipping frame ${i}: not from user script (${script.url})`);
|
|
continue;
|
|
}
|
|
const transpiledLine = frame.location.lineNumber;
|
|
let frameLine = transpiledLine;
|
|
if (this.sourceMapMappings.transpiledToOriginal.size > 0) {
|
|
const originalLine = this.sourceMapMappings.transpiledToOriginal.get(transpiledLine);
|
|
if (originalLine !== undefined) {
|
|
frameLine = originalLine;
|
|
}
|
|
}
|
|
stackFrames.push({
|
|
id: i + 1,
|
|
name: frame.functionName || "<module>",
|
|
source: {
|
|
path: script.url || this.scriptPath || "<unknown>",
|
|
name: script.url?.split("/").pop() || "script.ts"
|
|
},
|
|
line: frameLine,
|
|
column: frame.location.columnNumber + 1
|
|
});
|
|
}
|
|
logger.info(`handleStackTrace: returning ${stackFrames.length} frames`);
|
|
this.sendResponse(request, true, {
|
|
stackFrames,
|
|
totalFrames: stackFrames.length
|
|
});
|
|
}
|
|
async handleScopes(request) {
|
|
const args = request.arguments || {};
|
|
const frameId = args.frameId || 1;
|
|
const frameIndex = frameId - 1;
|
|
logger.info(`handleScopes: frameId=${frameId}, frameIndex=${frameIndex}, callFrames.length=${this.callFrames.length}`);
|
|
const frame = this.callFrames[frameIndex];
|
|
if (!frame) {
|
|
logger.warn(`handleScopes: No frame at index ${frameIndex}`);
|
|
this.sendResponse(request, true, { scopes: [] });
|
|
return;
|
|
}
|
|
logger.info(`handleScopes: frame.scopeChain has ${frame.scopeChain.length} scopes`);
|
|
for (const s of frame.scopeChain) {
|
|
logger.info(` scope: type=${s.type}, name=${s.name}, hasObjectId=${!!s.object.objectId}`);
|
|
}
|
|
const scopes = [];
|
|
for (const scope of frame.scopeChain) {
|
|
const ref = this.nextVarRef();
|
|
const objectId = scope.object.objectId;
|
|
if (objectId) {
|
|
this.scopesMap.set(ref, { type: scope.type, objectId, frameIndex });
|
|
let name;
|
|
if (scope.name) {
|
|
name = scope.name;
|
|
} else {
|
|
name = scope.type.charAt(0).toUpperCase() + scope.type.slice(1);
|
|
}
|
|
scopes.push({
|
|
name,
|
|
variablesReference: ref,
|
|
expensive: scope.type === "global"
|
|
});
|
|
logger.info(` Added scope: name=${name}, ref=${ref}, type=${scope.type}`);
|
|
} else {
|
|
logger.warn(` Scope ${scope.type} has no objectId, skipping`);
|
|
}
|
|
}
|
|
logger.info(`handleScopes: returning ${scopes.length} scopes`);
|
|
this.sendResponse(request, true, { scopes });
|
|
}
|
|
async handleVariables(request) {
|
|
const args = request.arguments || {};
|
|
const variablesRef = args.variablesReference || 0;
|
|
logger.info(`handleVariables: variablesRef=${variablesRef}`);
|
|
const scopeInfo = this.scopesMap.get(variablesRef);
|
|
const objectId = this.objectsMap.get(variablesRef) || scopeInfo?.objectId;
|
|
logger.info(`handleVariables: scopeInfo=${JSON.stringify(scopeInfo)}, objectId=${objectId}`);
|
|
if (!objectId) {
|
|
logger.warn(`handleVariables: No objectId found for ref ${variablesRef}`);
|
|
this.sendResponse(request, true, { variables: [] });
|
|
return;
|
|
}
|
|
try {
|
|
const scopeType = scopeInfo?.type || "unknown";
|
|
const isGlobalScope = scopeType === "global";
|
|
logger.info(`handleVariables: calling Runtime.getProperties for objectId=${objectId}, scopeType=${scopeType}`);
|
|
const response = await this.sendInspectorCommand("Runtime.getProperties", {
|
|
objectId,
|
|
ownProperties: true,
|
|
generatePreview: true
|
|
});
|
|
const allProps = response.result?.properties || [];
|
|
logger.info(`handleVariables: all property names: ${allProps.map((p) => p.name).join(", ")}`);
|
|
logger.info(`handleVariables: got response with ${response.result?.properties?.length || 0} properties for scope type: ${scopeType}`);
|
|
const variables = [];
|
|
const properties = response.result?.properties || [];
|
|
for (const prop of properties) {
|
|
if (!prop.value)
|
|
continue;
|
|
if (prop.name.startsWith("__") && prop.name !== "__windmill_result__")
|
|
continue;
|
|
if (prop.value.type === "function" && prop.value.description?.includes("[native code]"))
|
|
continue;
|
|
const builtInNames = new Set([
|
|
"NaN",
|
|
"Infinity",
|
|
"undefined",
|
|
"globalThis",
|
|
"global",
|
|
"self",
|
|
"window",
|
|
"console",
|
|
"Bun",
|
|
"process",
|
|
"navigator",
|
|
"performance",
|
|
"crypto",
|
|
"Loader",
|
|
"onmessage",
|
|
"onerror",
|
|
"toString",
|
|
"toLocaleString",
|
|
"valueOf",
|
|
"hasOwnProperty",
|
|
"propertyIsEnumerable",
|
|
"isPrototypeOf",
|
|
"constructor",
|
|
"Reflect",
|
|
"JSON",
|
|
"Math",
|
|
"Atomics",
|
|
"Intl",
|
|
"WebAssembly",
|
|
"Proxy",
|
|
"Object",
|
|
"Array",
|
|
"Function",
|
|
"Boolean",
|
|
"Symbol",
|
|
"Number",
|
|
"BigInt",
|
|
"String",
|
|
"RegExp",
|
|
"Date",
|
|
"Promise",
|
|
"Map",
|
|
"Set",
|
|
"WeakMap",
|
|
"WeakSet",
|
|
"Error",
|
|
"TypeError",
|
|
"RangeError",
|
|
"SyntaxError",
|
|
"ReferenceError",
|
|
"EvalError",
|
|
"URIError",
|
|
"AggregateError",
|
|
"ArrayBuffer",
|
|
"DataView",
|
|
"Int8Array",
|
|
"Uint8Array",
|
|
"Uint8ClampedArray",
|
|
"Int16Array",
|
|
"Uint16Array",
|
|
"Int32Array",
|
|
"Uint32Array",
|
|
"Float32Array",
|
|
"Float64Array",
|
|
"BigInt64Array",
|
|
"BigUint64Array",
|
|
"SharedArrayBuffer"
|
|
]);
|
|
if (builtInNames.has(prop.name))
|
|
continue;
|
|
if (prop.value.type === "function" && /^[A-Z][a-zA-Z0-9]*$/.test(prop.name))
|
|
continue;
|
|
let value;
|
|
let varRef = 0;
|
|
let displayType = prop.value.type;
|
|
const className = prop.value.className;
|
|
logger.debug(`Variable ${prop.name}: type=${prop.value.type}, className=${className}, description=${prop.value.description}, value=${JSON.stringify(prop.value.value)}`);
|
|
if (prop.value.type === "object" && prop.value.objectId) {
|
|
if (className === "String" && prop.value.description) {
|
|
value = prop.value.description.startsWith('"') ? prop.value.description : `"${prop.value.description}"`;
|
|
displayType = "string";
|
|
} else if (className === "Number" && prop.value.description) {
|
|
value = prop.value.description;
|
|
displayType = "number";
|
|
} else if (className === "Boolean" && prop.value.description) {
|
|
value = prop.value.description;
|
|
displayType = "boolean";
|
|
} else {
|
|
varRef = this.nextVarRef();
|
|
this.objectsMap.set(varRef, prop.value.objectId);
|
|
if (prop.value.preview && prop.value.preview.properties) {
|
|
const previewProps = prop.value.preview.properties;
|
|
const isArray = prop.value.subtype === "array" || prop.value.preview.subtype === "array";
|
|
if (isArray) {
|
|
const items = previewProps.map((p) => this.formatPreviewValue(p));
|
|
const suffix = prop.value.preview.overflow ? ", ..." : "";
|
|
value = `[${items.join(", ")}${suffix}]`;
|
|
} else {
|
|
const items = previewProps.map((p) => `${p.name}: ${this.formatPreviewValue(p)}`);
|
|
const suffix = prop.value.preview.overflow ? ", ..." : "";
|
|
value = `{ ${items.join(", ")}${suffix} }`;
|
|
}
|
|
} else {
|
|
value = prop.value.description || `[${prop.value.subtype || className || "Object"}]`;
|
|
}
|
|
}
|
|
} else if (prop.value.type === "string") {
|
|
value = prop.value.value !== undefined ? JSON.stringify(prop.value.value) : prop.value.description || '""';
|
|
displayType = "string";
|
|
} else if (prop.value.value !== undefined) {
|
|
value = JSON.stringify(prop.value.value);
|
|
} else {
|
|
value = prop.value.description || String(prop.value.type);
|
|
}
|
|
variables.push({
|
|
name: prop.name,
|
|
value,
|
|
type: displayType,
|
|
variablesReference: varRef
|
|
});
|
|
}
|
|
logger.info(`handleVariables: returning ${variables.length} variables (filtered from ${properties.length})`);
|
|
this.sendResponse(request, true, { variables });
|
|
} catch (error) {
|
|
logger.error("Failed to get variables:", error);
|
|
this.sendResponse(request, true, { variables: [] });
|
|
}
|
|
}
|
|
async handleEvaluate(request) {
|
|
const args = request.arguments || {};
|
|
let expression = args.expression || "";
|
|
const frameId = args.frameId;
|
|
const shouldAwait = /^\s*await\s+/.test(expression);
|
|
if (shouldAwait) {
|
|
expression = expression.replace(/^\s*await\s+/, "");
|
|
}
|
|
try {
|
|
let result;
|
|
let varRef = 0;
|
|
const formatResult = async (evalResult) => {
|
|
let result2 = "undefined";
|
|
let varRef2 = 0;
|
|
if (!evalResult)
|
|
return { result: result2, varRef: varRef2 };
|
|
if (evalResult.subtype === "promise" || evalResult.className === "Promise") {
|
|
if (evalResult.preview?.properties) {
|
|
const props = evalResult.preview.properties;
|
|
const statusProp = props.find((p) => p.name === "status");
|
|
const resultProp = props.find((p) => p.name === "result");
|
|
if (statusProp?.value === "fulfilled" && resultProp) {
|
|
if (resultProp.value !== undefined) {
|
|
return { result: resultProp.value, varRef: 0 };
|
|
}
|
|
if (resultProp.type === "object") {
|
|
if (resultProp.valuePreview?.properties) {
|
|
const objProps = resultProp.valuePreview.properties.map((p) => `${p.name}: ${p.value ?? "..."}`).join(", ");
|
|
return { result: `{${objProps}}`, varRef: 0 };
|
|
}
|
|
return { result: resultProp.subtype || "[Object]", varRef: 0 };
|
|
}
|
|
return { result: resultProp.type || "undefined", varRef: 0 };
|
|
} else if (statusProp?.value === "rejected" && resultProp) {
|
|
return { result: `Rejected: ${resultProp.value ?? resultProp.type}`, varRef: 0 };
|
|
}
|
|
if (statusProp?.value === "pending") {
|
|
return { result: "Promise { <pending> }", varRef: 0 };
|
|
}
|
|
}
|
|
}
|
|
if (evalResult.type === "undefined") {
|
|
result2 = "undefined";
|
|
} else if (evalResult.value !== undefined) {
|
|
result2 = JSON.stringify(evalResult.value);
|
|
} else if (evalResult.objectId) {
|
|
varRef2 = this.nextVarRef();
|
|
this.objectsMap.set(varRef2, evalResult.objectId);
|
|
if (evalResult.preview?.properties) {
|
|
const props = evalResult.preview.properties.map((p) => `${p.name}: ${p.value}`).join(", ");
|
|
result2 = `{${props}}`;
|
|
} else {
|
|
result2 = evalResult.description || "[Object]";
|
|
}
|
|
} else {
|
|
result2 = evalResult.description || String(evalResult.type);
|
|
}
|
|
return { result: result2, varRef: varRef2 };
|
|
};
|
|
if (frameId !== undefined && this.callFrames[frameId - 1]) {
|
|
const frame = this.callFrames[frameId - 1];
|
|
const response = await this.sendInspectorCommand("Debugger.evaluateOnCallFrame", {
|
|
callFrameId: frame.callFrameId,
|
|
expression,
|
|
returnByValue: false,
|
|
generatePreview: true
|
|
});
|
|
const evalResult = response.result?.result;
|
|
const formatted = await formatResult(evalResult);
|
|
result = formatted.result;
|
|
varRef = formatted.varRef;
|
|
} else {
|
|
const response = await this.sendInspectorCommand("Runtime.evaluate", {
|
|
expression,
|
|
returnByValue: false,
|
|
generatePreview: true
|
|
});
|
|
const evalResult = response.result?.result;
|
|
const formatted = await formatResult(evalResult);
|
|
result = formatted.result;
|
|
varRef = formatted.varRef;
|
|
}
|
|
this.sendResponse(request, true, { result, variablesReference: varRef });
|
|
} catch (error) {
|
|
this.sendResponse(request, true, {
|
|
result: `Error: ${error}`,
|
|
variablesReference: 0
|
|
});
|
|
}
|
|
}
|
|
async handleContinue(request) {
|
|
try {
|
|
this.isStepping = true;
|
|
this.pausedAtBreakpoint = false;
|
|
await this.sendInspectorCommand("Debugger.resume", {});
|
|
this.sendResponse(request, true, { allThreadsContinued: true });
|
|
} catch (error) {
|
|
this.isStepping = false;
|
|
this.sendResponse(request, false, {}, String(error));
|
|
}
|
|
}
|
|
async handleNext(request) {
|
|
try {
|
|
this.isStepping = true;
|
|
this.pausedAtBreakpoint = false;
|
|
await this.sendInspectorCommand("Debugger.stepOver", {});
|
|
this.sendResponse(request);
|
|
} catch (error) {
|
|
this.isStepping = false;
|
|
this.sendResponse(request, false, {}, String(error));
|
|
}
|
|
}
|
|
async handleStepIn(request) {
|
|
try {
|
|
this.isStepping = true;
|
|
this.pausedAtBreakpoint = false;
|
|
await this.sendInspectorCommand("Debugger.stepInto", {});
|
|
this.sendResponse(request);
|
|
} catch (error) {
|
|
this.isStepping = false;
|
|
this.sendResponse(request, false, {}, String(error));
|
|
}
|
|
}
|
|
async handleStepOut(request) {
|
|
try {
|
|
this.isStepping = true;
|
|
this.pausedAtBreakpoint = false;
|
|
await this.sendInspectorCommand("Debugger.stepOut", {});
|
|
this.sendResponse(request);
|
|
} catch (error) {
|
|
this.isStepping = false;
|
|
this.sendResponse(request, false, {}, String(error));
|
|
}
|
|
}
|
|
async handlePause(request) {
|
|
try {
|
|
await this.sendInspectorCommand("Debugger.pause", {});
|
|
this.sendResponse(request);
|
|
} catch (error) {
|
|
this.sendResponse(request, false, {}, String(error));
|
|
}
|
|
}
|
|
async handleDisconnect(request) {
|
|
this.running = false;
|
|
await this.cleanup();
|
|
this.sendResponse(request);
|
|
}
|
|
async handleTerminate(request) {
|
|
this.running = false;
|
|
const shouldSendTerminated = !this.terminatedSent;
|
|
this.terminatedSent = true;
|
|
await this.cleanup();
|
|
this.sendResponse(request);
|
|
if (shouldSendTerminated) {
|
|
this.sendEvent("terminated", this.scriptResult !== undefined ? { result: this.scriptResult } : {});
|
|
}
|
|
}
|
|
async cleanup() {
|
|
if (this.inspectorWs) {
|
|
this.inspectorWs.close();
|
|
this.inspectorWs = null;
|
|
}
|
|
if (this.process) {
|
|
this.process.kill();
|
|
this.process = null;
|
|
}
|
|
if (this.tempFile) {
|
|
try {
|
|
await unlink(this.tempFile);
|
|
} catch {}
|
|
this.tempFile = null;
|
|
}
|
|
if (this.tempDir) {
|
|
try {
|
|
await rmdir(this.tempDir);
|
|
} catch {}
|
|
this.tempDir = null;
|
|
}
|
|
}
|
|
async handleRequest(request) {
|
|
const command = request.command || "";
|
|
logger.debug(`Handling command: ${command}`);
|
|
const handlers = {
|
|
initialize: (req) => this.handleInitialize(req),
|
|
setBreakpoints: (req) => this.handleSetBreakpoints(req),
|
|
configurationDone: (req) => this.handleConfigurationDone(req),
|
|
launch: (req) => this.handleLaunch(req),
|
|
threads: (req) => this.handleThreads(req),
|
|
stackTrace: (req) => this.handleStackTrace(req),
|
|
scopes: (req) => this.handleScopes(req),
|
|
variables: (req) => this.handleVariables(req),
|
|
evaluate: (req) => this.handleEvaluate(req),
|
|
continue: (req) => this.handleContinue(req),
|
|
next: (req) => this.handleNext(req),
|
|
stepIn: (req) => this.handleStepIn(req),
|
|
stepOut: (req) => this.handleStepOut(req),
|
|
pause: (req) => this.handlePause(req),
|
|
disconnect: (req) => this.handleDisconnect(req),
|
|
terminate: (req) => this.handleTerminate(req)
|
|
};
|
|
const handler = handlers[command];
|
|
if (handler) {
|
|
await handler(request);
|
|
} else {
|
|
logger.warn(`Unhandled command: ${command}`);
|
|
this.sendResponse(request, false, {}, `Unsupported command: ${command}`);
|
|
}
|
|
}
|
|
}
|
|
var sessions = new Map;
|
|
var args = process.argv.slice(2);
|
|
var host = "localhost";
|
|
var port = 5680;
|
|
var windmillPath;
|
|
for (let i = 0;i < args.length; i++) {
|
|
if (args[i] === "--host" && args[i + 1]) {
|
|
host = args[i + 1];
|
|
i++;
|
|
} else if (args[i] === "--port" && args[i + 1]) {
|
|
port = parseInt(args[i + 1], 10);
|
|
i++;
|
|
} else if (args[i] === "--windmill" && args[i + 1]) {
|
|
windmillPath = args[i + 1];
|
|
i++;
|
|
}
|
|
}
|
|
if (windmillPath) {
|
|
logger.info(`Windmill binary path: ${windmillPath}`);
|
|
}
|
|
logger.info(`Starting DAP WebSocket server on ws://${host}:${port}`);
|
|
var server = Bun.serve({
|
|
hostname: host,
|
|
port,
|
|
fetch(req, server2) {
|
|
if (server2.upgrade(req)) {
|
|
return;
|
|
}
|
|
return new Response("DAP WebSocket Server for Bun/TypeScript", { status: 200 });
|
|
},
|
|
websocket: {
|
|
open(ws) {
|
|
logger.info("New client connected");
|
|
const wsWrapper = {
|
|
send: (data) => ws.send(data),
|
|
close: () => ws.close(),
|
|
readyState: WebSocket.OPEN,
|
|
CONNECTING: WebSocket.CONNECTING,
|
|
OPEN: WebSocket.OPEN,
|
|
CLOSING: WebSocket.CLOSING,
|
|
CLOSED: WebSocket.CLOSED
|
|
};
|
|
const session = new DebugSession(wsWrapper, { windmillPath });
|
|
sessions.set(ws, session);
|
|
},
|
|
async message(ws, message) {
|
|
const session = sessions.get(ws);
|
|
if (!session)
|
|
return;
|
|
try {
|
|
const data = JSON.parse(message);
|
|
logger.debug("Received:", JSON.stringify(data));
|
|
if (data.type === "request") {
|
|
await session.handleRequest(data);
|
|
}
|
|
} catch (error) {
|
|
logger.error("Error handling message:", error);
|
|
}
|
|
},
|
|
close(ws) {
|
|
logger.info("Client disconnected");
|
|
sessions.delete(ws);
|
|
}
|
|
}
|
|
});
|
|
logger.info(`Server started on ${server.url}`);
|
|
export {
|
|
DebugSession
|
|
};
|