#!/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 || "", source: { path: script.url || this.scriptPath || "", 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 { }", 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 };