Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 9 additions & 9 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,6 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "WP Playground CLI - Listen for Xdebug",
"type": "php",
"request": "launch",
"port": 9003,
"pathMappings": {
"/": "${workspaceFolder}/.playground-xdebug-root"
}
},
{
"name": "Debug PHP-WASM CLI",
"request": "launch",
Expand Down Expand Up @@ -120,6 +111,15 @@
"--inspect-brk",
"--loader=${workspaceFolder}/packages/nx-extensions/src/executors/built-script/loader.mjs"
]
},
{
"name": "WP Playground CLI - Listen for Xdebug",
"type": "php",
"request": "launch",
"port": 9003,
"pathMappings": {
"/": "${workspaceFolder}/.playground-xdebug-root"
}
}
],
"inputs": [
Expand Down
17 changes: 16 additions & 1 deletion packages/php-wasm/node/src/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
export * from './get-php-loader-module';
export * from './networking/with-networking';
export * from './load-runtime';
export * from './use-host-filesystem';
export * from './node-fs-mount';
export * from './file-lock-manager';
export * from './file-lock-manager-for-node';
export * from './xdebug/with-xdebug';

// Network connectors
export {
createSmtpConnector,
createMysqlConnector,
type SmtpConnectorOptions,
type SmtpEmail,
type MysqlConnectorOptions,
createPortConnector,
createCustomConnector,
createFindConnector,
type NetworkConnector,
type NetworkConnection,
type ConnectionInfo,
type FindConnectorFunction,
} from '@php-wasm/util';
60 changes: 58 additions & 2 deletions packages/php-wasm/node/src/lib/load-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,47 @@ import type {
import { loadPHPRuntime, FSHelpers } from '@php-wasm/universal';
import fs from 'fs';
import { getPHPLoaderModule } from '.';
import { withNetworking } from './networking/with-networking';
import type { FileLockManager } from './file-lock-manager';
import { withXdebug, type XdebugOptions } from './xdebug/with-xdebug';
import { withIntl } from './extensions/intl/with-intl';
import { joinPaths } from '@php-wasm/util';
import type { Promised } from '@php-wasm/util';
import { dirname } from 'path';
import type { ConnectToFunction } from '@php-wasm/util';
import {
initOutboundWebsocketProxyServer,
addSocketOptionsSupportToWebSocketClass,
} from './networking/outbound-ws-to-tcp-proxy';
import { addTCPServerToWebSocketServerClass } from './networking/inbound-tcp-to-ws-proxy';
import { findFreePorts } from './networking/utils';

export interface PHPLoaderOptions {
emscriptenOptions?: EmscriptenOptions;
followSymlinks?: boolean;
withXdebug?: boolean;
xdebug?: XdebugOptions;
withIntl?: boolean;
/**
* Function to find the appropriate network connector for a connection.
* Unhandled ports will use the default TCP proxy.
*
* Example:
* ```
* import { createSmtpConnector, createMysqlConnector } from '@php-wasm/node';
*
* const smtpConnector = createSmtpConnector();
* const mysqlConnector = createMysqlConnector({ debug: true });
*
* function findConnector(info) {
* if (info.port === 25 || info.port === 587) return smtpConnector;
* if (info.port === 3306) return mysqlConnector;
* return undefined; // Falls back to real TCP
* }
*
* const php = await loadNodeRuntime('8.0', { findConnector });
* ```
*/
connectTo?: ConnectToFunction;
}

type PHPLoaderOptionsForNode = PHPLoaderOptions & {
Expand Down Expand Up @@ -235,7 +262,36 @@ export async function loadNodeRuntime(
emscriptenOptions = await withIntl(phpVersion, emscriptenOptions);
}

emscriptenOptions = await withNetworking(emscriptenOptions);
// Apply networking - defaults to TCP proxy with no interceptors
const [inboundProxyWsServerPort, outboundProxyWsServerPort] =
await findFreePorts(2);

const outboundNetworkProxyServer = await initOutboundWebsocketProxyServer(
outboundProxyWsServerPort,
'127.0.0.1',
options?.connectTo
);

emscriptenOptions = {
...emscriptenOptions,
outboundNetworkProxyServer,
websocket: {
...(emscriptenOptions['websocket'] || {}),
url: (_: any, host: string, port: string) => {
const query = new URLSearchParams({
host,
port,
}).toString();
return `ws://127.0.0.1:${outboundProxyWsServerPort}/?${query}`;
},
subprotocol: 'binary',
decorator: addSocketOptionsSupportToWebSocketClass,
serverDecorator: addTCPServerToWebSocketServerClass.bind(
null,
inboundProxyWsServerPort
),
},
};

return await loadPHPRuntime(
await getPHPLoaderModule(phpVersion),
Expand Down
139 changes: 136 additions & 3 deletions packages/php-wasm/node/src/lib/networking/outbound-ws-to-tcp-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import * as net from 'net';
import * as util from 'node:util';
import { WebSocketServer } from 'ws';
import { debugLog } from './utils';
import type { ConnectToFunction, NetworkConnection } from '@php-wasm/util';
import type { NetworkConnector } from '@php-wasm/util';

function log(...args: any[]) {
debugLog('[WS Server]', ...args);
Expand Down Expand Up @@ -97,7 +99,8 @@ export function addSocketOptionsSupportToWebSocketClass(

export function initOutboundWebsocketProxyServer(
listenPort: number,
listenHost = '127.0.0.1'
listenHost = '127.0.0.1',
connectTo?: ConnectToFunction
): Promise<http.Server> {
log(`Binding the WebSockets server to ${listenHost}:${listenPort}...`);
const webServer = http.createServer((request, response) => {
Expand All @@ -110,14 +113,97 @@ export function initOutboundWebsocketProxyServer(
return new Promise((resolve) => {
webServer.listen(listenPort, listenHost, function () {
const wsServer = new WebSocketServer({ server: webServer });
wsServer.on('connection', onWsConnect);
wsServer.on('connection', (client, request) =>
onWsConnect(client, request, connectTo)
);
resolve(webServer);
});
});
}

/**
* Bridges a WebSocket to a stream-based NetworkConnector.
* Converts WebSocket messages to ReadableStream and WritableStream.
*/
async function bridgeWebSocketToConnector(
client: any,
connector: NetworkConnector,
host: string,
port: number,
clientLog: (...args: any[]) => void
): Promise<void> {
// Create upstream (from WebSocket to connector)
const upstreamController = {
controller: null as ReadableStreamDefaultController<Uint8Array> | null,
};

const upstream = new ReadableStream<Uint8Array>({
start(controller) {
upstreamController.controller = controller;
},
});

// Create downstream (from connector to WebSocket)
const downstream = new WritableStream<Uint8Array>({
write(chunk) {
if (client.readyState === 1) {
// OPEN
// Prepend COMMAND_CHUNK byte
client.send(prependByte(chunk, COMMAND_CHUNK));
}
},
close() {
client.close();
},
abort(error) {
clientLog('Downstream aborted:', error);
client.close();
},
});

// Handle incoming WebSocket messages
client.on('message', (msg: Buffer) => {
if (!upstreamController.controller) return;

// First byte is command type
const commandType = msg[0];
if (commandType === COMMAND_CHUNK) {
// Send data to connector (skip command byte)
upstreamController.controller.enqueue(new Uint8Array(msg.slice(1)));
}
// Ignore socket option commands for now
});

client.on('close', () => {
if (upstreamController.controller) {
upstreamController.controller.close();
}
});

client.on('error', (error: Error) => {
clientLog('WebSocket error:', error);
if (upstreamController.controller) {
upstreamController.controller.error(error);
}
});

// Create NetworkConnection and call connector
const connection: NetworkConnection = {
host,
port,
upstream,
downstream,
};

await connector.connect(connection);
}

// Handle new WebSocket client
async function onWsConnect(client: any, request: http.IncomingMessage) {
async function onWsConnect(
client: any,
request: http.IncomingMessage,
connectTo?: ConnectToFunction
) {
const clientAddr = client?._socket?.remoteAddress || client.url;
const clientLog = function (...args: any[]) {
log(' ' + clientAddr + ': ', ...args);
Expand Down Expand Up @@ -155,6 +241,53 @@ async function onWsConnect(client: any, request: http.IncomingMessage) {
return;
}

// Check if there's a custom connector for this port
if (connectTo) {
// Resolve the target host first (connectors may need the IP)
let reqTargetIp = reqTargetHost;
if (net.isIP(reqTargetHost) === 0) {
clientLog('resolving ' + reqTargetHost + '... ');
try {
const resolution = await lookup(reqTargetHost);
reqTargetIp = resolution.address;
clientLog('resolved ' + reqTargetHost + ' -> ' + reqTargetIp);
} catch (e) {
clientLog("can't resolve " + reqTargetHost + ' due to:', e);
// Still try to find a connector even if DNS resolution fails
}
}

const connector = connectTo({
port: reqTargetPort,
host: reqTargetHost,
ip: reqTargetIp !== reqTargetHost ? reqTargetIp : undefined,
});

if (connector) {
clientLog(
`Using connector "${connector.name}" for ${reqTargetHost}:${reqTargetPort}`
);
try {
// Bridge WebSocket to streams for the unified connector
await bridgeWebSocketToConnector(
client,
connector,
reqTargetHost,
reqTargetPort,
clientLog
);
return;
} catch (error) {
clientLog(`Connector error: ${error}`);
client.send([]);
setTimeout(() => {
client.close(3000);
});
return;
}
}
}

// eslint-disable-next-line prefer-const
let target: any;
const recvQueue: Buffer[] = [];
Expand Down
Loading
Loading