diff --git a/CHANGELOG.md b/CHANGELOG.md index cea00a98a5..6f0ff5248c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,11 @@ All notable changes to this project are documented in this file by a CI job that runs on every NPM release. The file follows the [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. -## [v2.0.1] (2025-07-23) +## [v2.0.1] (2025-07-23) ### Blueprints -- Rewrite paths in the wp-cli step. Improve error reporting. ([#2406](https://github.com/WordPress/wordpress-playground/pull/2406)) +- Rewrite paths in the wp-cli step. Improve error reporting. ([#2406](https://github.com/WordPress/wordpress-playground/pull/2406)) ### Contributors @@ -16,41 +16,40 @@ The following contributors merged PRs in this release: @adamziel - -## [v2.0.0] (2025-07-23) +## [v2.0.0] (2025-07-23) ### Enhancements -- [Playground CLI] Improve error reporting. ([#2401](https://github.com/WordPress/wordpress-playground/pull/2401)) -- [Playground CLI] Kebab-case yargs options declarations. ([#2399](https://github.com/WordPress/wordpress-playground/pull/2399)) +- [Playground CLI] Improve error reporting. ([#2401](https://github.com/WordPress/wordpress-playground/pull/2401)) +- [Playground CLI] Kebab-case yargs options declarations. ([#2399](https://github.com/WordPress/wordpress-playground/pull/2399)) ### Blueprints -- Expose Blueprints v2 runner in Playground CLI. ([#2394](https://github.com/WordPress/wordpress-playground/pull/2394)) -- Extract Blueprint v1-specific parts of Playground CLI. ([#2392](https://github.com/WordPress/wordpress-playground/pull/2392)) -- [Playground CLI] Separate Blueprints v1 and Blueprints v2 code paths. ([#2396](https://github.com/WordPress/wordpress-playground/pull/2396)) +- Expose Blueprints v2 runner in Playground CLI. ([#2394](https://github.com/WordPress/wordpress-playground/pull/2394)) +- Extract Blueprint v1-specific parts of Playground CLI. ([#2392](https://github.com/WordPress/wordpress-playground/pull/2392)) +- [Playground CLI] Separate Blueprints v1 and Blueprints v2 code paths. ([#2396](https://github.com/WordPress/wordpress-playground/pull/2396)) ### Tools -- [XDebug] Add a mock @php-wasm/xdebug-bridge package. ([#2398](https://github.com/WordPress/wordpress-playground/pull/2398)) +- [XDebug] Add a mock @php-wasm/xdebug-bridge package. ([#2398](https://github.com/WordPress/wordpress-playground/pull/2398)) ### Documentation -- Adding Brazilian Portuguese translation for developer documentation. ([#2391](https://github.com/WordPress/wordpress-playground/pull/2391)) +- Adding Brazilian Portuguese translation for developer documentation. ([#2391](https://github.com/WordPress/wordpress-playground/pull/2391)) ### Website -- [Remote] Use CORS proxy in embedded Playgrounds. ([#2369](https://github.com/WordPress/wordpress-playground/pull/2369)) +- [Remote] Use CORS proxy in embedded Playgrounds. ([#2369](https://github.com/WordPress/wordpress-playground/pull/2369)) ### Bug Fixes -- CLI: Fix --login option and "landingPage" Blueprint property. ([#2344](https://github.com/WordPress/wordpress-playground/pull/2344)) +- CLI: Fix --login option and "landingPage" Blueprint property. ([#2344](https://github.com/WordPress/wordpress-playground/pull/2344)) ### Various -- Add Japanese translations to steps and steps shorthands. ([#2386](https://github.com/WordPress/wordpress-playground/pull/2386)) -- Add OPCache support. ([#2400](https://github.com/WordPress/wordpress-playground/pull/2400)) -- [Node] Gracefully handle connection errors in the outbound network proxy. ([#2370](https://github.com/WordPress/wordpress-playground/pull/2370)) +- Add Japanese translations to steps and steps shorthands. ([#2386](https://github.com/WordPress/wordpress-playground/pull/2386)) +- Add OPCache support. ([#2400](https://github.com/WordPress/wordpress-playground/pull/2400)) +- [Node] Gracefully handle connection errors in the outbound network proxy. ([#2370](https://github.com/WordPress/wordpress-playground/pull/2370)) ### Contributors @@ -58,7 +57,6 @@ The following contributors merged PRs in this release: @adamziel @fellyph @shimotmk @zaerl - ## [v1.2.3] (2025-07-21) ### Enhancements diff --git a/package-lock.json b/package-lock.json index ce0d0469df..92d8b406ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "dependencies": { "@preact/signals-react": "1.3.6", "@reduxjs/toolkit": "2.6.1", + "@types/xml2js": "0.4.14", "@wordpress/dataviews": "4.5.0", "@zip.js/zip.js": "2.7.57", "ajv": "8.12.0", @@ -36,6 +37,7 @@ "react-transition-group": "4.4.5", "sha.js": "2.4.11", "wasm-feature-detect": "1.8.0", + "xml2js": "0.6.2", "yargs": "17.7.2" }, "devDependencies": { @@ -16249,6 +16251,15 @@ "@types/node": "*" } }, + "node_modules/@types/xml2js": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.14.tgz", + "integrity": "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -46047,7 +46058,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", - "dev": true, "license": "ISC" }, "node_modules/saxes": { @@ -52486,6 +52496,28 @@ "node": ">=12" } }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", diff --git a/package.json b/package.json index 6e04c6a008..ce8ca79c07 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "dependencies": { "@preact/signals-react": "1.3.6", "@reduxjs/toolkit": "2.6.1", + "@types/xml2js": "0.4.14", "@wordpress/dataviews": "4.5.0", "@zip.js/zip.js": "2.7.57", "ajv": "8.12.0", @@ -87,6 +88,7 @@ "react-transition-group": "4.4.5", "sha.js": "2.4.11", "wasm-feature-detect": "1.8.0", + "xml2js": "0.6.2", "yargs": "17.7.2" }, "devDependencies": { diff --git a/packages/docs/site/docs/main/changelog.md b/packages/docs/site/docs/main/changelog.md index 91a385e303..270f9b7414 100644 --- a/packages/docs/site/docs/main/changelog.md +++ b/packages/docs/site/docs/main/changelog.md @@ -9,11 +9,11 @@ All notable changes to this project are documented in this file by a CI job that runs on every NPM release. The file follows the [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. -## [v2.0.1] (2025-07-23) +## [v2.0.1] (2025-07-23) ### Blueprints -- Rewrite paths in the wp-cli step. Improve error reporting. ([#2406](https://github.com/WordPress/wordpress-playground/pull/2406)) +- Rewrite paths in the wp-cli step. Improve error reporting. ([#2406](https://github.com/WordPress/wordpress-playground/pull/2406)) ### Contributors @@ -21,41 +21,40 @@ The following contributors merged PRs in this release: @adamziel - -## [v2.0.0] (2025-07-23) +## [v2.0.0] (2025-07-23) ### Enhancements -- [Playground CLI] Improve error reporting. ([#2401](https://github.com/WordPress/wordpress-playground/pull/2401)) -- [Playground CLI] Kebab-case yargs options declarations. ([#2399](https://github.com/WordPress/wordpress-playground/pull/2399)) +- [Playground CLI] Improve error reporting. ([#2401](https://github.com/WordPress/wordpress-playground/pull/2401)) +- [Playground CLI] Kebab-case yargs options declarations. ([#2399](https://github.com/WordPress/wordpress-playground/pull/2399)) ### Blueprints -- Expose Blueprints v2 runner in Playground CLI. ([#2394](https://github.com/WordPress/wordpress-playground/pull/2394)) -- Extract Blueprint v1-specific parts of Playground CLI. ([#2392](https://github.com/WordPress/wordpress-playground/pull/2392)) -- [Playground CLI] Separate Blueprints v1 and Blueprints v2 code paths. ([#2396](https://github.com/WordPress/wordpress-playground/pull/2396)) +- Expose Blueprints v2 runner in Playground CLI. ([#2394](https://github.com/WordPress/wordpress-playground/pull/2394)) +- Extract Blueprint v1-specific parts of Playground CLI. ([#2392](https://github.com/WordPress/wordpress-playground/pull/2392)) +- [Playground CLI] Separate Blueprints v1 and Blueprints v2 code paths. ([#2396](https://github.com/WordPress/wordpress-playground/pull/2396)) ### Tools -- [XDebug] Add a mock @php-wasm/xdebug-bridge package. ([#2398](https://github.com/WordPress/wordpress-playground/pull/2398)) +- [XDebug] Add a mock @php-wasm/xdebug-bridge package. ([#2398](https://github.com/WordPress/wordpress-playground/pull/2398)) ### Documentation -- Adding Brazilian Portuguese translation for developer documentation. ([#2391](https://github.com/WordPress/wordpress-playground/pull/2391)) +- Adding Brazilian Portuguese translation for developer documentation. ([#2391](https://github.com/WordPress/wordpress-playground/pull/2391)) ### Website -- [Remote] Use CORS proxy in embedded Playgrounds. ([#2369](https://github.com/WordPress/wordpress-playground/pull/2369)) +- [Remote] Use CORS proxy in embedded Playgrounds. ([#2369](https://github.com/WordPress/wordpress-playground/pull/2369)) ### Bug Fixes -- CLI: Fix --login option and "landingPage" Blueprint property. ([#2344](https://github.com/WordPress/wordpress-playground/pull/2344)) +- CLI: Fix --login option and "landingPage" Blueprint property. ([#2344](https://github.com/WordPress/wordpress-playground/pull/2344)) ### Various -- Add Japanese translations to steps and steps shorthands. ([#2386](https://github.com/WordPress/wordpress-playground/pull/2386)) -- Add OPCache support. ([#2400](https://github.com/WordPress/wordpress-playground/pull/2400)) -- [Node] Gracefully handle connection errors in the outbound network proxy. ([#2370](https://github.com/WordPress/wordpress-playground/pull/2370)) +- Add Japanese translations to steps and steps shorthands. ([#2386](https://github.com/WordPress/wordpress-playground/pull/2386)) +- Add OPCache support. ([#2400](https://github.com/WordPress/wordpress-playground/pull/2400)) +- [Node] Gracefully handle connection errors in the outbound network proxy. ([#2370](https://github.com/WordPress/wordpress-playground/pull/2370)) ### Contributors @@ -63,7 +62,6 @@ The following contributors merged PRs in this release: @adamziel @fellyph @shimotmk @zaerl - ## [v1.2.3] (2025-07-21) ### Enhancements diff --git a/packages/php-wasm/xdebug-bridge/.eslintrc.json b/packages/php-wasm/xdebug-bridge/.eslintrc.json index 79fd7c1d98..2cc8d25933 100644 --- a/packages/php-wasm/xdebug-bridge/.eslintrc.json +++ b/packages/php-wasm/xdebug-bridge/.eslintrc.json @@ -8,7 +8,9 @@ }, { "files": ["*.ts", "*.tsx"], - "rules": {} + "rules": { + "no-console": 0 + } }, { "files": ["*.js", "*.jsx"], diff --git a/packages/php-wasm/xdebug-bridge/README.md b/packages/php-wasm/xdebug-bridge/README.md index b6f6a4f0fc..fa8963dd08 100644 --- a/packages/php-wasm/xdebug-bridge/README.md +++ b/packages/php-wasm/xdebug-bridge/README.md @@ -13,24 +13,21 @@ npm install @php-wasm/xdebug-bridge ### Programmatic API ```typescript -import { startXDebugBridge } from '@php-wasm/xdebug-bridge'; +import { startBridge } from './xdebug-bridge/src/start-bridge'; // Start with default settings -const server = startXDebugBridge(); +const server = startBridge(); await server.start(); // Start with custom configuration -const server = startXDebugBridge({ - protocol: 'cdp', // or 'dap' - xdebugServerPort: 9003, // XDebug connection port - xdebugServerHost: 'localhost', - verbose: false, // Silent mode +const server = startBridge({ + cdpHost: 'localhost', // CDP connection host + cdpPort: 9229, // CDP connection port + dbgpPort: 9003, // XDebug connection port + phpRoot: './', // Root to directory }); await server.start(); - -// Stop the server -await server.stop(); ``` ### CLI Usage @@ -42,29 +39,31 @@ npx xdebug-bridge # Custom port and verbose logging npx xdebug-bridge --port 9000 --verbose -# DAP protocol, bind to all interfaces -npx xdebug-bridge --protocol dap --host 0.0.0.0 - # Show help npx xdebug-bridge --help ``` ## Configuration Options -- `protocol`: Protocol to use ('cdp' or 'dap', default: 'cdp') -- `xdebugServerPort`: Port to listen for XDebug connections (default: 9003) -- `xdebugServerHost`: Host to bind to (default: 'localhost') -- `verbose`: Enable verbose logging (default: false for API, true for CLI) -- `logger`: Custom logger function +- `cdpPort`: Port to listen for CDP connections (default: 9229) +- `cdpHost`: Host to bind to (default: 'localhost') +- `dbgpPort`: Port to listen for XDebug connections (default: 9003) +- `phpRoot`: Root path for php files; +- `remoteRoot`: Remote root path for php files; +- `localRoot`: Local root path for php files; +- `phpInstance`: PHP instance +- `getPHPFile`: Custom file listing function ## Events -The server emits events for monitoring connection activity: +The bridge listens to events for monitoring connection activity: + +- `connected`: Xdebug Server has started +- `close`: Xdebug Server has stopped +- `message`: Raw XDebug data received +- `error`: Xdebug Server error occurred -- `started`: Server has started -- `stopped`: Server has stopped -- `connection`: New XDebug connection established -- `disconnection`: XDebug connection closed -- `xdebugData`: Raw XDebug data received -- `error`: Server error occurred -- `socketError`: Socket-level error occurred +- `clientConnected`: Devtools client connected +- `clientDisconnected`: Devtools client disconnected +- `message`: Raw Devtools data received +- `error`: Devtools client error occurred diff --git a/packages/php-wasm/xdebug-bridge/package.json b/packages/php-wasm/xdebug-bridge/package.json index 7bceb9cd28..ec1c9c0f3e 100644 --- a/packages/php-wasm/xdebug-bridge/package.json +++ b/packages/php-wasm/xdebug-bridge/package.json @@ -26,7 +26,7 @@ "type": "module", "main": "./index.cjs", "module": "./index.js", - "bin": "cli.js", + "bin": "xdebug-bridge.js", "typedoc": { "entryPoint": "./src/index.ts", "readmeFile": "./README.md", diff --git a/packages/php-wasm/xdebug-bridge/project.json b/packages/php-wasm/xdebug-bridge/project.json index e243d5021f..6385c1375f 100644 --- a/packages/php-wasm/xdebug-bridge/project.json +++ b/packages/php-wasm/xdebug-bridge/project.json @@ -29,7 +29,7 @@ "executor": "@nx/vite:build", "outputs": ["{options.outputPath}"], "options": { - "main": "dist/packages/php-wasm/xdebug-bridge/index.js", + "main": "dist/packages/php-wasm/xdebug-bridge/src/cli.js", "outputPath": "dist/packages/php-wasm/xdebug-bridge" }, "defaultConfiguration": "production", @@ -45,10 +45,21 @@ "dev": { "executor": "nx:run-commands", "options": { - "command": "bun --watch ./packages/php-wasm/xdebug-bridge/src/cli.ts", + "command": "node --no-warnings --experimental-wasm-stack-switching --experimental-wasm-jspi --loader=./packages/meta/src/node-es-module-loader/loader.mts ./packages/php-wasm/xdebug-bridge/src/cli.ts", "tty": true } }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["packages/php-wasm/xdebug-bridge/**/*.ts"] + } + }, + "package-for-self-hosting": { + "executor": "@wp-playground/nx-extensions:package-for-self-hosting", + "dependsOn": ["build"] + }, "test": { "executor": "@nx/vite:test", "outputs": [ @@ -56,16 +67,18 @@ ], "options": { "reportsDirectory": "../../../coverage/packages/php-wasm/xdebug-bridge", - "testFiles": ["tests/mock-test.spec.ts"] + "testFiles": ["mock-test.spec.ts"] } }, - "lint": { - "executor": "@nx/eslint:lint", - "outputs": ["{options.outputFile}"], + "typecheck": { + "executor": "nx:run-commands", "options": { - "lintFilePatterns": ["packages/php-wasm/xdebug-bridge/**/*.ts"] + "commands": [ + "tsc -p packages/php-wasm/xdebug-bridge/tsconfig.lib.json --noEmit", + "tsc -p packages/php-wasm/xdebug-bridge/tsconfig.spec.json --noEmit" + ] } } }, - "tags": ["php-wasm"] + "tags": ["scope:php-binaries"] } diff --git a/packages/php-wasm/xdebug-bridge/public/xdebug-bridge.js b/packages/php-wasm/xdebug-bridge/public/xdebug-bridge.js new file mode 100644 index 0000000000..cf80c624bb --- /dev/null +++ b/packages/php-wasm/xdebug-bridge/public/xdebug-bridge.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import './cli.js'; diff --git a/packages/php-wasm/xdebug-bridge/src/cli.ts b/packages/php-wasm/xdebug-bridge/src/cli.ts index 05113844bc..042dd6f91f 100644 --- a/packages/php-wasm/xdebug-bridge/src/cli.ts +++ b/packages/php-wasm/xdebug-bridge/src/cli.ts @@ -1,209 +1,3 @@ -#!/usr/bin/env node +import { main } from './lib/run-cli'; -import { parseArgs } from 'util'; -import { startXDebugBridge, type XDebugBridgeConfig } from './xdebug-bridge'; - -interface CLIArgs { - protocol?: 'cdp' | 'dap'; - port?: number; - host?: string; - verbose?: boolean; - help?: boolean; -} - -function printHelp(): void { - // eslint-ignore-next-line - console.log(` -XDebug Bridge Server CLI - -Usage: xdebug-bridge [options] - -Options: - --protocol Protocol to use: cdp, dap (default: cdp) - --port Port to listen on (default: 9003) - --host Host to bind to (default: localhost) - --verbose Enable verbose logging - --help Show this help message - -Examples: - xdebug-bridge # Start with default settings - xdebug-bridge --port 9000 --verbose # Custom port with verbose logging - xdebug-bridge --protocol dap --host 0.0.0.0 # DAP protocol, bind to all interfaces -`); -} - -function parseCliArgs(): CLIArgs { - try { - const { values } = parseArgs({ - args: process.argv.slice(2), - options: { - protocol: { - type: 'string', - short: 'p', - }, - port: { - type: 'string', - short: 'P', - }, - host: { - type: 'string', - short: 'h', - }, - verbose: { - type: 'boolean', - short: 'v', - }, - help: { - type: 'boolean', - }, - }, - allowPositionals: false, - }); - - const args: CLIArgs = {}; - - if (values.protocol) { - if (values.protocol !== 'cdp' && values.protocol !== 'dap') { - throw new Error( - `Invalid protocol: ${values.protocol}. Must be 'cdp' or 'dap'.` - ); - } - args.protocol = values.protocol as 'cdp' | 'dap'; - } - - if (values.port) { - const port = parseInt(values.port, 10); - if (isNaN(port) || port < 1 || port > 65535) { - throw new Error( - `Invalid port: ${values.port}. Must be a number between 1 and 65535.` - ); - } - args.port = port; - } - - if (values.host) { - args.host = values.host; - } - - if (values.verbose) { - args.verbose = true; - } - - if (values.help) { - args.help = true; - } - - return args; - } catch (error) { - console.error( - `Error parsing arguments: ${ - error instanceof Error ? error.message : String(error) - }` - ); - process.exit(1); - } -} - -async function main(): Promise { - const args = parseCliArgs(); - - if (args.help) { - printHelp(); - return; - } - - const config: XDebugBridgeConfig = { - protocol: args.protocol, - xdebugServerPort: args.port, - xdebugServerHost: args.host, - verbose: args.verbose ?? true, // CLI defaults to verbose - }; - - // eslint-ignore-next-line - console.log('Starting XDebug Bridge Server...'); - - const server = startXDebugBridge(config); - - // Handle graceful shutdown - const shutdown = async (signal: string) => { - // eslint-ignore-next-line - console.log(`\nReceived ${signal}, shutting down gracefully...`); - try { - await server.stop(); - // eslint-ignore-next-line - console.log('XDebug Bridge Server stopped.'); - process.exit(0); - } catch (error) { - console.error( - `Error during shutdown: ${ - error instanceof Error ? error.message : String(error) - }` - ); - process.exit(1); - } - }; - - process.on('SIGINT', () => shutdown('SIGINT')); - process.on('SIGTERM', () => shutdown('SIGTERM')); - - // Start the server - try { - await server.start(); - - const port = server.getPort(); - const host = server.getHost(); - - // eslint-ignore-next-line - console.log(`✅ XDebug Bridge Server is running on ${host}:${port}`); - // eslint-ignore-next-line - console.log(`📡 Protocol: ${config.protocol || 'cdp'}`); - // eslint-ignore-next-line - console.log('🔍 Waiting for XDebug connections...'); - // eslint-ignore-next-line - console.log('Press Ctrl+C to stop the server'); - - // Set up event listeners for connection activity - server.on('connection', (socket) => { - // eslint-ignore-next-line - console.log( - `🔗 New XDebug connection established from ${socket.remoteAddress}:${socket.remotePort}` - ); - }); - - server.on('disconnection', (socket) => { - // eslint-ignore-next-line - console.log( - `❌ XDebug connection closed from ${socket.remoteAddress}:${socket.remotePort}` - ); - }); - - server.on('error', (error) => { - console.error(`❌ Server error: ${error.message}`); - }); - - server.on('socketError', ({ socket, error }) => { - console.error( - `❌ Socket error from ${socket.remoteAddress}:${socket.remotePort}: ${error.message}` - ); - }); - } catch (error) { - console.error( - `❌ Failed to start XDebug Bridge Server: ${ - error instanceof Error ? error.message : String(error) - }` - ); - process.exit(1); - } -} - -// Only run if this file is executed directly -if (import.meta.url === `file://${process.argv[1]}`) { - main().catch((error) => { - console.error( - `❌ Unexpected error: ${ - error instanceof Error ? error.message : String(error) - }` - ); - process.exit(1); - }); -} +main(); diff --git a/packages/php-wasm/xdebug-bridge/src/index.ts b/packages/php-wasm/xdebug-bridge/src/index.ts index 84eddb25e0..f41a696fd2 100644 --- a/packages/php-wasm/xdebug-bridge/src/index.ts +++ b/packages/php-wasm/xdebug-bridge/src/index.ts @@ -1 +1 @@ -export * from './xdebug-bridge'; +export * from './lib'; diff --git a/packages/php-wasm/xdebug-bridge/src/lib/cdp-server.ts b/packages/php-wasm/xdebug-bridge/src/lib/cdp-server.ts new file mode 100644 index 0000000000..2cf0130146 --- /dev/null +++ b/packages/php-wasm/xdebug-bridge/src/lib/cdp-server.ts @@ -0,0 +1,50 @@ +import { EventEmitter } from 'events'; +import { type WebSocket, WebSocketServer } from 'ws'; + +export class CDPServer extends EventEmitter { + private wss: WebSocketServer; + private ws: WebSocket | null = null; + + constructor(port = 9229) { + super(); + this.wss = new WebSocketServer({ port: port }); + this.wss.on('connection', (ws: WebSocket) => { + // Only one client at a time + if (this.ws) { + ws.close(); + return; + } + this.ws = ws; + this.emit('clientConnected'); + ws.on('message', (data) => { + console.log( + '\x1b[1;32m[CDP][received]\x1b[0m', + data.toString() + ); + let message: any; + try { + message = JSON.parse(data.toString()); + } catch { + return; + } + this.emit('message', message); + }); + ws.on('close', () => { + this.ws = null; + this.emit('clientDisconnected'); + }); + ws.on('error', (err) => { + this.emit('error', err); + }); + }); + } + + sendMessage(message: any) { + if (!this.ws || this.ws.readyState !== this.ws.OPEN) { + return; + } + const json = JSON.stringify(message); + console.log('\x1b[1;32m[CDP][send]\x1b[0m', json); + this.ws.send(json); + } +} diff --git a/packages/php-wasm/xdebug-bridge/src/lib/dbgp-session.ts b/packages/php-wasm/xdebug-bridge/src/lib/dbgp-session.ts new file mode 100644 index 0000000000..fb2a4e2a89 --- /dev/null +++ b/packages/php-wasm/xdebug-bridge/src/lib/dbgp-session.ts @@ -0,0 +1,83 @@ +import { EventEmitter } from 'events'; +import net from 'net'; + +export class DbgpSession extends EventEmitter { + private server: net.Server; + private socket: net.Socket | null = null; + private buffer = ''; + private expectedLength: number | null = null; + + constructor(port = 9003) { + super(); + this.server = net.createServer(); + this.server.on('connection', (socket) => { + // Only allow one connection (single-session) + if (this.socket) { + socket.destroy(); + return; + } + this.socket = socket; + socket.setEncoding('utf8'); + this.emit('connected'); + socket.on('data', (data: Buffer) => this.onData(data.toString())); + socket.on('close', () => { + this.socket = null; + this.emit('close'); + }); + socket.on('error', (err) => { + // Forward error events if needed + this.emit('error', err); + }); + }); + this.server.listen(port); + } + + private onData(data: string) { + console.log('\x1b[1;32m[XDebug][received]]\x1b[0m', data); + this.buffer += data; + while (true) { + if (this.expectedLength === null) { + // Look for the separator for length + const nullIndex = this.buffer.indexOf('\x00'); + if (nullIndex === -1) { + // Wait for more data + break; + } + const lengthStr = this.buffer.substring(0, nullIndex); + const length = parseInt(lengthStr, 10); + if (isNaN(length)) { + // Invalid length, reset buffer to be safe + this.buffer = ''; + break; + } + this.expectedLength = length; + // Remove the length part and null terminator from buffer + this.buffer = this.buffer.slice(nullIndex + 1); + } + if (this.expectedLength !== null) { + if (this.buffer.length >= this.expectedLength) { + const xml = this.buffer.substring(0, this.expectedLength); + this.buffer = this.buffer.slice(this.expectedLength); + // Remove trailing null of the message if present + if (this.buffer.startsWith('\x00')) { + this.buffer = this.buffer.slice(1); + } + // Reset expectedLength for next message + const msg = xml.trim(); + this.expectedLength = null; + // Emit the raw XML message + this.emit('message', msg); + // Continue loop in case multiple messages are in buffer + continue; + } + } + break; + } + } + + sendCommand(command: string) { + if (!this.socket) return; + // Commands must end with null terminator + this.socket.write(command + '\x00'); + } +} diff --git a/packages/php-wasm/xdebug-bridge/src/lib/index.ts b/packages/php-wasm/xdebug-bridge/src/lib/index.ts new file mode 100644 index 0000000000..40af065388 --- /dev/null +++ b/packages/php-wasm/xdebug-bridge/src/lib/index.ts @@ -0,0 +1,5 @@ +export * from './cdp-server'; +export * from './dbgp-session'; +export * from './run-cli'; +export * from './start-bridge'; +export * from './xdebug-cdp-bridge'; diff --git a/packages/php-wasm/xdebug-bridge/src/lib/run-cli.ts b/packages/php-wasm/xdebug-bridge/src/lib/run-cli.ts new file mode 100644 index 0000000000..8433050de4 --- /dev/null +++ b/packages/php-wasm/xdebug-bridge/src/lib/run-cli.ts @@ -0,0 +1,69 @@ +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; +import { startBridge } from './start-bridge'; + +interface CLIArgs { + protocol?: 'cdp' | 'dap'; + port?: number; + host?: string; + verbose?: boolean; + help?: boolean; + phpRoot?: string; +} + +function parseCliArgs(): CLIArgs { + return yargs(hideBin(process.argv)) + .usage( + ` +XDebug Bridge Server CLI + +Usage: xdebug-bridge [options] + ` + ) + .option('port', { + alias: 'p', + type: 'number', + description: 'Xdebug port to listen on', + default: 9003, + }) + .option('host', { + alias: 'h', + type: 'string', + description: 'Xdebug host to bind to', + default: 'localhost', + }) + .option('php-root', { + type: 'string', + description: 'Path to PHP root directory', + default: './', + }) + .help() + .epilog( + ` +Examples: + xdebug-bridge # Start with default settings + xdebug-bridge --port 9000 --verbose # Custom port with verbose logging + xdebug-bridge --php-root /path/to/php/files # Specify PHP root directory + ` + ) + .parseSync() as CLIArgs; +} + +export async function main(): Promise { + const args = parseCliArgs(); + + if (args.help) { + return; + } + + console.log('Starting XDebug Bridge...'); + + const bridge = await startBridge({ + cdpPort: 9229, + cdpHost: args.host, + dbgpPort: args.port, + phpRoot: args.phpRoot, + }); + + bridge.start(); +} diff --git a/packages/php-wasm/xdebug-bridge/src/lib/start-bridge.ts b/packages/php-wasm/xdebug-bridge/src/lib/start-bridge.ts new file mode 100644 index 0000000000..802365c4ef --- /dev/null +++ b/packages/php-wasm/xdebug-bridge/src/lib/start-bridge.ts @@ -0,0 +1,80 @@ +import type { PHP } from '@php-wasm/universal'; +import { readdirSync, readFileSync, lstatSync } from 'fs'; +import { join } from 'path'; +import { CDPServer } from './cdp-server'; +import { DbgpSession } from './dbgp-session'; +import { XdebugCDPBridge } from './xdebug-cdp-bridge'; + +export type StartBridgeConfig = { + cdpPort?: number; + cdpHost?: string; + dbgpPort?: number; + phpRoot?: string; + remoteRoot?: string; + localRoot?: string; + + phpInstance?: PHP; + getPHPFile?: (path: string) => string; +}; + +export async function startBridge(config: StartBridgeConfig) { + const cdpPort = config.cdpPort ?? 9229; + const dbgpPort = config.dbgpPort ?? 9003; + const cdpHost = config.cdpHost ?? 'localhost'; + const phpRoot = config.phpRoot ?? import.meta.dirname; + + // index.ts - Entry point to start the service + const cdpServer = new CDPServer(cdpPort); + console.log('Connect Chrome DevTools to CDP at:'); + + console.log( + `devtools://devtools/bundled/inspector.html?ws=${cdpHost}:${cdpPort}` + ); + await new Promise((resolve) => cdpServer.on('clientConnected', resolve)); + await new Promise((resolve) => setTimeout(resolve, 2000)); + + console.log('Chrome connected! Initializing Xdebug receiver...'); + + const dbgpSession = new DbgpSession(dbgpPort); + + console.log(`XDebug receiver running on port ${dbgpPort}`); + console.log('Running a PHP script with Xdebug enabled...'); + + // Recursively get a list of .php files in phpRoot + function getPhpFiles(dir: string): string[] { + const results: string[] = []; + const list = readdirSync(dir); + for (const file of list) { + const filePath = join(dir, file); + // lstat avoids crashes when encountering symlinks + const stat = lstatSync(filePath); + if (stat && stat.isDirectory()) { + results.push(...getPhpFiles(filePath)); + } else if (file.endsWith('.php')) { + results.push(`file://${filePath}`); + } + } + return results; + } + + const getPHPFile = config.phpInstance + ? (path: string) => config.phpInstance!.readFileAsText(path) + : config.getPHPFile + ? config.getPHPFile + : (path: string) => { + // Default implementation: read from filesystem + // Convert file:/// URLs to local paths + const localPath = path.startsWith('file://') + ? path.replace('file://', '') + : path; + return readFileSync(localPath, 'utf-8'); + }; + + const phpFiles = getPhpFiles(phpRoot); + return new XdebugCDPBridge(dbgpSession, cdpServer, { + knownScriptUrls: phpFiles, + remoteRoot: config.remoteRoot, + localRoot: config.localRoot, + getPHPFile, + }); +} diff --git a/packages/php-wasm/xdebug-bridge/src/lib/xdebug-cdp-bridge.ts b/packages/php-wasm/xdebug-bridge/src/lib/xdebug-cdp-bridge.ts new file mode 100644 index 0000000000..c07921c2c0 --- /dev/null +++ b/packages/php-wasm/xdebug-bridge/src/lib/xdebug-cdp-bridge.ts @@ -0,0 +1,976 @@ +import path from 'path'; +import { parseStringPromise } from 'xml2js'; +import type { DbgpSession } from './dbgp-session'; +import type { CDPServer } from './cdp-server'; + +interface PendingCommand { + cdpId?: number; + cdpMethod?: string; + // Additional fields to help with response if needed + params?: any; +} + +interface BreakpointInfo { + cdpId: string; + xdebugId: string | null; + file: string; + line: number; +} + +interface ObjectHandle { + type: 'context' | 'property'; + contextId?: number; + depth: number; + fullname?: string; +} + +export interface XdebugCDPBridgeConfig { + knownScriptUrls: string[]; + remoteRoot?: string; + localRoot?: string; + getPHPFile(path: string): string; +} + +export class XdebugCDPBridge { + private dbgp: DbgpSession; + public cdp: CDPServer; + private nextTxnId = 1; + private pendingCommands: Map = new Map(); + private breakpoints: Map = new Map(); // key: cdp breakpointId + private scriptIdByUrl: Map = new Map(); + private nextScriptId = 1; + private objectHandles: Map = new Map(); + private nextObjectId = 1; + private callFramesMap: Map = new Map(); // callFrameId -> stack depth + private xdebugConnected = false; + private xdebugStatus = 'starting'; + private initFileUri: string | null = null; + private readPHPFile: (path: string) => string; + private remoteRoot: string; + private localRoot: string; + + constructor( + dbgp: DbgpSession, + cdp: CDPServer, + config: XdebugCDPBridgeConfig + ) { + this.dbgp = dbgp; + this.cdp = cdp; + this.readPHPFile = config.getPHPFile; + this.remoteRoot = config.remoteRoot || ''; + this.localRoot = config.localRoot || ''; + for (const url of config.knownScriptUrls) { + this.scriptIdByUrl.set(url, this.getOrCreateScriptId(url)); + } + } + + start() { + // Xdebug connected + this.dbgp.on('connected', () => { + this.xdebugConnected = true; + this.sendDbgpCommand('stdout', '-c 1'); // copies PHP stdout to IDE + this.sendDbgpCommand('stderr', '-c 1'); // copies PHP stderr to IDE + }); + // Xdebug messages + this.dbgp.on('message', async (xml: string) => { + try { + const msgObj = await parseStringPromise(xml, { + explicitArray: false, + }); + await this.handleDbgpMessage(msgObj); + } catch { + // Parsing error, ignore or log + } + }); + // Xdebug closed + this.dbgp.on('close', () => { + this.xdebugConnected = false; + // If DevTools is still connected, inform or close + this.cdp.sendMessage({ + method: 'Debugger.paused', + params: { reason: 'terminated', callFrames: [] }, + }); + // Close the DevTools connection + // Note: Alternatively, could keep it open and allow reconnect + // But here we assume one session and close the WS. + // We schedule close after sending terminated event. + setTimeout(() => { + // @ts-ignore: access private ws for immediate close + if (this.cdp['ws']) this.cdp['ws'].close(); + }, 100); + }); + + // DevTools client connected + this.cdp.on('clientConnected', () => { + // If Xdebug already connected and paused (starting or break), send script(s) and pause status + if (this.xdebugConnected) { + this.sendInitialScripts(); + + if ( + this.xdebugStatus === 'starting' || + this.xdebugStatus === 'break' + ) { + // Retrieve stack and send paused event + const txn = this.sendDbgpCommand(`stack_get`); + this.pendingCommands.set(txn, { + /* internal stack get (no cdpId) */ + }); + // We'll handle sending paused event when stack_get response arrives + } else { + // If script is running, we might send an initial resumed state or nothing. + // DevTools by default considers it running if no paused event. + } + } + }); + // DevTools messages (requests) + this.cdp.on('message', (msg: any) => { + this.handleCdpMessage(msg); + }); + // DevTools disconnected + this.cdp.on('clientDisconnected', () => { + // If Xdebug still connected, detach from it + if (this.xdebugConnected) { + this.sendDbgpCommand(`detach`); + // After detach, Xdebug will likely close connection + } + }); + } + + private sendInitialScripts() { + // Send scriptParsed for the main file if not already sent + if (this.initFileUri && !this.scriptIdByUrl.has(this.initFileUri)) { + const scriptId = this.getOrCreateScriptId(this.initFileUri); + this.cdp.sendMessage({ + method: 'Debugger.scriptParsed', + params: { + scriptId: scriptId, + url: this.initFileUri, + startLine: 0, + startColumn: 0, + // Assuming unknown end, skip endLine/endColumn + executionContextId: 1, + }, + }); + } + + // Send every script we already know about + for (const [url, scriptId] of this.scriptIdByUrl.entries()) { + this.cdp.sendMessage({ + method: 'Debugger.scriptParsed', + params: { + scriptId, + url, + startLine: 0, + startColumn: 0, + executionContextId: 1, + }, + }); + } + } + + private getOrCreateScriptId(fileUri: string): string { + let scriptId = this.scriptIdByUrl.get(fileUri); + if (!scriptId) { + scriptId = String(this.nextScriptId++); + this.scriptIdByUrl.set(fileUri, scriptId); + } + return scriptId; + } + + // Utility: escape and quote Xdebug fullname for property_get + private formatPropertyFullName(fullname: string): string { + // Escape quotes, backslashes, and nulls + let needsQuotes = false; + let result = ''; + for (const ch of fullname) { + if (ch === '"' || ch === '\\' || ch === '\x00') { + result += '\\' + ch; + needsQuotes = true; + } else if (ch === ' ') { + result += ch; + needsQuotes = true; + } else { + result += ch; + } + } + if (needsQuotes || fullname.includes("'")) { + // If contains single quote or spaces or special chars, wrap in double quotes + result = `"${result}"`; + } + return result; + } + + private sendDbgpCommand(command: string, data?: string): string { + console.log('\x1b[1;32m[XDebug][send]\x1b[0m', command, data); + const txnId = this.nextTxnId++; + const txnIdStr = txnId.toString(); + let cmdStr = `${command} -i ${txnIdStr}`; + if (data !== undefined) { + cmdStr += ` ${data}`; + } + this.dbgp.sendCommand(cmdStr); + return txnIdStr; + } + + private handleCdpMessage(message: any) { + const { id, method, params } = message; + let result: any = {}; + let sendResponse = true; + switch (method) { + case 'Debugger.enable': + case 'Runtime.enable': + // Acknowledge enabling of domains + result = {}; + break; + case 'Debugger.setBreakpointByUrl': { + const { url, lineNumber } = params; + const fileUri = url; + const line = + (typeof lineNumber === 'number' ? lineNumber : 0) + 1; // CDP lineNumber is 0-based, Xdebug expects 1-based + // Generate a new breakpoint ID for DevTools + const cdpBreakpointId = String(this.breakpoints.size + 1); + // If Xdebug connected, send breakpoint_set now + if (this.xdebugConnected) { + const cmd = `breakpoint_set -t line -f ${this.formatPropertyFullName( + fileUri + )} -n ${line}`; + const txn = this.sendDbgpCommand(cmd); + this.pendingCommands.set(txn, { + cdpId: id, + cdpMethod: method, + params: { + breakpointId: cdpBreakpointId, + fileUri, + line, + }, + }); + // We'll send response when we get confirmation from Xdebug + sendResponse = false; + } else { + // Xdebug not yet connected: store breakpoint to set later + this.breakpoints.set(cdpBreakpointId, { + cdpId: cdpBreakpointId, + xdebugId: null, + file: fileUri, + line: line, + }); + result = { + breakpointId: cdpBreakpointId, + locations: [ + { + scriptId: this.getOrCreateScriptId(fileUri), + lineNumber: line - 1, + columnNumber: 0, + }, + ], + }; + } + break; + } + case 'Debugger.removeBreakpoint': { + const { breakpointId } = params; + const bpIdStr = String(breakpointId); + const bp = this.breakpoints.get(bpIdStr); + if (bp) { + if (bp.xdebugId && this.xdebugConnected) { + // Remove from Xdebug if it was set + const cmd = `breakpoint_remove -d ${bp.xdebugId}`; + const txn = this.sendDbgpCommand(cmd); + this.pendingCommands.set(txn, { + cdpId: id, + cdpMethod: method, + }); + sendResponse = false; + } + // Remove from our map + this.breakpoints.delete(bpIdStr); + } + result = {}; + break; + } + case 'Debugger.resume': { + if (this.xdebugConnected) { + // Continue execution + this.xdebugStatus = 'running'; + this.sendDbgpCommand('run'); + } + result = {}; + break; + } + case 'Debugger.stepOver': { + if (this.xdebugConnected) { + this.xdebugStatus = 'running'; + this.sendDbgpCommand('step_over'); + } + result = {}; + break; + } + case 'Debugger.stepInto': { + if (this.xdebugConnected) { + this.xdebugStatus = 'running'; + this.sendDbgpCommand('step_into'); + } + result = {}; + break; + } + case 'Debugger.stepOut': { + if (this.xdebugConnected) { + this.xdebugStatus = 'running'; + this.sendDbgpCommand('step_out'); + } + result = {}; + break; + } + case 'Debugger.pause': { + if (this.xdebugConnected) { + // Attempt to break running script + this.sendDbgpCommand('break'); + } + result = {}; + break; + } + case 'Runtime.evaluate': + case 'Debugger.evaluateOnCallFrame': { + const expression: string = params.expression || ''; + const callFrameId: string | undefined = params.callFrameId; + // If evaluateOnCallFrame, check if supported frame + if (method === 'Debugger.evaluateOnCallFrame') { + if ( + callFrameId === undefined || + !this.callFramesMap.has(callFrameId) + ) { + // Invalid frame + this.cdp.sendMessage({ + id, + error: { + code: -32000, + message: 'No such call frame', + }, + }); + return; + } + const frameDepth = this.callFramesMap.get(callFrameId)!; + if (frameDepth !== 0) { + // Only support evaluation in top frame for simplicity + this.cdp.sendMessage({ + id, + error: { + code: -32000, + message: + 'Evaluation in this frame not supported', + }, + }); + return; + } + } + if (this.xdebugConnected) { + // Xdebug eval expects code in base64 + const code = Buffer.from(expression).toString('base64'); + const txn = this.sendDbgpCommand('eval', `-- ${code}`); + this.pendingCommands.set(txn, { + cdpId: id, + cdpMethod: method, + }); + sendResponse = false; + } else { + // If no Xdebug, return undefined result + result = { + result: { type: 'undefined', value: undefined }, + }; + } + break; + } + case 'Runtime.getProperties': { + const { objectId } = params; + const handle = this.objectHandles.get(objectId); + if (handle && this.xdebugConnected) { + if (handle.type === 'context') { + const contextId = handle.contextId ?? 0; + const depth = handle.depth; + // Get variables in the context + const cmd = `context_get -d ${depth} -c ${contextId}`; + const txn = this.sendDbgpCommand(cmd); + this.pendingCommands.set(txn, { + cdpId: id, + cdpMethod: method, + }); + sendResponse = false; + } else if (handle.type === 'property') { + const depth = handle.depth; + const fullname = handle.fullname!; + const fmtName = this.formatPropertyFullName(fullname); + const cmd = `property_get -d ${depth} -n ${fmtName}`; + const txn = this.sendDbgpCommand(cmd); + this.pendingCommands.set(txn, { + cdpId: id, + cdpMethod: method, + params: { parentObjectId: objectId }, + }); + sendResponse = false; + } else { + // Unknown handle type + result = { result: [] }; + } + } else { + result = { result: [] }; + } + break; + } + case 'Debugger.getScriptSource': { + const sid = params.scriptId; + const uri = [...this.scriptIdByUrl.entries()].find( + ([, v]) => v === sid + )?.[0]; + let scriptSource = ''; + if (uri) { + scriptSource = this.readPHPFile(this.uriToRemotePath(uri)); + } + result = { scriptSource }; + break; + } + default: + // Unknown or unimplemented method + result = {}; + break; + } + if (sendResponse) { + this.cdp.sendMessage({ id, result }); + } + } + + /* ---------- path mapping ---------- */ + + private uriToRemotePath(uri: string) { + return uri.startsWith('file://') ? uri.slice(7) : uri; + } + + private remoteToLocal(remote: string) { + let p = remote; + if (this.remoteRoot && p.startsWith(this.remoteRoot)) + p = path.join( + this.localRoot || '', + p.slice(this.remoteRoot.length) + ); + if (process.platform === 'win32' && p.startsWith('/')) p = p.slice(1); + return p; + } + + private async handleDbgpMessage(msgObj: any) { + if (msgObj.init) { + // Xdebug initial handshake + const initAttr = msgObj.init.$; + this.initFileUri = initAttr.fileuri || initAttr.fileuri; + this.xdebugStatus = 'starting'; + + const firstBreakTxn = this.sendDbgpCommand('step_into'); + this.pendingCommands.set(firstBreakTxn, { + /* auto step_into after init */ + }); + + // Optionally send scriptParsed for the main file if DevTools already connected + if (this.cdp['ws']) { + this.sendInitialScripts(); + } + return; + } + if (msgObj.response) { + const response = msgObj.response; + const attrs = response.$; + const command = attrs.command; + const transId = attrs.transaction_id; + const pending = this.pendingCommands.get(transId); + // If this is a response to a command we sent + switch (command) { + case 'breakpoint_set': { + if ( + pending && + pending.cdpId !== undefined && + pending.cdpMethod === 'Debugger.setBreakpointByUrl' + ) { + // Map Xdebug breakpoint id to our cdp breakpoint id + const xdebugBpId = attrs.id; + const bpInfo = pending.params; + if (bpInfo) { + const { + breakpointId: cdpBpId, + fileUri, + line, + } = bpInfo; + // Store mapping + this.breakpoints.set(cdpBpId, { + cdpId: cdpBpId, + xdebugId: xdebugBpId, + file: fileUri, + line: line, + }); + // Prepare CDP response + const scriptId = this.getOrCreateScriptId(fileUri); + const result = { + breakpointId: cdpBpId, + locations: [ + { + scriptId: scriptId, + lineNumber: line - 1, + columnNumber: 0, + }, + ], + }; + this.cdp.sendMessage({ id: pending.cdpId, result }); + } + this.pendingCommands.delete(transId); + } + break; + } + case 'breakpoint_remove': { + if (pending && pending.cdpId !== undefined) { + // No specific result content needed + this.cdp.sendMessage({ id: pending.cdpId, result: {} }); + this.pendingCommands.delete(transId); + } + break; + } + case 'run': + case 'step_into': + case 'step_over': + case 'step_out': { + // These come when execution stops or ends + const status = attrs.status; // 'break' or 'stopping' + // const reason = attrs.reason; // 'ok', 'breakpoint', 'exception', etc. // Note: not currently needed + this.xdebugStatus = status; + + // NEW: send scriptParsed for any newly discovered file + if (response['xdebug:message']) { + const fileUri = response['xdebug:message'].$.filename; + if (fileUri && !this.scriptIdByUrl.has(fileUri)) { + const scriptId = this.getOrCreateScriptId(fileUri); + this.cdp.sendMessage({ + method: 'Debugger.scriptParsed', + params: { + scriptId, + url: fileUri, + startLine: 0, + startColumn: 0, + executionContextId: 1, + }, + }); + } + } + if (status === 'break') { + // Paused at breakpoint or step or exception + // Get more info: which breakpoint or where + // Use stack_get to retrieve call stack + const txn = this.sendDbgpCommand(`stack_get`); + this.pendingCommands.set(txn, { + /* internal stack get */ + }); + // If reason indicates exception, we might handle after stack + this.pendingCommands.delete(transId); + } else if (status === 'stopping' || status === 'stopped') { + // Script execution finished or engine detached + // We can treat as resumed and terminated + this.cdp.sendMessage({ + method: 'Debugger.resumed', + params: {}, + }); + // Xdebug might close connection after this, which triggers our close handler + } + break; + } + case 'eval': { + if (pending && pending.cdpId !== undefined) { + // Handle evaluation result + let resultValue: any; + if (response.property) { + // The eval response may have a with result + const property = response.property; + const type = property.$.type; + const encoding = property.$.encoding; + let valueStr: string | null = null; + if ( + Object.prototype.hasOwnProperty.call( + property, + '_' + ) + ) { + valueStr = property._; + } else if (typeof property.$value !== 'undefined') { + // Some responses might carry value in attribute or differently, but usually in _ or in value tag + valueStr = property.$value; + } + if (encoding === 'base64' && valueStr !== null) { + try { + const buf = Buffer.from(valueStr, 'base64'); + valueStr = buf.toString(); + } catch { + /* ignore decoding errors */ + } + } + if (type === 'string') { + resultValue = { + type: 'string', + value: valueStr ?? '', + }; + } else if ( + type === 'int' || + type === 'float' || + type === 'bool' || + type === 'boolen' || + type === 'integer' || + type === 'double' + ) { + // Map basic types + let parsed: any = valueStr; + if ( + type.startsWith('int') || + type === 'integer' + ) { + parsed = parseInt(valueStr || '0', 10); + } else if ( + type === 'float' || + type === 'double' + ) { + parsed = parseFloat(valueStr || '0'); + } else if (type.startsWith('bool')) { + parsed = + valueStr === '1' || valueStr === 'true'; + } + resultValue = { type: 'number', value: parsed }; + } else if (type === 'array' || type === 'object') { + // Complex object: create a handle for it + const className = + property.$.classname || + (type === 'array' ? 'Array' : 'Object'); + const objectId = String(this.nextObjectId++); + const fullname = property.$.fullname || ''; + // Store handle for later property retrieval + this.objectHandles.set(objectId, { + type: 'property', + depth: 0, + contextId: 0, + fullname: fullname, + }); + resultValue = { + type: 'object', + objectId: objectId, + className: className, + description: className, + }; + } else if (type === 'null') { + resultValue = { + type: 'object', + subtype: 'null', + value: null, + }; + } else { + // Other types (resource, etc) + resultValue = { + type: 'undefined', + value: undefined, + }; + } + } else { + // No property in response (maybe an error or empty) + resultValue = { + type: 'undefined', + value: undefined, + }; + } + const result = { result: resultValue }; + this.cdp.sendMessage({ id: pending.cdpId, result }); + this.pendingCommands.delete(transId); + } + break; + } + case 'context_get': + case 'property_get': { + if (pending && pending.cdpId !== undefined) { + // Handle variables or object properties retrieval + const props: any = []; + const responseProps = response.property; + if (responseProps) { + const propertiesArray = Array.isArray(responseProps) + ? responseProps + : [responseProps]; + for (const prop of propertiesArray) { + const name = + prop.$.name || prop.$.fullname || ''; + let type = prop.$.type || 'undefined'; + const hasChildren = prop.$.children === '1'; + const encoding = prop.$.encoding; + let valueStr: string | null = null; + if (typeof prop._ !== 'undefined') { + valueStr = prop._; + } + if ( + encoding === 'base64' && + valueStr !== null + ) { + try { + const buf = Buffer.from( + valueStr, + 'base64' + ); + valueStr = buf.toString(); + } catch { + /* ignore base64 decode errors */ + } + } + if (hasChildren) { + // Object or array + const className = + prop.$.classname || + (type === 'array' ? 'Array' : 'Object'); + const objectId = String( + this.nextObjectId++ + ); + // Store handle + const contextId = + pending.cdpMethod === + 'Runtime.getProperties' && + pending.params?.parentObjectId + ? this.objectHandles.get( + pending.params + .parentObjectId + )?.contextId || 0 + : 0; + const depth = + pending.cdpMethod === + 'Runtime.getProperties' && + pending.params?.parentObjectId + ? this.objectHandles.get( + pending.params + .parentObjectId + )?.depth || 0 + : 0; + // Use same depth/context as parent + this.objectHandles.set(objectId, { + type: 'property', + depth: depth, + contextId: contextId, + fullname: prop.$.fullname || name, + }); + props.push({ + name: prop.$.key || name, + value: { + type: 'object', + className: className, + description: className, + objectId: objectId, + }, + writable: false, + configurable: false, + enumerable: true, + }); + } else { + // Primitive or null + let value: any; + let subtype: string | undefined; + if (type === 'string') { + value = valueStr ?? ''; + } else if ( + type === 'int' || + type === 'integer' + ) { + value = parseInt(valueStr || '0', 10); + } else if ( + type === 'float' || + type === 'double' + ) { + value = parseFloat(valueStr || '0'); + } else if ( + type === 'bool' || + type === 'boolean' + ) { + value = + valueStr === '1' || + valueStr === 'true'; + type = 'boolean'; + } else if (type === 'null') { + value = null; + subtype = 'null'; + } else { + // other types like resource + value = valueStr; + } + const valueObj: any = { + type: + type === 'integer' + ? 'number' + : type, + }; + if (subtype) valueObj.subtype = subtype; + valueObj.value = value; + props.push({ + name: prop.$.key || name, + value: valueObj, + writable: false, + configurable: false, + enumerable: true, + }); + } + } + } + const result = { result: props }; + this.cdp.sendMessage({ id: pending.cdpId, result }); + this.pendingCommands.delete(transId); + } + break; + } + case 'stack_get': { + // Build callFrames for paused state + if (response.stack) { + const stackEntries = Array.isArray(response.stack) + ? response.stack + : [response.stack]; + const callFrames: any[] = []; + this.callFramesMap.clear(); + // Send scriptParsed for any new files in stack + for (const frame of stackEntries) { + const file = frame.$.filename; + const scriptId = this.getOrCreateScriptId(file); + if (!this.scriptIdByUrl.has(file)) { + // Mark it known and send scriptParsed + this.scriptIdByUrl.set(file, scriptId); + this.cdp.sendMessage({ + method: 'Debugger.scriptParsed', + params: { + scriptId: scriptId, + url: file, + startLine: 0, + startColumn: 0, + executionContextId: 1, + }, + }); + } + } + // Build callFrames array + for (const frame of stackEntries) { + const level = parseInt(frame.$.level, 10); + const file = frame.$.filename; + const line = parseInt(frame.$.lineno, 10); + const functionName = + frame.$.where && frame.$.where !== '{main}' + ? frame.$.where + : '(anonymous)'; + const scriptId = this.getOrCreateScriptId(file); + const callFrameId = `frame:${level}`; + // Map callFrameId to depth for evaluate + this.callFramesMap.set(callFrameId, level); + // Prepare scope chain (local and global) + const scopes: any[] = []; + // Local scope + const localObjectId = String(this.nextObjectId++); + this.objectHandles.set(localObjectId, { + type: 'context', + contextId: 0, + depth: level, + }); + scopes.push({ + type: 'local', + object: { + objectId: localObjectId, + className: 'Object', + description: 'Local', + }, + }); + // Global scope (superglobals in PHP) + const globalObjectId = String(this.nextObjectId++); + this.objectHandles.set(globalObjectId, { + type: 'context', + contextId: 1, + depth: level, + }); + scopes.push({ + type: 'global', + object: { + objectId: globalObjectId, + className: 'Object', + description: 'Global', + }, + }); + // Build callFrame entry + callFrames.push({ + callFrameId: callFrameId, + functionName: functionName, + location: { + scriptId: scriptId, + lineNumber: line - 1, + columnNumber: 0, + }, + scopeChain: scopes, + this: { + type: 'object', + className: 'Object', + description: 'Object', + objectId: globalObjectId, + }, + }); + } + // Send paused event to DevTools + let pauseReason = 'pause'; + // Determine reason from Xdebug if available + // (Xdebug 'reason' might be in the original run/step response we handled prior) + // We'll simplify: if any breakpoint matches top frame location, reason = breakpoint + if (stackEntries.length > 0) { + const topFrame = stackEntries[0]; + if (topFrame.$.filename && topFrame.$.lineno) { + const file = topFrame.$.filename; + const line = parseInt(topFrame.$.lineno, 10); + for (const bp of this.breakpoints.values()) { + if (bp.file === file && bp.line === line) { + pauseReason = 'breakpoint'; + break; + } + } + } + } + this.cdp.sendMessage({ + method: 'Debugger.paused', + params: { + reason: pauseReason, + callFrames: callFrames, + hitBreakpoints: + pauseReason === 'breakpoint' ? [''] : [], + }, + }); + } + // Remove pending stack_get + this.pendingCommands.delete(transId); + break; + } + default: { + // Other commands we didn't specifically handle + if (pending && pending.cdpId !== undefined) { + this.cdp.sendMessage({ id: pending.cdpId, result: {} }); + this.pendingCommands.delete(transId); + } + break; + } + } + } else if (msgObj.stream) { + const stream = msgObj.stream; + const kind = stream.$.type; // 'stdout' or 'stderr' + const enc = stream.$.encoding || 'none'; + let data = typeof stream._ === 'string' ? stream._ : ''; + if (enc === 'base64') data = Buffer.from(data, 'base64').toString(); + + this.cdp.sendMessage({ + method: 'Log.entryAdded', + params: { + entry: { + source: 'other', + level: kind === 'stderr' ? 'error' : 'info', + text: data, + timestamp: Date.now(), + // url: 'file:///' + this.initFileUri, + // lineNumber: 1, + // columnNumber: 1, + stackTrace: { callFrames: [] }, + }, + }, + }); + } else if (msgObj.notify) { + // Notifications (e.g., breakpoint_resolved, etc.) - not specifically handled here. + } + } +} diff --git a/packages/php-wasm/xdebug-bridge/src/xdebug-bridge.ts b/packages/php-wasm/xdebug-bridge/src/xdebug-bridge.ts deleted file mode 100644 index da3cf88e9b..0000000000 --- a/packages/php-wasm/xdebug-bridge/src/xdebug-bridge.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { createServer, type Server, type Socket } from 'net'; -import { EventEmitter } from 'events'; - -export interface XDebugBridgeConfig { - /** - * The protocol to use for the bridge communication. - * @default "cdp" - */ - protocol?: 'cdp' | 'dap'; - - /** - * The port where XDebug server will listen for connections. - * @default 9003 - */ - xdebugServerPort?: number; - - /** - * The host where XDebug server will bind to. - * @default "localhost" - */ - xdebugServerHost?: string; - - /** - * Whether to enable verbose logging. - * @default false - */ - verbose?: boolean; - - /** - * Custom logger function. If not provided and verbose is true, console.log will be used. - */ - logger?: (message: string) => void; -} - -export interface XDebugBridgeServer extends EventEmitter { - /** - * Start the XDebug bridge server. - */ - start(): Promise; - - /** - * Stop the XDebug bridge server. - */ - stop(): Promise; - - /** - * Get the actual port the server is listening on. - */ - getPort(): number | null; - - /** - * Get the host the server is listening on. - */ - getHost(): string; - - /** - * Check if the server is currently running. - */ - isRunning(): boolean; -} - -class XDebugBridgeServerImpl - extends EventEmitter - implements XDebugBridgeServer -{ - private server: Server | null = null; - private config: Required; - private connectedClients = new Set(); - - constructor(config: XDebugBridgeConfig = {}) { - super(); - - this.config = { - protocol: config.protocol ?? 'cdp', - xdebugServerPort: config.xdebugServerPort ?? 9003, - xdebugServerHost: config.xdebugServerHost ?? 'localhost', - verbose: config.verbose ?? false, - logger: - config.logger ?? - ((message: string) => { - if (this.config.verbose) { - // @ts-ignore - console.log(`[XDebug Bridge] ${message}`); - } - }), - }; - } - - private log(message: string): void { - this.config.logger(message); - } - - async start(): Promise { - if (this.server) { - throw new Error('XDebug bridge server is already running'); - } - - return new Promise((resolve, reject) => { - this.server = createServer(); - - this.server.on('connection', (socket: Socket) => { - this.handleConnection(socket); - }); - - this.server.on('error', (error: Error) => { - this.log(`Server error: ${error.message}`); - this.emit('error', error); - reject(error); - }); - - this.server.listen( - this.config.xdebugServerPort, - this.config.xdebugServerHost, - () => { - const address = this.server?.address(); - const port = - typeof address === 'object' && address - ? address.port - : this.config.xdebugServerPort; - - this.log( - `XDebug bridge server started on ${this.config.xdebugServerHost}:${port}` - ); - this.log(`Protocol: ${this.config.protocol}`); - this.emit('started', { - host: this.config.xdebugServerHost, - port, - }); - resolve(); - } - ); - }); - } - - async stop(): Promise { - if (!this.server) { - return; - } - - return new Promise((resolve) => { - // Close all client connections - for (const client of this.connectedClients) { - client.destroy(); - } - this.connectedClients.clear(); - - this.server!.close(() => { - this.log('XDebug bridge server stopped'); - this.emit('stopped'); - this.server = null; - resolve(); - }); - }); - } - - getPort(): number | null { - if (!this.server) return null; - const address = this.server.address(); - return typeof address === 'object' && address ? address.port : null; - } - - getHost(): string { - return this.config.xdebugServerHost; - } - - isRunning(): boolean { - return this.server !== null && this.server.listening; - } - - private handleConnection(socket: Socket): void { - const clientAddress = `${socket.remoteAddress}:${socket.remotePort}`; - this.log(`New XDebug connection from ${clientAddress}`); - - this.connectedClients.add(socket); - this.emit('connection', socket); - - socket.on('data', () => { - // TODO: Handle XDebug data - }); - - socket.on('close', () => { - this.log(`XDebug connection closed from ${clientAddress}`); - this.connectedClients.delete(socket); - this.emit('disconnection', socket); - }); - - socket.on('error', (error: Error) => { - this.log(`Socket error from ${clientAddress}: ${error.message}`); - this.connectedClients.delete(socket); - this.emit('socketError', { socket, error }); - }); - } -} - -/** - * Starts an XDebug bridge server that can relay debugging sessions. - * - * @param config Configuration options for the XDebug bridge - * @returns A promise that resolves to an XDebugBridgeServer instance - */ -export function startXDebugBridge( - config: XDebugBridgeConfig = {} -): XDebugBridgeServer { - const bridge = new XDebugBridgeServerImpl(config); - return bridge; -} diff --git a/packages/php-wasm/xdebug-bridge/tsconfig.spec.json b/packages/php-wasm/xdebug-bridge/tsconfig.spec.json index 231650b3da..eb23daacbc 100644 --- a/packages/php-wasm/xdebug-bridge/tsconfig.spec.json +++ b/packages/php-wasm/xdebug-bridge/tsconfig.spec.json @@ -1,14 +1,19 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "outDir": "../../../dist/out-tsc", - "module": "commonjs", - "types": ["jest", "node"] + "outDir": "../../dist/out-tsc", + "types": ["vitest/globals", "vitest/importMeta", "vite/client", "node"] }, "include": [ - "jest.config.ts", + "vite.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", "src/**/*.d.ts" ] } diff --git a/packages/php-wasm/xdebug-bridge/vite.config.ts b/packages/php-wasm/xdebug-bridge/vite.config.ts index 2d24871ec3..921542c587 100644 --- a/packages/php-wasm/xdebug-bridge/vite.config.ts +++ b/packages/php-wasm/xdebug-bridge/vite.config.ts @@ -1,41 +1,58 @@ /// +import { join } from 'path'; import { defineConfig } from 'vite'; -import { resolve } from 'path'; +import dts from 'vite-plugin-dts'; + +// eslint-disable-next-line @nx/enforce-module-boundaries +import { viteTsConfigPaths } from '../../vite-extensions/vite-ts-config-paths'; export default defineConfig({ - plugins: [], + cacheDir: '../../../node_modules/.vite/php-wasm-xdebug-bridge', + + plugins: [ + dts({ + entryRoot: 'src', + tsconfigPath: join(__dirname, 'tsconfig.lib.json'), + pathsToAliases: false, + }), + + viteTsConfigPaths({ + root: '../../../', + }), + ], + build: { lib: { entry: { - index: resolve(__dirname, 'src/index.ts'), - cli: resolve(__dirname, 'src/cli.ts'), + index: 'src/index.ts', + cli: 'src/cli.ts', }, + name: 'php-wasm-xdebug-bridge', formats: ['es', 'cjs'], - fileName: (format, entryName) => { - if (format === 'es') { - return `${entryName}.js`; - } - return `${entryName}.cjs`; - }, }, rollupOptions: { - external: ['net', 'events', 'util'], + external: [ + 'assert', + 'fs', + 'net', + 'path', + 'stream', + 'timers', + 'url', + 'util', + 'ws', + ], output: { exports: 'named', }, }, - sourcemap: true, + sourcemap: false, target: 'node20', }, + test: { - globals: true, - cache: { - dir: '../../../node_modules/.vitest', - }, environment: 'node', + globals: true, reporters: ['default'], }, - define: { - 'import.meta.vitest': undefined, - }, });