Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
07a90a2
XDebug <-> CDP bridge
adamziel Jul 22, 2025
57496d4
Correct lint errors and warnings
mho22 Jul 23, 2025
877d80e
Correct merge conflicts with unneeded file index.ts
mho22 Jul 23, 2025
a48da90
Implement build based on other packages
mho22 Jul 23, 2025
66a81e0
Corrected errors crashing built package
mho22 Jul 23, 2025
587fee5
Correct error related to tests
mho22 Jul 23, 2025
90d601e
Focus on repository first xdebug bridge usage
mho22 Jul 23, 2025
8ff09a8
Switch statSync to lstatSync
mho22 Jul 23, 2025
ee31029
Improve help section
mho22 Jul 23, 2025
14f2216
Correct merge conflicts with trunk
mho22 Jul 23, 2025
eaa48e4
Correct merge conflicts with trunk
mho22 Jul 23, 2025
3b7cb6c
Implement package building files and commands
mho22 Jul 23, 2025
84caeae
Reorganize files for next step
mho22 Jul 23, 2025
f7a006b
Add --cdp option in @php-wasm/cli
mho22 Jul 23, 2025
32dbfa5
Merge branch 'trunk' into add-cdp-option-in-cli
mho22 Jul 23, 2025
b947aea
rename --cdp to --devtools
mho22 Jul 23, 2025
5a43a3a
[XDebug Bridge] Fetch all array keys when inspecting an array
adamziel Jul 23, 2025
1646447
Correct merge conflict and add if statement to avoid nested arrays
mho22 Aug 4, 2025
d00e766
Added Xdebug Bridge tests and paginate tests
mho22 Aug 13, 2025
481b086
Correct merge conflicts
mho22 Aug 15, 2025
1a65dea
Merge branch 'trunk' into xdebug-paginate-props
adamziel Aug 25, 2025
e0d3c26
Merge branch 'trunk' into xdebug-paginate-props
adamziel Aug 25, 2025
a9ad95a
Merge packages/docs packages/php-wasm/node from trunk
adamziel Aug 25, 2025
6fc5e59
Merge packages/docs packages/php-wasm/node from trunk
adamziel Aug 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions packages/php-wasm/xdebug-bridge/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,7 @@
"{workspaceRoot}/coverage/packages/php-wasm/xdebug-bridge"
],
"options": {
"reportsDirectory": "../../../coverage/packages/php-wasm/xdebug-bridge",
"testFiles": ["mock-test.spec.ts"]
"reportsDirectory": "../../../coverage/packages/php-wasm/xdebug-bridge"
}
},
"typecheck": {
Expand Down
4 changes: 4 additions & 0 deletions packages/php-wasm/xdebug-bridge/src/lib/cdp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,8 @@ export class CDPServer extends EventEmitter {
console.log('\x1b[1;32m[CDP][send]\x1b[0m', json);
this.ws.send(json);
}

close() {
this.wss.close();
}
}
4 changes: 4 additions & 0 deletions packages/php-wasm/xdebug-bridge/src/lib/dbgp-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,8 @@ export class DbgpSession extends EventEmitter {
// Commands must end with null terminator
this.socket.write(command + '\x00');
}

close() {
this.server.close();
}
}
129 changes: 115 additions & 14 deletions packages/php-wasm/xdebug-bridge/src/lib/xdebug-cdp-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ interface ObjectHandle {
contextId?: number;
depth: number;
fullname?: string;
// Add pagination support
currentPage?: number;
totalPages?: number;
aggregatedProps?: any[];
}

export interface XdebugCDPBridgeConfig {
Expand Down Expand Up @@ -136,6 +140,11 @@ export class XdebugCDPBridge {
});
}

stop() {
this.dbgp.close();
this.cdp.close();
}

