2585 lines
80 KiB
TypeScript
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}`)
|
|
}
|