Files
windmill/debugger/dap_websocket_server_bun.ts
2026-01-14 14:45:23 +00:00

2585 lines
80 KiB
TypeScript

#!/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 { mkdtemp, writeFile, unlink, rmdir, symlink } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
// Types for V8 Inspector Protocol
interface V8Message {
id?: number
method?: string
params?: Record<string, unknown>
result?: Record<string, unknown>
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<string, unknown>
arguments?: Record<string, unknown>
}
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<string, number>
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, unknown>): 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'
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<CryptoKey | null> | null = null
/**
* Fetch and cache the Ed25519 public key from the JWKS endpoint.
*/
async function getPublicKey(): Promise<CryptoKey | null> {
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<string> {
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<string | null> {
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<string> {
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<string | null> {
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[]
}
/**
* 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<number, number>
// transpiled 0-indexed line -> original 0-indexed line (for reporting positions)
transpiledToOriginal: Map<number, number>
}
/**
* 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<string, number[]>() // file -> line numbers
private breakpointIds = new Map<string, string[]>() // file -> V8 breakpoint IDs
// Script ID mapping (V8 uses script IDs, we need to map to file paths)
private scripts = new Map<string, V8Script>()
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<number, { type: string; objectId: string; frameIndex: number }>()
private objectsMap = new Map<number, string>() // variablesRef -> objectId
// For calling main()
private callMain = false
private mainArgs: Record<string, unknown> = {}
// Buffer console output during stepping to ensure correct order
private isStepping = false
private pendingConsoleOutput: Array<Record<string, unknown>> = []
// 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<string, string> = {}
// 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
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<string, unknown> = {},
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<string, unknown> = {}): 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<string, unknown> = {}
): Promise<V8Message> {
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<void> {
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<string, unknown>): Promise<void> {
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<string, unknown>): 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<string, unknown>): 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<string, unknown> = {
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<string, unknown>): 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<void> {
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<void> {
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<void> {
this.configured = true
this.sendResponse(request)
}
/**
* Handle the 'launch' request.
*/
async handleLaunch(request: DAPMessage): Promise<void> {
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<string, unknown>) || {}
this.envVars = (args.env as Record<string, string>) || {}
// Verify JWT token if code is provided (signed debug request)
// The token is passed in the launch arguments
if (code && REQUIRE_SIGNED_REQUESTS) {
const token = args.token as string | undefined
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) {
this.nodeModulesPath = await this.prepareDependencies(code) || undefined
// 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.
*/
private async prepareDependencies(code: string, language: string = 'bun'): Promise<string | null> {
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 }) + '\n'
logger.info(`prepare-deps input length: ${input.length}`)
// Spawn the windmill binary with prepare-deps command
const proc = spawn({
cmd: [this.windmillPath, 'prepare-deps'],
stdin: new Blob([input]), // Use Blob for complete stdin data
stdout: 'pipe',
stderr: 'pipe'
})
// Wait for completion
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) {
// 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) {
logger.error(`Failed to prepare dependencies: ${error}`)
this.sendEvent('output', {
category: 'console',
output: `Warning: Failed to prepare dependencies: ${error}\n`
})
return null
}
}
/**
* Start the Bun subprocess with debugging enabled.
*/
private async startBunProcess(cwd: string): Promise<void> {
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
let cmd: string[] = [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}`)
}
// Create a promise to wait for the WebSocket URL
const wsUrlPromise = new Promise<string>((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 + client-provided ones
// Don't inherit all of process.env to keep debugger environment clean
const envVars: Record<string, string | undefined> = {
// Essential system vars
PATH: process.env.PATH || '/usr/bin:/bin',
HOME: process.env.HOME,
// 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<Uint8Array> | null,
category: 'stdout' | 'stderr'
): Promise<void> {
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<Uint8Array> | null): Promise<void> {
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<void> {
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<void> {
this.sendResponse(request, true, {
threads: [{ id: 1, name: 'MainThread' }]
})
}
/**
* Handle the 'stackTrace' request.
*/
async handleStackTrace(request: DAPMessage): Promise<void> {
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 || '<module>',
source: {
path: script.url || this.scriptPath || '<unknown>',
name: script.url?.split('/').pop() || 'script.ts'
},
line: frameLine,
column: frame.location.columnNumber + 1
})
}
logger.info(`handleStackTrace: returning ${stackFrames.length} frames`)
this.sendResponse(request, true, {
stackFrames,
totalFrames: stackFrames.length
})
}
/**
* Handle the 'scopes' request.
*/
async handleScopes(request: DAPMessage): Promise<void> {
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<void> {
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<void> {
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 { <pending> }', 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
this.running = false
await this.cleanup()
this.sendResponse(request)
}
/**
* Handle the 'terminate' request.
*/
async handleTerminate(request: DAPMessage): Promise<void> {
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.
*/
private async cleanup(): Promise<void> {
// Close inspector connection
if (this.inspectorWs) {
this.inspectorWs.close()
this.inspectorWs = 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<void> {
const command = request.command || ''
logger.debug(`Handling command: ${command}`)
const handlers: Record<string, (req: DAPMessage) => Promise<void>> = {
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<unknown, DebugSession>()
// 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) {
// 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)
}
},
close(ws) {
logger.info('Client disconnected')
sessions.delete(ws)
}
}
})
logger.info(`Server started on ${server.url}`)
}