#!/usr/bin/env bun /** * Lightweight DAP (Debug Adapter Protocol) WebSocket Server for Bun/TypeScript debugging. * * This server acts as a bridge between a WebSocket client (Monaco editor) and Bun's * built-in debugging capabilities using the WebKit Inspector Protocol (not V8/Chrome DevTools). * * Key differences from V8/Node.js debugging: * - Uses WebKit Inspector Protocol (similar to Safari DevTools) * - Requires Inspector.enable, Debugger.setPauseOnDebuggerStatements, and Inspector.initialized * - Breakpoints must be activated with Debugger.setBreakpointsActive * - Console output comes via Console.messageAdded instead of Runtime.consoleAPICalled * * It implements a minimal subset of DAP to support basic TypeScript debugging: * - Setting breakpoints * - Stepping through code (step in, step over, step out, continue) * - Inspecting variables and stack frames * - Evaluating expressions * * Usage: * bun run dap_websocket_server_bun.ts [--port PORT] [--host HOST] */ import { spawn, type Subprocess } from 'bun' import { readFileSync } from 'node:fs' import { mkdtemp, writeFile, unlink, rmdir, symlink } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { sessionEnv } from './env_passthrough' import { fetchRegistryConfig, type RegistryConfig } from './registry_config' // Types for V8 Inspector Protocol interface V8Message { id?: number method?: string params?: Record result?: Record error?: { message: string; code?: number } } interface V8CallFrame { callFrameId: string functionName: string location: { scriptId: string lineNumber: number columnNumber: number } scopeChain: Array<{ type: string object: { objectId?: string } name?: string }> this?: { objectId?: string } } interface V8Script { scriptId: string url: string startLine: number startColumn: number endLine: number endColumn: number hash: string sourceMapURL?: string } // DAP Message types interface DAPMessage { seq: number type: 'request' | 'response' | 'event' command?: string event?: string request_seq?: number success?: boolean message?: string body?: Record arguments?: Record } interface StackFrame { id: number name: string source: { path: string; name: string } line: number column: number } interface Variable { name: string value: string type: string variablesReference: number } interface Breakpoint { id: number verified: boolean line: number source?: { path: string; name?: string } } // Logging const LOG_LEVEL = process.env.LOG_LEVEL || 'DEBUG' const logger = { debug: (...args: unknown[]) => { if (LOG_LEVEL === 'DEBUG') console.log('[DEBUG]', new Date().toISOString(), ...args) }, info: (...args: unknown[]) => console.log('[INFO]', new Date().toISOString(), ...args), warn: (...args: unknown[]) => console.warn('[WARN]', new Date().toISOString(), ...args), error: (...args: unknown[]) => console.error('[ERROR]', new Date().toISOString(), ...args) } /** * Parse the main function parameters from TypeScript/JavaScript code. * Returns an array of parameter names in order. * * Handles various function declaration styles: * - export function main(a, b, c) * - export async function main(a: string, b: number) * - export const main = (a, b) => ... * - export const main = async (a, b) => ... */ function parseMainFunctionParams(code: string): string[] { // Match various main function declaration patterns // Pattern 1: function main(...) or async function main(...) const funcPattern = /(?:export\s+)?(?:async\s+)?function\s+main\s*\(([^)]*)\)/ // Pattern 2: const main = (...) => or const main = async (...) => const arrowPattern = /(?:export\s+)?const\s+main\s*=\s*(?:async\s*)?\(([^)]*)\)/ let paramsStr: string | null = null const funcMatch = code.match(funcPattern) if (funcMatch) { paramsStr = funcMatch[1] } else { const arrowMatch = code.match(arrowPattern) if (arrowMatch) { paramsStr = arrowMatch[1] } } if (!paramsStr || paramsStr.trim() === '') { return [] } // Parse parameter names, handling TypeScript type annotations // Split by comma, but be careful of generic types like Array const params: string[] = [] let depth = 0 let current = '' for (const char of paramsStr) { if (char === '<' || char === '(' || char === '[' || char === '{') { depth++ current += char } else if (char === '>' || char === ')' || char === ']' || char === '}') { depth-- current += char } else if (char === ',' && depth === 0) { const param = current.trim() if (param) { // Extract just the parameter name (before : or = or ?) const nameMatch = param.match(/^(\w+)/) if (nameMatch) { params.push(nameMatch[1]) } } current = '' } else { current += char } } // Don't forget the last parameter const lastParam = current.trim() if (lastParam) { const nameMatch = lastParam.match(/^(\w+)/) if (nameMatch) { params.push(nameMatch[1]) } } return params } /** * Generate the arguments string for calling main() with the correct parameter order. * This handles the case where undefined values are stripped by JSON serialization. * * @param code - The source code containing the main function * @param args - The args object with parameter values (may have missing keys for undefined) * @returns A string like "undefined, \"hello\", \"world\"" for main(a, b, c) with a=undefined */ function generateMainCallArgs(code: string, args: Record): string { const paramNames = parseMainFunctionParams(code) if (paramNames.length === 0) { // Fallback to old behavior if we can't parse the signature return Object.values(args) .map((v) => JSON.stringify(v)) .join(', ') } // Generate args in the correct order based on parameter names return paramNames .map((name) => { if (name in args) { const value = args[name] // Handle undefined explicitly (JSON.stringify returns undefined for undefined) if (value === undefined) { return 'undefined' } return JSON.stringify(value) } else { // Parameter not in args object - was likely undefined and stripped by JSON return 'undefined' } }) .join(', ') } // JWT verification for debug requests // The debugger fetches the public key from the Windmill backend's JWKS endpoint const WINDMILL_BASE_URL = process.env.WINDMILL_BASE_URL || process.env.BASE_INTERNAL_URL // e.g., http://localhost:8000 const REQUIRE_SIGNED_REQUESTS = process.env.REQUIRE_SIGNED_DEBUG_REQUESTS !== 'false' const PREPARE_DEPS_TIMEOUT_MS = 120_000 // Opt-in cross-origin protection (CSWSH defense-in-depth); see // dap_debug_service.ts for the rationale. Only enforced for this file's // standalone Bun.serve entrypoint (the windmill-extra runtime imports the // DebugSession class and runs the guarded server in dap_debug_service.ts). const ALLOWED_ORIGINS = (process.env.DEBUG_ALLOWED_ORIGINS || '') .split(',') .map(o => o.trim()) .filter(Boolean) function isOriginRejected(req: Request): boolean { const origin = req.headers.get('origin') if (!origin || ALLOWED_ORIGINS.length === 0) return false return !ALLOWED_ORIGINS.includes(origin) } interface JWK { kty: string crv: string x: string kid: string use: string alg: string } interface JWKS { keys: JWK[] } interface DebugTokenClaims { code_hash: string language: string workspace_id: string email: string iat: number exp: number job_id: string } interface ExpressionTokenClaims { expression_hash: string job_id: string workspace_id: string email: string iat: number exp: number } // Cached public key let cachedPublicKey: CryptoKey | null = null let publicKeyFetchPromise: Promise | null = null /** * Fetch and cache the Ed25519 public key from the JWKS endpoint. */ async function getPublicKey(): Promise { 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: 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}`) } // Decode the public key from base64url const publicKeyBytes = base64urlDecode(jwk.x) // Import as Ed25519 public key 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 } /** * Compute SHA-256 hash of code and return first 16 bytes as hex. */ async function computeCodeHash(code: string): Promise { 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('') } /** * Base64url decode */ function base64urlDecode(str: string): Uint8Array { // Add padding if needed 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)) } /** * Verify a JWT debug token. * Returns null if valid, or an error message if invalid. */ async function verifyDebugToken(token: string, code: string): Promise { 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 } // Parse JWT const parts = token.split('.') if (parts.length !== 3) { return 'Invalid JWT format' } const [headerB64, claimsB64, signatureB64] = parts try { // Verify signature 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' } // Parse and validate claims const claimsJson = new TextDecoder().decode(base64urlDecode(claimsB64)) const claims: DebugTokenClaims = JSON.parse(claimsJson) // Check expiration const now = Math.floor(Date.now() / 1000) if (now > claims.exp) { return `Token expired: ${now - claims.exp} seconds ago` } // Verify code hash 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}` } } /** * Compute SHA-256 hash of an expression and return first 16 bytes as hex. */ async function computeExpressionHash(expression: string): Promise { const encoder = new TextEncoder() const data = encoder.encode(expression) 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('') } /** * Verify a JWT expression token. * Returns null if valid, or an error message if invalid. * Note: Expression tokens are optional - if not provided, the expression is still evaluated * but just not audit logged. This maintains backwards compatibility. */ async function verifyExpressionToken(token: string, expression: string): Promise { const publicKey = await getPublicKey() if (!publicKey) { // Expression tokens are optional - don't block if public key unavailable logger.debug('Public key not available - skipping expression token verification') return null } // Parse JWT const parts = token.split('.') if (parts.length !== 3) { return 'Invalid JWT format' } const [headerB64, claimsB64, signatureB64] = parts try { // Verify signature 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 expression token signature' } // Parse and validate claims const claimsJson = new TextDecoder().decode(base64urlDecode(claimsB64)) const claims: ExpressionTokenClaims = JSON.parse(claimsJson) // Check expiration const now = Math.floor(Date.now() / 1000) if (now > claims.exp) { return `Expression token expired: ${now - claims.exp} seconds ago` } // Verify expression hash const expectedHash = await computeExpressionHash(expression) if (claims.expression_hash !== expectedHash) { return 'Expression hash mismatch - expression was modified after signing' } logger.info(`Verified expression token from ${claims.email} in workspace ${claims.workspace_id} (job: ${claims.job_id})`) return null } catch (error) { return `Expression token verification error: ${error}` } } /** * Remove version specifiers from import statements. * Transforms `import x from "package@1.2.3"` to `import x from "package"` * This matches the behavior of the Windmill backend's remove_pinned_imports function. */ function removePinnedImports(code: string): string { // Regex to match import specifiers with version pins // Handles both scoped packages (@scope/package@version) and regular packages (package@version) // Group 1: package name (including scope if present) // The @version part is matched but not captured // Group 2: any path suffix after the package (e.g., /subpath) const IMPORTS_VERSION = /^((?:@[^/@]+\/[^/@]+)|(?:[^/@]+))(?:@[^/]+)?(.*)$/ // Find all string literals that look like imports (in quotes) // This regex finds strings in import/export statements const importRegex = /(?:import|export).*?from\s+['"]([^'"]+)['"]/g let result = code let match: RegExpExecArray | null // Collect all imports first to avoid issues with overlapping replacements const imports: string[] = [] while ((match = importRegex.exec(code)) !== null) { imports.push(match[1]) } // Sort by length descending to handle longer matches first (avoids partial replacements) imports.sort((a, b) => b.length - a.length) // Process each import and remove version specifiers 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 // Only replace if we actually removed a version if (newImportPath !== importPath) { // Replace all occurrences of this import in the code result = result.split(`"${importPath}"`).join(`"${newImportPath}"`) result = result.split(`'${importPath}'`).join(`'${newImportPath}'`) logger.debug(`Removed version from import: "${importPath}" -> "${newImportPath}"`) } } } return result } // Nsjail configuration for sandboxed execution export interface NsjailConfig { enabled: boolean binaryPath: string configPath?: string extraArgs?: string[] } /** * Wrap a command so nsjail runs it, or return it unchanged when sandboxing is off. * The environment is not filtered here: the config sets `keep_env`, so the jailed process * receives whatever the spawning call gives it. */ export function nsjailWrap(cmd: string[], nsjail: NsjailConfig | undefined, cwd?: string): string[] { if (!nsjail?.enabled) { return cmd } const wrapped = [nsjail.binaryPath] if (nsjail.configPath) { wrapped.push('--config', nsjail.configPath) } if (nsjail.extraArgs) { wrapped.push(...nsjail.extraArgs) } if (cwd) { wrapped.push('--cwd', cwd) } wrapped.push('--', ...cmd) return wrapped } /** * SIGKILL a subprocess along with everything it spawned. * * SIGKILL because `windmill prepare-deps` does not act on SIGTERM while uv is running. The * whole group because uv is a grandchild: signalling the child alone reparents uv to init and * it keeps downloading. The group id is read back from /proc instead of assumed, since a group * kill aimed at this service's own group would take down every service in the container; a * child spawned without `detached` therefore only gets the plain kill. */ export function killProcessTree(proc: Subprocess): void { if (proc.exitCode !== null || proc.signalCode !== null) { // Nothing left to signal, and the pid may already have been handed to someone else return } let ownsGroup = false try { const stat = readFileSync(`/proc/${proc.pid}/stat`, 'utf8') // The comm field can hold spaces and parentheses, so read the fields after its closing one ownsGroup = Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[2]) === proc.pid } catch { // Already reaped, or not Linux: fall back to killing the process alone } try { if (ownsGroup) { process.kill(-proc.pid, 'SIGKILL') } else { proc.kill('SIGKILL') } } catch (error) { logger.error('Failed to kill process:', error) } } /** * VLQ (Variable-Length Quantity) decoder for source maps. * Returns array of decoded integers from VLQ string. */ function decodeVLQ(encoded: string): number[] { const VLQ_BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' const values: number[] = [] 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 } /** * Source map line mappings - both directions. */ interface SourceMapMappings { // original 0-indexed line -> transpiled 0-indexed line (for setting breakpoints) originalToTranspiled: Map // transpiled 0-indexed line -> original 0-indexed line (for reporting positions) transpiledToOriginal: Map } /** * Parse source map and build bidirectional line mapping. * Returns Maps for both original->transpiled and transpiled->original mappings. * * Important: When multiple original lines map to the same transpiled line (common with * TypeScript transpilation), we store the LAST/HIGHEST original line for the reverse * mapping. This is because Bun compresses multi-line function signatures, but the * actual statement being executed typically corresponds to the highest original line * that maps to that transpiled line. */ function parseSourceMapLineMapping(sourceMapUrl: string): SourceMapMappings { const mappings: SourceMapMappings = { originalToTranspiled: new Map(), transpiledToOriginal: new Map() } try { // Extract base64 content from data URL 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) as { mappings: string sources?: string[] sourcesContent?: string[] } if (!sourceMap.mappings) { return mappings } // Parse VLQ mappings // Each line in transpiled code is separated by ';' // Each segment within a line is separated by ',' // Segment format: [transpiled column, source index, original line, original column, name index] 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) { // values[2] is the delta for original line originalLine += values[2] // Map this original line to this transpiled line (for setting breakpoints) // Only store the first occurrence (first transpiled line for this original line) // This ensures breakpoints are set at the earliest transpiled line containing the original code if (!mappings.originalToTranspiled.has(originalLine)) { mappings.originalToTranspiled.set(originalLine, transpiledLine) logger.debug(`Source map: original line ${originalLine} -> transpiled line ${transpiledLine}`) } // Map transpiled line to original line (for reporting positions) // ALWAYS update to store the HIGHEST original line for each transpiled line. // This is critical because when Bun compresses code (e.g., multi-line function // params into one line), the actual statement being executed corresponds to // the highest original line that maps to that transpiled line. // Example: transpiled line 3 might map to original lines 6 and 7; // when stopped on transpiled line 3, we want to report line 7 (the return statement) // not line 6 (the console.log that happened earlier). 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 } /** * Manages a debug session with a Bun subprocess. */ export class DebugSession { private ws: WebSocket private seq = 1 private initialized = false private configured = false private running = false private terminatedSent = false // Bun subprocess private process: Subprocess | null = null private inspectorWs: WebSocket | null = null private inspectorSeq = 1 private pendingInspectorRequests = new Map< number, { resolve: (value: V8Message) => void; reject: (error: Error) => void } >() // Script and breakpoint tracking private scriptPath: string | null = null private tempDir: string | null = null private tempFile: string | null = null private breakpoints = new Map() // file -> line numbers private breakpointIds = new Map() // file -> V8 breakpoint IDs // Script ID mapping (V8 uses script IDs, we need to map to file paths) private scripts = new Map() private mainScriptId: string | null = null // Source map line mappings for both directions // This is needed because Bun transpiles TypeScript and may strip blank lines/comments private sourceMapMappings: SourceMapMappings = { originalToTranspiled: new Map(), transpiledToOriginal: new Map() } // Call frames when paused private callFrames: V8CallFrame[] = [] private variablesRefCounter = 1 private scopesMap = new Map() private objectsMap = new Map() // variablesRef -> objectId // For calling main() private callMain = false private mainArgs: Record = {} // Buffer console output during stepping to ensure correct order private isStepping = false private pendingConsoleOutput: Array> = [] // Captured result from main() execution private scriptResult: unknown = undefined // Track if current pause is due to a breakpoint (for line number correction) private pausedAtBreakpoint = false // Environment variables to pass to the debugger subprocess (e.g., WM_WORKSPACE, WM_TOKEN, etc.) private envVars: Record = {} // Nsjail configuration for sandboxed execution private nsjailConfig?: NsjailConfig // Custom bun binary path (can be overridden) private bunPath: string = '/usr/bin/bun' // Windmill binary path for prepare-deps CLI (optional, for dependency installation) private windmillPath?: string // Path to installed node_modules (set after prepare-deps runs) private nodeModulesPath?: string // Running dependency installer, so a teardown mid-install can stop it private prepareDepsProcess: Subprocess | null = null private disposed = false constructor(ws: WebSocket, options?: { nsjailConfig?: NsjailConfig; bunPath?: string; windmillPath?: string }) { this.ws = ws this.nsjailConfig = options?.nsjailConfig this.bunPath = options?.bunPath || '/usr/bin/bun' this.windmillPath = options?.windmillPath } private nextSeq(): number { return this.seq++ } private nextInspectorSeq(): number { return this.inspectorSeq++ } private nextVarRef(): number { return this.variablesRefCounter++ } /** * Send a DAP message to the client. */ private sendMessage(msg: DAPMessage): void { const data = JSON.stringify(msg) logger.debug('Sending DAP:', data) this.ws.send(data) } /** * Send a DAP response. */ private sendResponse( request: DAPMessage, success = true, body: Record = {}, message = '' ): void { this.sendMessage({ seq: this.nextSeq(), type: 'response', command: request.command || '', request_seq: request.seq, success, message, body }) } /** * Send a DAP event. */ private sendEvent(event: string, body: Record = {}): void { this.sendMessage({ seq: this.nextSeq(), type: 'event', event, body }) } /** * Send a V8 Inspector command and wait for response. */ private async sendInspectorCommand( method: string, params: Record = {} ): Promise { 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}`)) }, 10000) 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)) }) } /** * Handle messages from the V8 Inspector. */ private handleInspectorMessage(data: string): void { try { const message: V8Message = JSON.parse(data) logger.debug('Inspector message:', data) // Handle responses 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 } // Handle events if (message.method) { this.handleInspectorEvent(message) } } catch (error) { logger.error('Failed to parse inspector message:', error) } } /** * Handle V8 Inspector events. */ private handleInspectorEvent(message: V8Message): void { const params = message.params || {} switch (message.method) { case 'Debugger.scriptParsed': this.handleScriptParsed(params as unknown as V8Script) 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': // Bun's WebKit Inspector uses Console.messageAdded instead of Runtime.consoleAPICalled 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) } } /** * Handle script parsed event - track script IDs and parse source maps. */ private handleScriptParsed(script: V8Script): void { this.scripts.set(script.scriptId, script) // Identify the main 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})`) // Parse source map to get bidirectional line mappings // This is crucial because Bun transpiles TypeScript and may strip blank lines/comments 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`) } } } /** * Apply breakpoints using URL pattern (works before script is parsed). * This is called before the script starts running. */ private async applyBreakpointsByUrl(): Promise { logger.info(`applyBreakpointsByUrl called. scriptPath: ${this.scriptPath}, breakpoints: ${JSON.stringify([...this.breakpoints.entries()])}`) if (!this.scriptPath) { logger.warn('No scriptPath set, skipping breakpoints') return } // Clear existing breakpoints first for (const [filePath, ids] of this.breakpointIds) { for (const id of ids) { try { await this.sendInspectorCommand('Debugger.removeBreakpoint', { breakpointId: id }) } catch { // Ignore errors during removal } } } this.breakpointIds.clear() // Set breakpoints by URL pattern for (const [filePath, lines] of this.breakpoints) { logger.info(`Setting breakpoints for ${filePath}: lines ${lines}`) const ids: string[] = [] for (const line of lines) { try { // Use the actual script path as URL regex // For file URLs, we match the end of the path const urlRegex = this.scriptPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Line number conversion: // - User line N (1-indexed from DAP/Monaco) // - After debugger injection at line 0: user line N -> temp file line N (0-indexed) // - But Bun transpiles TypeScript and may strip blank lines/comments! // - We need to use the source map to find the correct transpiled line // // The source map maps original 0-indexed lines to transpiled 0-indexed lines. // Original line 0 is the injected debugger statement. // User line N (1-indexed) in original = line N (0-indexed) in temp file after injection. const originalLine0Indexed = line // After injection, user line N = temp file line N (0-indexed) // Look up the transpiled line from the source map 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 { // If not found in source map, try to find the nearest mapped line // This handles cases where the exact line isn't in the map (comments, blank lines) 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 as string) 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}`) } // Track if this is the initial pause from our injected debugger statement private initialPauseDone = false /** * Handle paused event. */ private async handlePaused(params: Record): Promise { const callFrames = params.callFrames as V8CallFrame[] | undefined const reason = params.reason as string logger.info(`handlePaused called: reason=${reason}, lineNumber=${callFrames?.[0]?.location?.lineNumber}, scriptId=${callFrames?.[0]?.location?.scriptId}`) if (callFrames) { this.callFrames = callFrames } // Check if this is the initial pause from our injected debugger statement // We detect this by checking if we're on line 0 (our injected line) and reason is DebuggerStatement 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') // Re-apply breakpoints now that the script is fully parsed // This ensures breakpoints resolve to actual code locations logger.info('Re-applying breakpoints after script is parsed...') await this.applyBreakpointsByUrl() // Auto-resume to continue to actual user code try { await this.sendInspectorCommand('Debugger.resume', {}) logger.info('Auto-resumed after initial pause') } catch (error) { logger.error('Failed to auto-resume:', error) } return // Don't send stopped event for the initial debugger pause } // Mark initial pause as done if we get here this.initialPauseDone = true // Map reason to DAP reason let dapReason: string switch (reason) { case 'Breakpoint': case 'breakpoint': dapReason = 'breakpoint' break case 'DebuggerStatement': dapReason = 'breakpoint' // Treat user's debugger; statements as breakpoints break case 'step': dapReason = 'step' break case 'exception': dapReason = 'exception' break case 'debugCommand': dapReason = 'pause' break default: dapReason = 'step' } // Get current line from first call frame // lineNumber is 0-indexed in WebKit inspector (transpiled line) // We need to convert it back to the original user line using the source map let line: number | undefined if (callFrames && callFrames.length > 0) { const transpiledLine = callFrames[0].location.lineNumber // Convert transpiled line back to original line using source map 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 { // If not found in reverse map, use transpiled line as-is 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 }) // Flush any buffered console output that occurred during stepping this.isStepping = false for (const outputEvent of this.pendingConsoleOutput) { this.sendEvent('output', outputEvent) } this.pendingConsoleOutput = [] } /** * Handle console output (V8/Chrome format). */ private handleConsoleOutput(params: Record): void { const type = params.type as string const args = params.args as Array<{ type: string; value?: unknown; description?: string }> 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 + '\n' }) } /** * Format a console parameter for output. * Handles primitives, objects with preview, arrays, etc. */ private formatConsoleParameter(p: { type: string value?: unknown description?: string subtype?: string className?: string objectId?: string preview?: { type: string subtype?: string description?: string overflow?: boolean properties?: Array<{ name: string; type: string; value?: string; subtype?: string }> } }): string { // Handle primitives with direct values if (p.value !== undefined) { if (typeof p.value === 'string') return p.value return String(p.value) } // Handle null if (p.subtype === 'null') return 'null' // Handle undefined if (p.type === 'undefined') return 'undefined' // Handle objects with preview (this is the key fix for process.env, etc.) 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) { // Format as array: [val1, val2, ...] const items = props.map((prop) => this.formatPreviewValue(prop)) const suffix = p.preview.overflow ? ', ...' : '' return `[${items.join(', ')}${suffix}]` } else { // Format as object: { key1: val1, key2: val2, ... } const items = props.map((prop) => `${prop.name}: ${this.formatPreviewValue(prop)}`) const suffix = p.preview.overflow ? ', ...' : '' return `{ ${items.join(', ')}${suffix} }` } } // Handle functions if (p.type === 'function') { return p.description || '[Function]' } // Fallback to description (for other object types without preview) if (p.description) return p.description return '' } /** * Format a single property from an object preview. */ private formatPreviewValue(prop: { name: string; type: string; value?: string; subtype?: string }): string { 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') { // Nested objects just show their type/value preview if (prop.subtype === 'array') return prop.value || '[]' return prop.value || '{...}' } return prop.value ?? '' } /** * Handle console output (WebKit/Bun format - Console.messageAdded). */ private handleConsoleMessageAdded(params: Record): void { const message = params.message as { source?: string level?: string text?: string type?: string line?: number column?: number url?: string parameters?: Array<{ type: string value?: unknown description?: string subtype?: string className?: string objectId?: string preview?: { type: string subtype?: string description?: string overflow?: boolean properties?: Array<{ name: string; type: string; value?: string; subtype?: string }> } }> } if (!message) return let output: string if (message.parameters && message.parameters.length > 0) { output = message.parameters .map((p) => this.formatConsoleParameter(p)) .join(' ') } else { output = message.text || '' } // Check for __WINDMILL_RESULT__ prefix and capture the result if (output.startsWith('__WINDMILL_RESULT__:')) { try { const resultJson = output.substring('__WINDMILL_RESULT__:'.length) this.scriptResult = JSON.parse(resultJson) logger.info(`Captured script result: ${resultJson}`) // Flush any pending console output before sending terminated // (output may have been buffered during stepping/continue) this.isStepping = false for (const pendingOutput of this.pendingConsoleOutput) { this.sendEvent('output', pendingOutput) } this.pendingConsoleOutput = [] // Send terminated event immediately after capturing result // This is more reliable than waiting for process.exited or inspector.onclose 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) } // Don't send this as output to the client return } const category = message.level === 'error' || message.level === 'warning' ? 'stderr' : 'stdout' // Build the output event with optional source location const outputEvent: Record = { category, output: output + '\n' } // If line info is available, include source reference // Line numbers from WebKit are 0-indexed, but we have injected debugger at line 0 // So inspector line N = user line N (same as breakpoint handling) if (message.line !== undefined && message.url) { outputEvent.source = { path: message.url, name: message.url.split('/').pop() || 'script.ts' } outputEvent.line = message.line // 0-indexed + 1 - 1 for injected debugger = same if (message.column !== undefined) { outputEvent.column = message.column + 1 } } // Buffer output during stepping to ensure it appears after the stopped event if (this.isStepping) { this.pendingConsoleOutput.push(outputEvent) } else { this.sendEvent('output', outputEvent) } } /** * Handle runtime exceptions. */ private handleException(params: Record): void { const exceptionDetails = params.exceptionDetails as { text?: string exception?: { description?: string } } if (exceptionDetails) { const message = exceptionDetails.exception?.description || exceptionDetails.text || 'Unknown exception' this.sendEvent('output', { category: 'stderr', output: `Exception: ${message}\n` }) } } /** * Handle the 'initialize' request. */ async handleInitialize(request: DAPMessage): Promise { 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') } /** * Handle the 'setBreakpoints' request. */ async handleSetBreakpoints(request: DAPMessage): Promise { const args = request.arguments || {} const source = args.source as { path?: string } | undefined const sourcePath = source?.path || '' const breakpointsData = (args.breakpoints as Array<{ line: number }>) || [] const verifiedBreakpoints: Breakpoint[] = [] const lineNumbers: number[] = [] for (const bp of breakpointsData) { const line = bp.line lineNumbers.push(line) verifiedBreakpoints.push({ id: verifiedBreakpoints.length + 1, verified: true, line, source: { path: sourcePath } }) } // Store breakpoints this.breakpoints.set(sourcePath, lineNumbers) logger.info(`Stored breakpoints at lines ${lineNumbers} for ${sourcePath}`) // If inspector is connected, apply breakpoints immediately if (this.inspectorWs) { await this.applyBreakpointsByUrl() } this.sendResponse(request, true, { breakpoints: verifiedBreakpoints }) } /** * Handle the 'configurationDone' request. */ async handleConfigurationDone(request: DAPMessage): Promise { this.configured = true this.sendResponse(request) } /** * Handle the 'launch' request. */ async handleLaunch(request: DAPMessage): Promise { // Per launch, not per session: cleanup() also runs when a program finishes normally, and // the flag must only mean "torn down while this launch was still preparing". this.disposed = false const args = request.arguments || {} let code = args.code as string | undefined this.scriptPath = args.program as string | undefined let cwd = (args.cwd as string) || process.cwd() this.callMain = (args.callMain as boolean) || false this.mainArgs = (args.args as Record) || {} this.envVars = (args.env as Record) || {} // Also what authorizes the registry configuration fetch below. const token = args.token as string | undefined // Enforce signing on every launch. The token is passed in the launch // arguments and is verified against the inline `code` (see windmill-api-debug). if (REQUIRE_SIGNED_REQUESTS) { // The backend only signs inline `code`; a `program`-mode launch names an // arbitrary server-side file path that gets read and executed and is never // signed. Refuse it so it cannot bypass token verification entirely. if (this.scriptPath) { logger.error('Rejected program-mode launch: only signed inline code is permitted') this.sendResponse(request, false, {}, 'program-mode launch is not permitted; submit signed code instead') return } 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 BASE_INTERNAL_URL is set on the server, use it to override WM_BASE_URL 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 } // Prepare dependencies using the original code (before any modifications) // This analyzes imports and installs required npm packages if (code) { const registry = await fetchRegistryConfig(token, logger) // A round trip of its own, during which the client can give up: the installer runs the // packages' postinstall scripts, so starting one for a session that is already gone // executes package code nobody is waiting for. if (this.disposed) { logger.info('Session was torn down during the registry configuration fetch, not installing') this.sendResponse(request, false, {}, 'Session terminated during dependency preparation') return } if (registry.message) { this.sendEvent('output', { category: 'console', output: `${registry.message}\n` }) } this.nodeModulesPath = await this.prepareDependencies(code, registry) || undefined // Installing takes long enough for the client to give up meanwhile, and cleanup() has // then already run: starting the debuggee now would leak a process nothing owns. // The response still goes out, since a client that terminated without closing the // socket is otherwise left waiting out its own launch timeout. if (this.disposed) { logger.info('Session was torn down during dependency preparation, not starting Bun') this.sendResponse(request, false, {}, 'Session terminated during dependency preparation') return } // Remove version specifiers from imports (e.g., "lodash@4" -> "lodash") // This must happen AFTER prepareDependencies (which needs the versions) // but BEFORE the code is executed (Bun doesn't understand @version syntax) code = removePinnedImports(code) } // Reset state for new launch this.initialPauseDone = false // Inject a debugger statement at the beginning to force initial pause // This gives us a known pause point where we can ensure breakpoints are set code = `debugger; // Auto-injected by Windmill debugger\n${code || ''}` // If callMain is true, append a call to main() with the provided args if (this.callMain && code) { // Generate arguments in the correct order by parsing the function signature. // This handles the case where undefined values are stripped by JSON serialization. const argsValues = generateMainCallArgs(code, this.mainArgs) code = code + `\n\n// Auto-generated call to main entrypoint\n` + `globalThis.__windmill_result__ = await main(${argsValues});\n` + `console.log("__WINDMILL_RESULT__:" + JSON.stringify(globalThis.__windmill_result__));\n` + `// Small delay to ensure console output is delivered via inspector before process exits\n` + `await new Promise(r => setTimeout(r, 50));\n` logger.info(`Added main() call with args: ${argsValues}`) } // Write code to temp file if provided 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 // Use temp directory as cwd so bun can find the script and node_modules cwd = this.tempDir logger.info(`Wrote code to ${this.tempFile}, cwd=${cwd}`) // Log lines around breakpoint for debugging const lines = code.split('\n') 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 we have installed node_modules, symlink them into the temp directory // so Bun can find them when running the script 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}`) // Don't fail the launch - try NODE_PATH as fallback } } } catch (error) { this.sendResponse(request, false, {}, `Failed to create temp file: ${error}`) return } } this.sendResponse(request) // Start Bun with inspect-wait try { await this.startBunProcess(cwd) } catch (error) { this.sendEvent('output', { category: 'stderr', output: `Failed to start Bun: ${error}\n` }) this.sendEvent('terminated', { error: String(error) }) } } // Store the inspector WebSocket URL when parsed from stderr private inspectorWsUrl: string | null = null private inspectorWsUrlPromise: { resolve: (url: string) => void; reject: (error: Error) => void } | null = null /** * Prepare dependencies by calling the windmill CLI's prepare-deps command. * This analyzes imports in the code and installs required npm packages. * Returns the path to node_modules if any were installed. * * Jailed on the same terms as the debuggee: `bun install` runs the packages' postinstall * scripts, which is user-supplied code executing next to the other services in the container. * Its environment is inherited rather than filtered, which is what carries the CA settings * into the installer (the jail keeps the environment across the boundary). * * The CLI has no database, so `registry` carries the instance's registry settings down to it * instead. They configure `bun install` and nothing else: the debugged script never gets * them, since it could read them back out of the process it runs in. */ private async prepareDependencies( code: string, registry: RegistryConfig, language: string = 'bun' ): Promise { if (!this.windmillPath) { logger.info('No windmill binary path configured, skipping dependency preparation') return null } logger.info(`Preparing dependencies using ${this.windmillPath}`) // The launch response is only sent once this returns, so without progress a cold // cache looks like a frozen debugger for as long as the install takes. this.sendEvent('output', { category: 'console', output: 'Preparing dependencies...\n' }) let waited = 0 const progress = setInterval(() => { waited += 5 this.sendEvent('output', { category: 'console', output: `Still preparing dependencies... (${waited}s)\n` }) }, 5000) let killTimer: ReturnType | undefined let timedOut = false try { const input = JSON.stringify({ code, language, registry }) + '\n' logger.info(`prepare-deps input length: ${input.length}`) // Spawn the windmill binary with prepare-deps command. Its environment is inherited // rather than filtered, which is what gives prepare-deps the container's index and // certificate settings; the allowlist above is what keeps them from the debugged // script, and the jail keeps them across its own boundary. const cmd = nsjailWrap([this.windmillPath, 'prepare-deps'], this.nsjailConfig) logger.info(`Spawning${this.nsjailConfig?.enabled ? ' with nsjail' : ''}: ${cmd.join(' ')}`) const proc = spawn({ cmd, stdin: new Blob([input]), // Use Blob for complete stdin data stdout: 'pipe', stderr: 'pipe', // So the installer and the bun it spawns can be killed as one group detached: true }) this.prepareDepsProcess = proc // Bound the wait: the only other ceiling is the DAP client's launch timeout, // which is minutes, so a wedged installer would hang the session that long. killTimer = setTimeout(() => { timedOut = true logger.error(`prepare-deps timed out after ${PREPARE_DEPS_TIMEOUT_MS}ms`) killProcessTree(proc) }, PREPARE_DEPS_TIMEOUT_MS) // Wait for completion const output = await new Response(proc.stdout).text() const stderr = await new Response(proc.stderr).text() // The read also ends when cleanup() kills the installer, which leaves no output to // parse. Reporting that as an install failure blames the user for their own Stop. if (this.disposed) { return null } if (timedOut) { const errorMsg = `prepare-deps timed out after ${PREPARE_DEPS_TIMEOUT_MS / 1000}s` this.sendEvent('output', { category: 'console', output: `Warning: Failed to prepare dependencies: ${errorMsg}\n` }) return null } logger.info(`prepare-deps output: ${output.substring(0, 200)}`) logger.info(`prepare-deps stderr: ${stderr.substring(0, 200)}`) if (stderr) { // Filter out the "Running in standalone mode" message const filteredStderr = stderr.split('\n').filter(line => !line.includes('Running in standalone mode')).join('\n').trim() if (filteredStderr) { logger.debug(`prepare-deps stderr: ${filteredStderr}`) } } // Parse the JSON response const lastLine = output.trim().split('\n').pop() || '' if (!lastLine || !lastLine.startsWith('{')) { // If output is empty or not JSON, the windmill binary likely failed to start const errorMsg = stderr.trim() || 'windmill binary failed (no output)' logger.error(`prepare-deps failed: ${errorMsg}`) this.sendEvent('output', { category: 'console', output: `Warning: Failed to prepare dependencies: ${errorMsg}\n` }) return null } const response = JSON.parse(lastLine) if (!response.success) { const errorMsg = response.error || 'unknown error' logger.error(`prepare-deps failed: ${errorMsg}`) this.sendEvent('output', { category: 'console', output: `Warning: Failed to prepare dependencies: ${errorMsg}\n` }) 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}\n` }) return response.node_modules_path } logger.info('No external dependencies to install') return null } catch (error) { if (this.disposed) { return null } logger.error(`Failed to prepare dependencies: ${error}`) this.sendEvent('output', { category: 'console', output: `Warning: Failed to prepare dependencies: ${error}\n` }) return null } finally { clearInterval(progress) clearTimeout(killTimer) this.prepareDepsProcess = null } } /** * Start the Bun subprocess with debugging enabled. */ private async startBunProcess(cwd: string): Promise { if (!this.scriptPath) { throw new Error('No script path') } const inspectPort = 9229 + Math.floor(Math.random() * 1000) const inspectUrl = `127.0.0.1:${inspectPort}` // Build the command - optionally wrapped with nsjail const cmd = nsjailWrap( [this.bunPath, `--inspect-wait=${inspectUrl}`, this.scriptPath], this.nsjailConfig, cwd ) if (this.nsjailConfig?.enabled) { logger.info(`Starting Bun with nsjail: ${cmd.join(' ')}`) } else { logger.info(`Starting Bun with --inspect-wait=${inspectUrl}`) } // Create a promise to wait for the WebSocket URL const wsUrlPromise = new Promise((resolve, reject) => { this.inspectorWsUrlPromise = { resolve, reject } // Timeout after 10 seconds setTimeout(() => { if (this.inspectorWsUrlPromise) { reject(new Error('Timeout waiting for inspector WebSocket URL')) this.inspectorWsUrlPromise = null } }, 10000) }) // Only include essential env vars + the network-config allowlist + client-provided ones. // Don't inherit all of process.env to keep debugger environment clean const envVars: Record = { // Essential system vars PATH: process.env.PATH || '/usr/bin:/bin', HOME: process.env.HOME, // Proxy / TLS settings inherited from the container, before the client's env so an // explicit override still wins. Package-index settings are deliberately absent: this // runs user-supplied code and index URLs carry registry credentials. ...sessionEnv(), // Client-provided env vars (WM_WORKSPACE, WM_TOKEN, etc.) // Note: WM_BASE_URL is already overridden by BASE_INTERNAL_URL if set ...this.envVars } // If we have installed node_modules, add NODE_PATH so Bun can find them 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 }) // Note: We don't read stdout directly because Console.messageAdded events // from the WebKit inspector handle console.log output. Reading stdout // would cause duplicate output. // Handle stderr (look for inspector URL and forward non-inspector output) this.readStderrForInspectorUrl(this.process.stderr) // Wait for the WebSocket URL to be parsed from stderr const wsUrl = await wsUrlPromise logger.info(`Got inspector WebSocket URL: ${wsUrl}`) // Connect to the inspector await this.connectToInspector(wsUrl) // Wait for process to exit this.process.exited.then(async (exitCode) => { logger.info(`Bun process exited with code: ${exitCode}`) this.running = false // Send terminated event BEFORE cleanup to ensure WebSocket is still open 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) }) } /** * Read from a readable stream and send output events. */ private async readStream( stream: ReadableStream | null, category: 'stdout' | 'stderr' ): Promise { 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) } } /** * Read stderr specifically to extract the inspector WebSocket URL. */ private async readStderrForInspectorUrl(stream: ReadableStream | null): Promise { 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 // Look for the WebSocket URL in Bun's inspector output // Format: "ws://127.0.0.1:9229/xxxxx" 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 } // Forward non-inspector output to the client // Skip the Bun Inspector banner 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) } } /** * Connect to Bun's inspector WebSocket. */ private async connectToInspector(wsUrl: string): Promise { 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 { // Enable inspector domain first (required for Bun's WebKit Inspector Protocol) await this.sendInspectorCommand('Inspector.enable', {}) // Enable console for output await this.sendInspectorCommand('Console.enable', {}) // Enable the debugger domain await this.sendInspectorCommand('Debugger.enable', {}) // Enable runtime domain for evaluation await this.sendInspectorCommand('Runtime.enable', {}) // CRITICAL: Activate breakpoints (required for Bun) await this.sendInspectorCommand('Debugger.setBreakpointsActive', { active: true }) // CRITICAL: Enable pause on debugger statements (required for Bun) await this.sendInspectorCommand('Debugger.setPauseOnDebuggerStatements', { enabled: true }) // Set pause on exceptions await this.sendInspectorCommand('Debugger.setPauseOnExceptions', { state: 'uncaught' }) // Set breakpoints BEFORE starting execution await this.applyBreakpointsByUrl() this.running = true // CRITICAL: Call Inspector.initialized to start script execution // Without this, Bun waits indefinitely with --inspect-wait 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 as string) } this.inspectorWs.onerror = (error) => { logger.error('Inspector WebSocket error:', error) } this.inspectorWs.onclose = () => { logger.info('Inspector WebSocket closed') this.inspectorWs = null // When inspector closes, the script has ended - send terminated event 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) } } } }) } /** * Handle the 'threads' request. */ async handleThreads(request: DAPMessage): Promise { this.sendResponse(request, true, { threads: [{ id: 1, name: 'MainThread' }] }) } /** * Handle the 'stackTrace' request. */ async handleStackTrace(request: DAPMessage): Promise { logger.info(`handleStackTrace: callFrames.length=${this.callFrames.length}`) const stackFrames: StackFrame[] = [] // Filter to only include frames from the user's script // The call stack should start at module code, not include internal Bun/loader frames 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}`) // Only include frames from the main script if (!script?.url || !this.scriptPath) { logger.info(` skipping frame ${i}: no script URL or scriptPath`) continue } // Check if this frame is from the user's script const isUserScript = script.url.endsWith(this.scriptPath.split('/').pop()!) if (!isUserScript) { logger.info(` skipping frame ${i}: not from user script (${script.url})`) continue } // Inspector line is 0-indexed (transpiled line) // We need to convert it back to the original user line using the source map 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 }) } /** * Handle the 'scopes' request. */ async handleScopes(request: DAPMessage): Promise { const args = request.arguments || {} const frameId = (args.frameId as number) || 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: Array<{ name: string; variablesReference: number; expensive: boolean }> = [] 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 }) // Format the name nicely let name: string 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 }) } /** * Handle the 'variables' request. */ async handleVariables(request: DAPMessage): Promise { const args = request.arguments || {} const variablesRef = (args.variablesReference as number) || 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 { // Determine scope type for filtering 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 }) // Log all property names for debugging const allProps = (response.result?.properties as Array<{ name: string }>) || [] logger.info(`handleVariables: all property names: ${allProps.map(p => p.name).join(', ')}`) logger.info(`handleVariables: got response with ${(response.result?.properties as unknown[])?.length || 0} properties for scope type: ${scopeType}`) const variables: Variable[] = [] const properties = (response.result?.properties as Array<{ name: string value?: { type: string value?: unknown description?: string objectId?: string subtype?: string className?: string preview?: { type: string subtype?: string description?: string overflow?: boolean properties?: Array<{ name: string; type: string; value?: string; subtype?: string }> } } configurable?: boolean enumerable?: boolean writable?: boolean }>) || [] for (const prop of properties) { if (!prop.value) continue // Skip internal properties (start with __) if (prop.name.startsWith('__') && prop.name !== '__windmill_result__') continue // Skip native functions (built-in methods) if (prop.value.type === 'function' && prop.value.description?.includes('[native code]')) continue // Skip common built-in object names 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', // Built-in objects '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 // Skip properties that look like class/constructor names (PascalCase and are functions) if (prop.value.type === 'function' && /^[A-Z][a-zA-Z0-9]*$/.test(prop.name)) continue let value: string let varRef = 0 let displayType = prop.value.type const className = prop.value.className // Log for debugging variable parsing issues 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) { // Check if this is a boxed primitive (String, Number, Boolean) if (className === 'String' && prop.value.description) { // Boxed string - description contains the string value 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 { // Regular object - create a reference for nested inspection varRef = this.nextVarRef() this.objectsMap.set(varRef, prop.value.objectId) // Use preview data to show object contents instead of just "Object" 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') { // Handle primitive strings - they have value property 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: [] }) } } /** * Handle the 'evaluate' request. */ async handleEvaluate(request: DAPMessage): Promise { const args = request.arguments || {} let expression = (args.expression as string) || '' const frameId = args.frameId as number | undefined const token = args.token as string | undefined // Verify expression token if provided (optional - for audit logging) // Expression tokens are signed by the backend to create audit logs if (token) { const verificationError = await verifyExpressionToken(token, expression) if (verificationError) { // Log the error but don't block evaluation // Expression signing is for audit logging, not security enforcement logger.warn(`Expression token verification failed: ${verificationError}`) } } // If expression starts with 'await ', strip it and we'll await the promise result // This preserves the scope context unlike wrapping in an async IIFE const shouldAwait = /^\s*await\s+/.test(expression) if (shouldAwait) { expression = expression.replace(/^\s*await\s+/, '') } try { let result: string let varRef = 0 // Helper to format evaluation result const formatResult = async (evalResult: { type: string subtype?: string className?: string value?: unknown description?: string objectId?: string preview?: { properties?: Array<{ name: string; value: string }> } } | undefined): Promise<{ result: string; varRef: number }> => { let result = 'undefined' let varRef = 0 if (!evalResult) return { result, varRef } // Check if result is a Promise - extract value from preview if already settled // (Runtime.awaitPromise doesn't work reliably in Bun's inspector) if (evalResult.subtype === 'promise' || evalResult.className === 'Promise') { if (evalResult.preview?.properties) { const props = evalResult.preview.properties as Array<{ name: string type?: string value?: string subtype?: string valuePreview?: { properties?: Array<{ name: string; value?: string }> } }> const statusProp = props.find(p => p.name === 'status') const resultProp = props.find(p => p.name === 'result') if (statusProp?.value === 'fulfilled' && resultProp) { // Promise is already fulfilled - return the result directly // For primitives, value is directly available if (resultProp.value !== undefined) { return { result: resultProp.value, varRef: 0 } } // For objects, format from valuePreview or show type 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 } } // Fallback: show the subtype or generic object return { result: resultProp.subtype || '[Object]', varRef: 0 } } // For other types (function, symbol, etc) return { result: resultProp.type || 'undefined', varRef: 0 } } else if (statusProp?.value === 'rejected' && resultProp) { // Promise was rejected - show the error return { result: `Rejected: ${resultProp.value ?? resultProp.type}`, varRef: 0 } } // For pending promises, show that it's pending if (statusProp?.value === 'pending') { return { result: 'Promise { }', varRef: 0 } } } } if (evalResult.type === 'undefined') { result = 'undefined' } else if (evalResult.value !== undefined) { result = JSON.stringify(evalResult.value) } else if (evalResult.objectId) { varRef = this.nextVarRef() this.objectsMap.set(varRef, evalResult.objectId) // Use preview if available for better display if (evalResult.preview?.properties) { const props = evalResult.preview.properties.map(p => `${p.name}: ${p.value}`).join(', ') result = `{${props}}` } else { result = evalResult.description || '[Object]' } } else { result = evalResult.description || String(evalResult.type) } return { result, varRef } } 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 as { type: string subtype?: string className?: string value?: unknown description?: string objectId?: string preview?: { properties?: Array<{ name: string; value: string }> } } 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 as { type: string subtype?: string className?: string value?: unknown description?: string objectId?: string preview?: { properties?: Array<{ name: string; value: string }> } } 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 }) } } /** * Handle the 'continue' request. */ async handleContinue(request: DAPMessage): Promise { try { this.isStepping = true this.pausedAtBreakpoint = false // Reset for next pause await this.sendInspectorCommand('Debugger.resume', {}) this.sendResponse(request, true, { allThreadsContinued: true }) } catch (error) { this.isStepping = false this.sendResponse(request, false, {}, String(error)) } } /** * Handle the 'next' (step over) request. */ async handleNext(request: DAPMessage): Promise { try { this.isStepping = true this.pausedAtBreakpoint = false // Reset for next pause await this.sendInspectorCommand('Debugger.stepOver', {}) this.sendResponse(request) } catch (error) { this.isStepping = false this.sendResponse(request, false, {}, String(error)) } } /** * Handle the 'stepIn' request. */ async handleStepIn(request: DAPMessage): Promise { try { this.isStepping = true this.pausedAtBreakpoint = false // Reset for next pause await this.sendInspectorCommand('Debugger.stepInto', {}) this.sendResponse(request) } catch (error) { this.isStepping = false this.sendResponse(request, false, {}, String(error)) } } /** * Handle the 'stepOut' request. */ async handleStepOut(request: DAPMessage): Promise { try { this.isStepping = true this.pausedAtBreakpoint = false // Reset for next pause await this.sendInspectorCommand('Debugger.stepOut', {}) this.sendResponse(request) } catch (error) { this.isStepping = false this.sendResponse(request, false, {}, String(error)) } } /** * Handle the 'pause' request. */ async handlePause(request: DAPMessage): Promise { try { await this.sendInspectorCommand('Debugger.pause', {}) this.sendResponse(request) } catch (error) { this.sendResponse(request, false, {}, String(error)) } } /** * Handle the 'disconnect' request. */ async handleDisconnect(request: DAPMessage): Promise { this.running = false await this.cleanup() this.sendResponse(request) } /** * Handle the 'terminate' request. */ async handleTerminate(request: DAPMessage): Promise { this.running = false // Set flag BEFORE cleanup to prevent inspector onclose from sending duplicate const shouldSendTerminated = !this.terminatedSent this.terminatedSent = true await this.cleanup() this.sendResponse(request) // Include the captured result if available if (shouldSendTerminated) { this.sendEvent('terminated', this.scriptResult !== undefined ? { result: this.scriptResult } : {}) } } /** * Clean up resources. Public because both servers call it when a client goes away. */ async cleanup(): Promise { this.disposed = true // Close inspector connection if (this.inspectorWs) { this.inspectorWs.close() this.inspectorWs = null } // A disconnect during dependency installation must not leave bun install running if (this.prepareDepsProcess) { killProcessTree(this.prepareDepsProcess) this.prepareDepsProcess = null } // Kill process if (this.process) { this.process.kill() this.process = null } // Clean up temp file if (this.tempFile) { try { await unlink(this.tempFile) } catch { // Ignore } this.tempFile = null } if (this.tempDir) { try { await rmdir(this.tempDir) } catch { // Ignore } this.tempDir = null } } /** * Handle an incoming DAP request. */ async handleRequest(request: DAPMessage): Promise { const command = request.command || '' logger.debug(`Handling command: ${command}`) const handlers: Record Promise> = { 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}`) } } } // Store active sessions by WebSocket const sessions = new Map() // Parse command line arguments const args = process.argv.slice(2) let host = 'localhost' let port = 5680 // Different port from Python server let windmillPath: string | undefined 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++ } } // Only start the standalone server when run directly (not when imported) if (import.meta.main) { if (windmillPath) { logger.info(`Windmill binary path: ${windmillPath}`) } // Start the server logger.info(`Starting DAP WebSocket server on ws://${host}:${port}`) const server = Bun.serve({ hostname: host, port, fetch(req, server) { if (isOriginRejected(req)) { logger.warn(`Rejected request from disallowed origin: ${req.headers.get('origin')}`) return new Response('Forbidden origin', { status: 403 }) } // Upgrade to WebSocket if (server.upgrade(req)) { return undefined as unknown as Response } return new Response('DAP WebSocket Server for Bun/TypeScript', { status: 200 }) }, websocket: { open(ws) { logger.info('New client connected') // Create a wrapper that implements the WebSocket interface expected by DebugSession const wsWrapper: WebSocket = { send: (data: string) => ws.send(data), close: () => ws.close(), readyState: WebSocket.OPEN, CONNECTING: WebSocket.CONNECTING, OPEN: WebSocket.OPEN, CLOSING: WebSocket.CLOSING, CLOSED: WebSocket.CLOSED } as unknown as WebSocket 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 as string) as DAPMessage logger.debug('Received:', JSON.stringify(data)) if (data.type === 'request') { await session.handleRequest(data) } } catch (error) { logger.error('Error handling message:', error) } }, async close(ws) { logger.info('Client disconnected') const session = sessions.get(ws) if (session) { // Dropping the session without this leaves its installer and debuggee running await session.cleanup() sessions.delete(ws) } } } }) logger.info(`Server started on ${server.url}`) }