private sendInitialScripts() {
// Send scriptParsed for the main file if not already sent
if (this.initFileUri && !this.scriptIdByUrl.has(this.initFileUri)) {
Expand Down Expand Up @@ -387,20 +396,36 @@ export class XdebugCDPBridge {
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}`;
// Get variables in the context with pagination support (32 items per page)
const cmd = `context_get -d ${depth} -c ${contextId} -p 0 -m 32`;
const txn = this.sendDbgpCommand(cmd);
// Initialize pagination state
const updatedHandle = {
...handle,
currentPage: 0,
aggregatedProps: [],
};
this.objectHandles.set(objectId, updatedHandle);
this.pendingCommands.set(txn, {
cdpId: id,
cdpMethod: method,
params: { objectId: objectId },
});
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}`;
// Get property with pagination support (32 items per page)
const cmd = `property_get -d ${depth} -n ${fmtName} -p 0 -m 32`;
const txn = this.sendDbgpCommand(cmd);
// Initialize pagination state
const updatedHandle = {
...handle,
currentPage: 0,
aggregatedProps: [],
};
this.objectHandles.set(objectId, updatedHandle);
this.pendingCommands.set(txn, {
cdpId: id,
cdpMethod: method,
Expand Down Expand Up @@ -686,13 +711,26 @@ export class XdebugCDPBridge {
case 'context_get':
case 'property_get': {
if (pending && pending.cdpId !== undefined) {
// Handle variables or object properties retrieval
const props: any = [];
const responseProps = response.property;
// Handle variables or object properties retrieval with pagination
const objectId =
pending.params?.objectId ||
pending.params?.parentObjectId;
const handle = objectId
? this.objectHandles.get(objectId)
: null;

// @TODO: This is hacky. It enables browsing arrays. Without it,
// the debugger shows $_SERVER as an array with a single property called
// $_SERVER.
const responseProps =
response.property?.property ?? response.property;

const currentProps: any[] = [];
if (responseProps) {
const propertiesArray = Array.isArray(responseProps)
? responseProps
: [responseProps];

for (const prop of propertiesArray) {
const name =
prop.$.name || prop.$.fullname || '';
Expand Down Expand Up @@ -722,7 +760,7 @@ export class XdebugCDPBridge {
const className =
prop.$.classname ||
(type === 'array' ? 'Array' : 'Object');
const objectId = String(
const childObjectId = String(
this.nextObjectId++
);
// Store handle
Expand All @@ -745,19 +783,20 @@ export class XdebugCDPBridge {
)?.depth || 0
: 0;
// Use same depth/context as parent
this.objectHandles.set(objectId, {
this.objectHandles.set(childObjectId, {
type: 'property',
depth: depth,
contextId: contextId,
fullname: prop.$.fullname || name,
});
props.push({

currentProps.push({
name: prop.$.key || name,
value: {
type: 'object',
className: className,
description: className,
objectId: objectId,
objectId: childObjectId,
},
writable: false,
configurable: false,
Expand Down Expand Up @@ -802,7 +841,7 @@ export class XdebugCDPBridge {
};
if (subtype) valueObj.subtype = subtype;
valueObj.value = value;
props.push({
currentProps.push({
name: prop.$.key || name,
value: valueObj,
writable: false,
Expand All @@ -812,9 +851,71 @@ export class XdebugCDPBridge {
}
}
}
const result = { result: props };
this.cdp.sendMessage({ id: pending.cdpId, result });
this.pendingCommands.delete(transId);

// Handle pagination
if (handle) {
// Add current page props to aggregated results
const aggregatedProps = (
handle.aggregatedProps || []
).concat(currentProps);

// Check if there are more pages - if we got exactly 32 items (page size), there might be more
const pageSize = 32;
const hasMorePages =
currentProps.length === pageSize;

if (hasMorePages) {
// More pages available, fetch next page
const nextPage = (handle.currentPage || 0) + 1;
const updatedHandle = {
...handle,
currentPage: nextPage,
aggregatedProps: aggregatedProps,
};
this.objectHandles.set(
objectId!,
updatedHandle
);

// Send command for next page
let nextCmd: string;
if (command === 'context_get') {
const contextId = handle.contextId ?? 0;
const depth = handle.depth;
nextCmd = `context_get -d ${depth} -c ${contextId} -p ${nextPage} -m ${pageSize}`;
} else {
// property_get
const depth = handle.depth;
const fullname = handle.fullname!;
const fmtName =
this.formatPropertyFullName(fullname);
nextCmd = `property_get -d ${depth} -n ${fmtName} -p ${nextPage} -m ${pageSize}`;
}

const txn = this.sendDbgpCommand(nextCmd);
this.pendingCommands.set(txn, {
cdpId: pending.cdpId,
cdpMethod: pending.cdpMethod,
params: pending.params,
});
// Don't send response yet, wait for more pages
this.pendingCommands.delete(transId);
return;
} else {
// No more pages or last page, send final response
const result = { result: aggregatedProps };
this.cdp.sendMessage({
id: pending.cdpId,
result,
});
this.pendingCommands.delete(transId);
}
} else {
// No handle, send current props
const result = { result: currentProps };
this.cdp.sendMessage({ id: pending.cdpId, result });
this.pendingCommands.delete(transId);
}
}
break;
}
Expand Down
106 changes: 106 additions & 0 deletions packages/php-wasm/xdebug-bridge/src/tests/cdp-server.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import './mocker';
import { vi } from 'vitest';
import { WebSocket } from 'ws';
import { CDPServer } from '../lib/cdp-server';

describe('CDPServer', () => {
let server: any;
let socket: any;

beforeEach(() => {
// @ts-ignore
socket = new WebSocket();
server = new CDPServer(9999);
});

afterEach(() => {
server.removeAllListeners();
vi.clearAllMocks();
});

it('emits clientConnected on new connection', () => {
const onClientConnected = vi.fn();

server.on('clientConnected', onClientConnected);
server.wss.emit('connection', socket);

expect(onClientConnected).toHaveBeenCalled();
});

it('only allows one client at a time', () => {
// @ts-ignore
const client = new WebSocket();
const spy = vi.spyOn(client, 'close');

server.wss.emit('connection', socket);
server.wss.emit('connection', client);

expect(spy).toHaveBeenCalledTimes(1);
});

it('emits message when a valid JSON message is received', () => {
const msg = { id: 1, method: 'Debugger.enable' };
const onMessage = vi.fn();

server.on('message', onMessage);
server.wss.emit('connection', socket);
socket.emit('message', JSON.stringify(msg));

expect(onMessage).toHaveBeenCalledWith(msg);
});

it('ignores invalid JSON messages', () => {
const onMessage = vi.fn();

server.on('message', onMessage);
server.wss.emit('connection', socket);
socket.emit('message', '{ invalid json }');

expect(onMessage).not.toHaveBeenCalled();
});

it('emits clientDisconnected when client closes', () => {
const onDisconnect = vi.fn();

server.on('clientDisconnected', onDisconnect);
server.wss.emit('connection', socket);
socket.emit('close');

expect(onDisconnect).toHaveBeenCalled();
});

it('emits error on websocket error', () => {
const error = new Error('Test error');
const onError = vi.fn();

server.on('error', onError);
server.wss.emit('connection', socket);
socket.emit('error', error);

expect(onError).toHaveBeenCalledWith(error);
});

it('sends a message if client is connected and open', () => {
const msg = { id: 1, result: 'ok' };

server.wss.emit('connection', socket);
server.sendMessage(msg);

expect(socket.send).toHaveBeenCalledWith(JSON.stringify(msg));
});

it('does not send message if no client connected', () => {
server.sendMessage({ hello: 'world' });

expect(socket.send).not.toHaveBeenCalled();
});

it('does not send message if client is not OPEN', () => {
socket.readyState = 0;

server.wss.emit('connection', socket);
server.sendMessage({ id: 1 });

expect(socket.send).not.toHaveBeenCalled();
});
});
Loading
Loading