From ada4f06c1bfd95103701e6e236a2f6376164348c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 4 Nov 2025 17:53:26 +0100 Subject: [PATCH] =?UTF-8?q?[PHP]=20Network=20connectors\nExploring=20suppo?= =?UTF-8?q?rt=20for=20multiple=20network=20services=20=E2=80=93=20HTTPS=20?= =?UTF-8?q?proxy,=20MySQL,=20SMTP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/launch.json | 18 +- packages/php-wasm/node/src/lib/index.ts | 17 +- .../php-wasm/node/src/lib/load-runtime.ts | 60 +- .../networking/outbound-ws-to-tcp-proxy.ts | 139 +++- .../src/lib/connectors/mysql-connector.ts | 363 +++++++++ .../util/src/lib/connectors/smtp-connector.ts | 250 ++++++ packages/php-wasm/util/src/lib/index.ts | 23 + .../util/src/lib/network-connector.ts | 162 ++++ .../connectors/http-fetch-connector.spec.ts | 593 ++++++++++++++ .../lib/connectors/http-fetch-connector.ts | 496 ++++++++++++ packages/php-wasm/web/src/lib/index.ts | 20 +- packages/php-wasm/web/src/lib/load-runtime.ts | 37 +- .../php-wasm/web/src/lib/network-websocket.ts | 235 ++++++ .../src/lib/tcp-over-fetch-websocket.spec.ts | 561 ------------- .../web/src/lib/tcp-over-fetch-websocket.ts | 740 ------------------ .../src/lib/playground-worker-endpoint.ts | 20 +- 16 files changed, 2401 insertions(+), 1333 deletions(-) create mode 100644 packages/php-wasm/util/src/lib/connectors/mysql-connector.ts create mode 100644 packages/php-wasm/util/src/lib/connectors/smtp-connector.ts create mode 100644 packages/php-wasm/util/src/lib/network-connector.ts create mode 100644 packages/php-wasm/web/src/lib/connectors/http-fetch-connector.spec.ts create mode 100644 packages/php-wasm/web/src/lib/connectors/http-fetch-connector.ts create mode 100644 packages/php-wasm/web/src/lib/network-websocket.ts delete mode 100644 packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.spec.ts delete mode 100644 packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 1e25c208a8..2d8f2089e0 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -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", @@ -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": [ diff --git a/packages/php-wasm/node/src/lib/index.ts b/packages/php-wasm/node/src/lib/index.ts index e27461ff33..f92a90c676 100644 --- a/packages/php-wasm/node/src/lib/index.ts +++ b/packages/php-wasm/node/src/lib/index.ts @@ -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'; diff --git a/packages/php-wasm/node/src/lib/load-runtime.ts b/packages/php-wasm/node/src/lib/load-runtime.ts index 0b49ffba38..ffc31c69a2 100644 --- a/packages/php-wasm/node/src/lib/load-runtime.ts +++ b/packages/php-wasm/node/src/lib/load-runtime.ts @@ -7,13 +7,19 @@ 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; @@ -21,6 +27,27 @@ export interface PHPLoaderOptions { 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 & { @@ -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), diff --git a/packages/php-wasm/node/src/lib/networking/outbound-ws-to-tcp-proxy.ts b/packages/php-wasm/node/src/lib/networking/outbound-ws-to-tcp-proxy.ts index c34ead05c6..fc353e261d 100644 --- a/packages/php-wasm/node/src/lib/networking/outbound-ws-to-tcp-proxy.ts +++ b/packages/php-wasm/node/src/lib/networking/outbound-ws-to-tcp-proxy.ts @@ -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); @@ -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 { log(`Binding the WebSockets server to ${listenHost}:${listenPort}...`); const webServer = http.createServer((request, response) => { @@ -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 { + // Create upstream (from WebSocket to connector) + const upstreamController = { + controller: null as ReadableStreamDefaultController | null, + }; + + const upstream = new ReadableStream({ + start(controller) { + upstreamController.controller = controller; + }, + }); + + // Create downstream (from connector to WebSocket) + const downstream = new WritableStream({ + 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); @@ -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[] = []; diff --git a/packages/php-wasm/util/src/lib/connectors/mysql-connector.ts b/packages/php-wasm/util/src/lib/connectors/mysql-connector.ts new file mode 100644 index 0000000000..dae15979db --- /dev/null +++ b/packages/php-wasm/util/src/lib/connectors/mysql-connector.ts @@ -0,0 +1,363 @@ +/** + * Unified MySQL mock connector. + * + * Simulates a MySQL server on port 3306. + * Works in both browser and Node.js environments. + */ + +export interface MysqlConnectorOptions { + serverVersion?: string; + onQuery?: (query: string) => void; + debug?: boolean; +} + +export function createMysqlConnector(options: MysqlConnectorOptions = {}) { + const serverVersion = options.serverVersion || '8.0.0-playground-mock'; + const debug = options.debug || false; + + return { + name: 'MySQL Mock', + matches: 3306, + connect: async (connection: { + host: string; + port: number; + upstream: ReadableStream; + downstream: WritableStream; + }) => { + const log = debug + ? (msg: string) => + console.log( + `[MySQL ${connection.host}:${connection.port}] ${msg}` + ) + : () => {}; + + log('Connection established'); + + const reader = connection.upstream.getReader(); + const writer = connection.downstream.getWriter(); + + try { + // Send initial handshake packet + const handshake = createHandshakePacket(serverVersion); + await writer.write(handshake); + log('Sent handshake packet'); + + // Read authentication response + const authResponse = await readPacket(reader); + if (authResponse) { + log('Received authentication response'); + + // Send OK packet to accept authentication + const okPacket = createOkPacket(); + await writer.write(okPacket); + log('Authentication successful'); + } + + // Handle queries + while (true) { + const packet = await readPacket(reader); + if (!packet) { + log('Connection closed by client'); + break; + } + + // Parse command type (first byte after header) + const commandType = packet[4]; + + switch (commandType) { + case 0x01: // COM_QUIT + log('Client requested disconnect'); + await writer.close(); + return; + + case 0x03: // COM_QUERY + const query = new TextDecoder().decode( + packet.slice(5) + ); + log(`Query: ${query}`); + + if (options.onQuery) { + options.onQuery(query); + } + + // Send mock result set + const resultSet = createMockResultSet(query); + await writer.write(resultSet); + break; + + case 0x0e: // COM_PING + log('Ping received'); + const pingOk = createOkPacket(); + await writer.write(pingOk); + break; + + case 0x16: // COM_STMT_PREPARE + log('Prepare statement request'); + const prepareOk = createPrepareOkPacket(); + await writer.write(prepareOk); + break; + + case 0x17: // COM_STMT_EXECUTE + log('Execute statement request'); + const execResult = createMockResultSet('SELECT 1'); + await writer.write(execResult); + break; + + default: + log( + `Unknown command type: 0x${commandType.toString( + 16 + )}` + ); + // Send OK anyway to keep connection alive + const unknownOk = createOkPacket(); + await writer.write(unknownOk); + break; + } + } + } catch (error) { + log(`Error: ${error}`); + } finally { + try { + await writer.close(); + } catch { + // Already closed + } + log('Connection terminated'); + } + }, + }; +} + +/** + * Read a MySQL packet from the stream + */ +async function readPacket( + reader: ReadableStreamDefaultReader +): Promise { + // Read packet header (4 bytes: 3 bytes length + 1 byte sequence) + let header = new Uint8Array(0); + while (header.length < 4) { + const { done, value } = await reader.read(); + if (done) return null; + header = concatUint8Arrays([header, value]); + } + + // Parse packet length + const length = header[0] | (header[1] << 8) | (header[2] << 16); + + // Read packet body + let body = header.slice(4); + while (body.length < length) { + const { done, value } = await reader.read(); + if (done) return null; + body = concatUint8Arrays([body, value]); + } + + // Return header + body (full packet) + return concatUint8Arrays([header.slice(0, 4), body.slice(0, length)]); +} + +/** + * Create MySQL handshake packet (Protocol version 10) + */ +function createHandshakePacket(serverVersion: string): Uint8Array { + const parts: number[] = []; + + // Protocol version (10) + parts.push(0x0a); + + // Server version (null-terminated) + const versionBytes = new TextEncoder().encode(serverVersion); + parts.push(...versionBytes, 0x00); + + // Connection ID (4 bytes) + parts.push(0x01, 0x00, 0x00, 0x00); + + // Auth plugin data part 1 (8 bytes) + parts.push(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00); + + // Filler (1 byte) + parts.push(0x00); + + // Capability flags (2 bytes) - lower 16 bits + parts.push(0xff, 0xf7); + + // Character set (1 byte) - utf8_general_ci + parts.push(0x21); + + // Status flags (2 bytes) + parts.push(0x02, 0x00); + + // Capability flags (2 bytes) - upper 16 bits + parts.push(0xff, 0x81); + + // Auth plugin data length (1 byte) + parts.push(0x15); + + // Reserved (10 bytes) + parts.push(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00); + + // Auth plugin data part 2 (12 bytes + null terminator) + parts.push( + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00 + ); + + // Auth plugin name (null-terminated) - mysql_native_password + const pluginName = new TextEncoder().encode('mysql_native_password'); + parts.push(...pluginName, 0x00); + + const body = new Uint8Array(parts); + return addPacketHeader(body, 0); +} + +/** + * Create MySQL OK packet + */ +function createOkPacket(): Uint8Array { + const parts: number[] = []; + + // OK header + parts.push(0x00); + + // Affected rows (length-encoded integer - 0) + parts.push(0x00); + + // Last insert ID (length-encoded integer - 0) + parts.push(0x00); + + // Status flags (2 bytes) + parts.push(0x02, 0x00); + + // Warnings (2 bytes) + parts.push(0x00, 0x00); + + const body = new Uint8Array(parts); + return addPacketHeader(body, 1); +} + +/** + * Create a mock result set for a query + */ +function createMockResultSet(query: string): Uint8Array { + // For simplicity, always return a single row with value 1 + const packets: Uint8Array[] = []; + + // Column count packet + packets.push(addPacketHeader(new Uint8Array([0x01]), 1)); + + // Column definition packet + const colDef: number[] = []; + // catalog + colDef.push(0x03, 0x64, 0x65, 0x66); // "def" + // schema (empty) + colDef.push(0x00); + // table (empty) + colDef.push(0x00); + // org_table (empty) + colDef.push(0x00); + // name + colDef.push(0x01, 0x31); // "1" + // org_name + colDef.push(0x00); + // filler + colDef.push(0x0c); + // character set (2 bytes) - binary + colDef.push(0x3f, 0x00); + // column length (4 bytes) + colDef.push(0x01, 0x00, 0x00, 0x00); + // type (LONG) + colDef.push(0x08); + // flags (2 bytes) + colDef.push(0x01, 0x00); + // decimals + colDef.push(0x00); + // filler (2 bytes) + colDef.push(0x00, 0x00); + + packets.push(addPacketHeader(new Uint8Array(colDef), 2)); + + // EOF packet (or OK packet in newer protocol) + packets.push( + addPacketHeader(new Uint8Array([0xfe, 0x00, 0x00, 0x02, 0x00]), 3) + ); + + // Row data packet + const rowData = new Uint8Array([0x01, 0x31]); // "1" + packets.push(addPacketHeader(rowData, 4)); + + // EOF packet + packets.push( + addPacketHeader(new Uint8Array([0xfe, 0x00, 0x00, 0x02, 0x00]), 5) + ); + + return concatUint8Arrays(packets); +} + +/** + * Create prepare OK packet for prepared statements + */ +function createPrepareOkPacket(): Uint8Array { + const parts: number[] = []; + + // OK header for prepared statement + parts.push(0x00); + + // Statement ID (4 bytes) + parts.push(0x01, 0x00, 0x00, 0x00); + + // Number of columns (2 bytes) + parts.push(0x01, 0x00); + + // Number of parameters (2 bytes) + parts.push(0x00, 0x00); + + // Filler (1 byte) + parts.push(0x00); + + // Warning count (2 bytes) + parts.push(0x00, 0x00); + + const body = new Uint8Array(parts); + return addPacketHeader(body, 1); +} + +/** + * Add MySQL packet header (length + sequence number) + */ +function addPacketHeader(body: Uint8Array, sequenceId: number): Uint8Array { + const length = body.length; + const header = new Uint8Array([ + length & 0xff, + (length >> 8) & 0xff, + (length >> 16) & 0xff, + sequenceId & 0xff, + ]); + return concatUint8Arrays([header, body]); +} + +/** + * Concatenate Uint8Arrays + */ +function concatUint8Arrays(arrays: Uint8Array[]): Uint8Array { + const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + for (const arr of arrays) { + result.set(arr, offset); + offset += arr.length; + } + return result; +} diff --git a/packages/php-wasm/util/src/lib/connectors/smtp-connector.ts b/packages/php-wasm/util/src/lib/connectors/smtp-connector.ts new file mode 100644 index 0000000000..6d38db68a1 --- /dev/null +++ b/packages/php-wasm/util/src/lib/connectors/smtp-connector.ts @@ -0,0 +1,250 @@ +/** + * Unified SMTP mock connector. + * + * Simulates an SMTP server on ports 25, 587, 465. + * Works in both browser and Node.js environments. + */ + +export interface SmtpConnectorOptions { + hostname?: string; + onEmailSent?: (email: SmtpEmail) => void; + debug?: boolean; +} + +export interface SmtpEmail { + from: string; + to: string[]; + data: string; + timestamp: Date; +} + +export function createSmtpConnector(options: SmtpConnectorOptions = {}) { + const hostname = options.hostname || 'playground.internal'; + const debug = options.debug || false; + + return { + name: 'SMTP Mock', + matches: [25, 587, 465], + connect: async (connection: { + host: string; + port: number; + upstream: ReadableStream; + downstream: WritableStream; + }) => { + const log = debug + ? (msg: string) => + console.log( + `[SMTP ${connection.host}:${connection.port}] ${msg}` + ) + : () => {}; + + log('Connection established'); + + const reader = connection.upstream.getReader(); + const writer = connection.downstream.getWriter(); + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + + let buffer = ''; + let state: 'INITIAL' | 'MAIL' | 'RCPT' | 'DATA' = 'INITIAL'; + let emailData: Partial = { + to: [], + timestamp: new Date(), + }; + + const send = async (code: number, message: string) => { + const response = `${code} ${message}\r\n`; + log(`← ${response.trim()}`); + await writer.write(encoder.encode(response)); + }; + + try { + await send(220, `${hostname} ESMTP Service Ready`); + + while (true) { + const { done, value } = await reader.read(); + if (done) { + log('Connection closed by client'); + break; + } + + buffer += decoder.decode(value, { stream: true }); + + let newlineIndex; + while ((newlineIndex = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newlineIndex).trim(); + buffer = buffer.slice(newlineIndex + 1); + + if (!line) continue; + + log(`→ ${line}`); + + const command = line.split(' ')[0].toUpperCase(); + const args = line.slice(command.length).trim(); + + switch (command) { + case 'EHLO': + case 'HELO': + await send(250, `${hostname} Hello ${args}`); + state = 'INITIAL'; + break; + + case 'MAIL': + const fromMatch = + args.match(/FROM:\s*]+)>?/i); + if (fromMatch) { + emailData.from = fromMatch[1]; + await send(250, 'OK'); + state = 'MAIL'; + } else { + await send( + 501, + 'Syntax error in parameters' + ); + } + break; + + case 'RCPT': + if (state !== 'MAIL' && state !== 'RCPT') { + await send(503, 'Bad sequence of commands'); + break; + } + const toMatch = + args.match(/TO:\s*]+)>?/i); + if (toMatch) { + emailData.to!.push(toMatch[1]); + await send(250, 'OK'); + state = 'RCPT'; + } else { + await send( + 501, + 'Syntax error in parameters' + ); + } + break; + + case 'DATA': + if (state !== 'RCPT') { + await send(503, 'Bad sequence of commands'); + break; + } + await send( + 354, + 'Start mail input; end with .' + ); + state = 'DATA'; + + let dataBuffer = ''; + let collecting = true; + while (collecting) { + const { done: dataDone, value: dataValue } = + await reader.read(); + if (dataDone) { + collecting = false; + break; + } + + const chunk = decoder.decode(dataValue, { + stream: true, + }); + dataBuffer += chunk; + + const endMarkerIndex = + dataBuffer.indexOf('\r\n.\r\n'); + if (endMarkerIndex !== -1) { + emailData.data = dataBuffer.slice( + 0, + endMarkerIndex + ); + buffer = + dataBuffer.slice( + endMarkerIndex + 5 + ) + buffer; + collecting = false; + + log( + `Email captured: from=${ + emailData.from + }, to=${emailData.to!.join( + ',' + )}, size=${ + emailData.data!.length + } bytes` + ); + + if (options.onEmailSent) { + options.onEmailSent( + emailData as SmtpEmail + ); + } + + await send( + 250, + 'OK: Message accepted for delivery' + ); + state = 'INITIAL'; + + emailData = { + to: [], + timestamp: new Date(), + }; + } + } + break; + + case 'RSET': + emailData = { + to: [], + timestamp: new Date(), + }; + state = 'INITIAL'; + await send(250, 'OK'); + break; + + case 'NOOP': + await send(250, 'OK'); + break; + + case 'QUIT': + await send( + 221, + `${hostname} closing connection` + ); + await writer.close(); + log('Connection closed gracefully'); + return; + + case 'VRFY': + case 'EXPN': + await send( + 252, + 'Cannot VRFY user, but will accept message' + ); + break; + + case 'HELP': + await send(214, 'This is a mock SMTP server'); + break; + + default: + await send( + 500, + `Command not recognized: ${command}` + ); + break; + } + } + } + } catch (error) { + log(`Error: ${error}`); + } finally { + try { + await writer.close(); + } catch { + // Already closed + } + log('Connection terminated'); + } + }, + }; +} diff --git a/packages/php-wasm/util/src/lib/index.ts b/packages/php-wasm/util/src/lib/index.ts index 6567c0bbfa..ebf65246f4 100644 --- a/packages/php-wasm/util/src/lib/index.ts +++ b/packages/php-wasm/util/src/lib/index.ts @@ -37,3 +37,26 @@ export function concatArrayBuffers(buffers: ArrayBuffer[]): ArrayBuffer { } export * from './types'; + +// Network connector system (unified for web and node) +export { + createFindConnector, + createPortConnector, + createCustomConnector, + connectorMatches, + type NetworkConnector, + type NetworkConnection, + type ConnectionInfo, + type ConnectToFunction, +} from './network-connector'; + +// Network connectors (unified for web and node) +export { + createSmtpConnector, + type SmtpConnectorOptions, + type SmtpEmail, +} from './connectors/smtp-connector'; +export { + createMysqlConnector, + type MysqlConnectorOptions, +} from './connectors/mysql-connector'; diff --git a/packages/php-wasm/util/src/lib/network-connector.ts b/packages/php-wasm/util/src/lib/network-connector.ts new file mode 100644 index 0000000000..a13df2825d --- /dev/null +++ b/packages/php-wasm/util/src/lib/network-connector.ts @@ -0,0 +1,162 @@ +/** + * Unified network connector system for routing network traffic to different handlers + * based on port, protocol, or custom logic. + * + * This enables composable network handling where you can: + * - Route HTTP/HTTPS to fetch() + * - Route SMTP to a mock or real SMTP server + * - Route MySQL to a mock or real MySQL server + * - Add custom handlers for any port or protocol + */ + +/** + * Information about a connection attempt. + */ +export interface ConnectionInfo { + /** + * The port being connected to + */ + port: number; + + /** + * The hostname or IP address from the connection request + */ + host: string; + + /** + * Resolved IP address (if available and different from host) + */ + ip?: string; +} + +/** + * A network connection with stream-based I/O. + * This is the unified connection type used across web and Node.js. + */ +export interface NetworkConnection { + /** + * The host being connected to (e.g., "example.com", "192.168.1.1") + */ + host: string; + + /** + * The port being connected to (e.g., 80, 443, 3306) + */ + port: number; + + /** + * Stream of data from the client (PHP) to the server + */ + upstream: ReadableStream; + + /** + * Stream of data from the server back to the client (PHP) + */ + downstream: WritableStream; +} + +/** + * A network connector handles connections to specific ports or hosts. + */ +export interface NetworkConnector { + /** + * Human-readable name for this connector (e.g., "HTTP Fetch", "SMTP Mock") + */ + name: string; + + /** + * Determines if this connector should handle a given connection. + * Can be: + * - A single port number (e.g., 3306) + * - An array of port numbers (e.g., [25, 587, 465]) + * - A predicate function for complex logic + */ + matches: number | number[] | ((info: ConnectionInfo) => boolean); + + /** + * Handle the network connection. + * This method should: + * 1. Read data from connection.upstream + * 2. Process it according to the protocol + * 3. Write responses to connection.downstream + * + * @param connection The network connection to handle + * @returns A promise that resolves when the connection is closed + */ + connect(connection: NetworkConnection): Promise; +} + +/** + * Type for a function that finds the appropriate connector for a connection. + */ +export type ConnectToFunction = ( + info: ConnectionInfo +) => NetworkConnector | undefined; + +/** + * Checks if a connector matches the given connection info. + */ +export function connectorMatches( + connector: NetworkConnector, + info: ConnectionInfo +): boolean { + const { matches } = connector; + + if (typeof matches === 'number') { + return info.port === matches; + } + + if (Array.isArray(matches)) { + return matches.includes(info.port); + } + + if (typeof matches === 'function') { + return matches(info); + } + + return false; +} + +/** + * Creates a findConnector function from an array of connectors. + * Connectors are checked in order, first match wins. + */ +export function createFindConnector( + connectors: NetworkConnector[] +): ConnectToFunction { + return (info: ConnectionInfo) => { + return connectors.find((connector) => + connectorMatches(connector, info) + ); + }; +} + +/** + * Helper to create a connector with a simple port matcher. + */ +export function createPortConnector( + name: string, + ports: number | number[], + connect: (connection: NetworkConnection) => Promise +): NetworkConnector { + return { + name, + matches: ports, + connect, + }; +} + +/** + * Helper to create a connector with custom matching logic. + */ +export function createCustomConnector( + name: string, + matcher: (info: ConnectionInfo) => boolean, + connect: (connection: NetworkConnection) => Promise +): NetworkConnector { + return { + name, + matches: matcher, + connect, + }; +} diff --git a/packages/php-wasm/web/src/lib/connectors/http-fetch-connector.spec.ts b/packages/php-wasm/web/src/lib/connectors/http-fetch-connector.spec.ts new file mode 100644 index 0000000000..fcce7e045a --- /dev/null +++ b/packages/php-wasm/web/src/lib/connectors/http-fetch-connector.spec.ts @@ -0,0 +1,593 @@ +import { + createHttpConnector, + type HttpFetchConnectorOptions, +} from './http-fetch-connector'; +import express from 'express'; +import type http from 'http'; +import type { AddressInfo } from 'net'; +import zlib from 'zlib'; +import { concatUint8Arrays } from '@php-wasm/util'; +import * as fetchWithCorsProxyModule from '../fetch-with-cors-proxy'; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +const pygmalion = `PREFACE TO PYGMALION. + +A Professor of Phonetics. + +As will be seen later on, Pygmalion needs, not a preface, but a sequel, +which I have supplied in its due place. The English have no respect for +their language, and will not teach their children to speak it. They +spell it so abominably that no man can teach himself what it sounds +like. It is impossible for an Englishman to open his mouth without +making some other Englishman hate or despise him. German and Spanish +are accessible to foreigners: English is not accessible even to +Englishmen. The reformer England needs today is an energetic phonetic +enthusiast: that is why I have made such a one the hero of a popular +play. There have been heroes of that kind crying in the wilderness for +many years past. When I became interested in the subject towards the +end of the eighteen-seventies, Melville Bell was dead; but Alexander J. +Ellis was still a living patriarch, with an impressive head always +covered by a velvet skull cap, for which he would apologize to public +meetings in a very courtly manner. He and Tito Pagliardini, another +phonetic veteran, were men whom it was impossible to dislike. Henry +Sweet, then a young man, lacked their sweetness of character: he was +about as conciliatory to conventional mortals as Ibsen or Samuel +Butler. His great ability as a phonetician (he was, I think, the best +of them all at his job) would have entitled him to high official +recognition, and perhaps enabled him to popularize his subject, but for +his Satanic contempt for all academic dignitaries and persons in +general who thought more of Greek than of phonetics. Once, in the days +when the Imperial Institute rose in South Kensington, and Joseph +Chamberlain was booming the Empire, I induced the editor of a leading +monthly review to commission an article from Sweet on the imperial +importance of his subject. When it arrived, it contained nothing but a +savagely derisive attack on a professor of language and literature +whose chair Sweet regarded as proper to a phonetic expert only. The +article, being libelous, had to be returned as impossible; and I had to +renounce my dream of dragging its author into the limelight. When I met +him afterwards, for the first time for many years, I found to my +astonishment that he, who had been a quite tolerably presentable young +man, had actually managed by sheer scorn to alter his personal +appearance until he had become a sort of walking repudiation of Oxford +and all its traditions. It must have been largely in his own despite +that he was squeezed into something called a Readership of phonetics +there. The future of phonetics rests probably with his pupils, who all +swore by him; but nothing could bring the man himself into any sort of +compliance with the university, to which he nevertheless clung by +divine right in an intensely Oxonian way. I daresay his papers, if he +has left any, include some satires that may be published without too +destructive results fifty years hence. He was, I believe, not in the +least an ill-natured man: very much the opposite, I should say; but he +would not suffer fools gladly.`; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('createHttpConnector', () => { + let server: http.Server; + let host: string; + let port: number; + + beforeAll(async () => { + const app = express(); + server = app.listen(0); + const address = server.address() as AddressInfo; + host = '127.0.0.1'; + port = address.port; + + app.get('/simple', (_req, res) => { + res.send('Hello, World!'); + }); + + app.get('/slow', (_req, res) => { + setTimeout(() => { + res.send('Slow response'); + }, 1000); + }); + + app.get('/stream', (_req, res) => { + res.flushHeaders(); + res.write('Part 1'); + setTimeout(() => { + res.write('Part 2'); + res.end(); + }, 1500); + }); + + app.get('/headers', (_req, res) => { + res.set('X-Custom-Header', 'TestValue'); + res.send('OK'); + }); + + app.get('/gzipped', (_req, res) => { + const gzip = zlib.createGzip(); + gzip.write(pygmalion); + gzip.end(); + + const gzippedChunks: Uint8Array[] = []; + gzip.on('data', (chunk) => { + gzippedChunks.push(chunk); + }); + gzip.on('end', () => { + const length = gzippedChunks.reduce( + (acc, chunk) => acc + chunk.length, + 0 + ); + res.setHeader('Content-Encoding', 'gzip'); + res.setHeader('Content-Length', length.toString()); + for (const chunk of gzippedChunks) { + res.write(chunk); + } + res.end(); + }); + }); + + app.post('/echo', (req, res) => { + const contentType = + req.headers['content-type'] || 'text/plain; charset=utf-8'; + res.setHeader('Content-Type', contentType); + res.setHeader('Transfer-Encoding', 'chunked'); + req.pipe(res); + req.on('error', () => { + res.status(500).end(); + }); + }); + + app.get('/error', (_req, res) => { + res.status(500).send('Internal Server Error'); + }); + }); + + afterAll(() => { + server.close(); + }); + + it('should handle a simple HTTP request', async () => { + const response = await sendRawHttpRequest({ + request: buildHttpRequest({ + path: '/simple', + hostHeader: `${host}:${port}`, + }), + connectionHost: host, + connectionPort: port, + }); + + expect(response).toContain('HTTP/1.1 200 OK'); + expect(response).toContain('Hello, World!'); + }); + + it('should handle a slow response', async () => { + const response = await sendRawHttpRequest({ + request: buildHttpRequest({ + path: '/slow', + hostHeader: `${host}:${port}`, + }), + connectionHost: host, + connectionPort: port, + }); + + expect(response).toContain('HTTP/1.1 200 OK'); + expect(response).toContain('Slow response'); + }); + + it('should handle a streaming response', async () => { + const response = await sendRawHttpRequest({ + request: buildHttpRequest({ + path: '/stream', + hostHeader: `${host}:${port}`, + }), + connectionHost: host, + connectionPort: port, + }); + + expect(response).toContain('HTTP/1.1 200 OK'); + expect(response).toContain('Part 1'); + expect(response).toContain('Part 2'); + }); + + it('should handle an error response', async () => { + const response = await sendRawHttpRequest({ + request: buildHttpRequest({ + path: '/error', + hostHeader: `${host}:${port}`, + }), + connectionHost: host, + connectionPort: port, + }); + + expect(response).toContain('HTTP/1.1 500 Internal Server Error'); + expect(response).toContain('Internal Server Error'); + }); + + it('should handle a large POST payload', async () => { + const largePayload = 'X'.repeat(1024 * 1024); + const response = await sendRawHttpRequest({ + request: buildHttpRequest({ + method: 'POST', + path: '/echo', + hostHeader: `${host}:${port}`, + body: largePayload, + }), + connectionHost: host, + connectionPort: port, + }); + + expect(response).toContain('HTTP/1.1 200 OK'); + expect(response.length).toBeGreaterThanOrEqual(largePayload.length); + }); + + it('should forward POST request bodies', async () => { + const response = await sendRawHttpRequest({ + request: buildHttpRequest({ + method: 'POST', + path: '/echo', + hostHeader: `${host}:${port}`, + body: 'Hello, World!', + }), + connectionHost: host, + connectionPort: port, + }); + + expect(response).toContain('HTTP/1.1 200 OK'); + expect(response).toContain('Hello, World!'); + }); + + it('should handle a request with paused streaming', async () => { + const totalBody = 'Part 1Part 2Part 3'; + const headers = buildHttpRequest({ + method: 'POST', + path: '/echo', + hostHeader: `${host}:${port}`, + body: '', + additionalHeaders: `Content-Length: ${totalBody.length}\r\n`, + skipContentLength: true, + }); + const response = await runConnector({ + connectionHost: host, + connectionPort: port, + send: async (writer) => { + await writer.write(encoder.encode(headers)); + await writer.write(encoder.encode('Part 1')); + await new Promise((resolve) => setTimeout(resolve, 200)); + await writer.write(encoder.encode('Part 2')); + await new Promise((resolve) => setTimeout(resolve, 200)); + await writer.write(encoder.encode('Part 3')); + }, + }); + + expect(response).toContain('HTTP/1.1 200 OK'); + expect(response).toContain('Part 1'); + expect(response).toContain('Part 2'); + expect(response).toContain('Part 3'); + }); + + it('should surface response headers', async () => { + const response = await sendRawHttpRequest({ + request: buildHttpRequest({ + path: '/headers', + hostHeader: `${host}:${port}`, + }), + connectionHost: host, + connectionPort: port, + }); + + expect(response).toContain('HTTP/1.1 200 OK'); + expect(response).toContain('x-custom-header: TestValue'); + }); + + it('should handle a gzipped response', async () => { + const response = await sendRawHttpRequest({ + request: buildHttpRequest({ + path: '/gzipped', + hostHeader: `${host}:${port}`, + }), + connectionHost: host, + connectionPort: port, + }); + + expect(response).toContain('HTTP/1.1 200 OK'); + expect(response).not.toContain('content-length'); + expect(response).toContain('transfer-encoding: chunked'); + expect(response.length).toBeGreaterThan(pygmalion.length); + expect(response).toContain(pygmalion.slice(-100)); + }); + + it('should handle a non-existent endpoint', async () => { + const response = await sendRawHttpRequest({ + request: buildHttpRequest({ + path: '/non-existent', + hostHeader: `${host}:${port}`, + }), + connectionHost: host, + connectionPort: port, + }); + + expect(response).toContain('HTTP/1.1 404 Not Found'); + }); + + it('should emit a 400 response when fetch fails', async () => { + vi.spyOn( + fetchWithCorsProxyModule, + 'fetchWithCorsProxy' + ).mockRejectedValue(new Error('Network error')); + + const response = await sendRawHttpRequest({ + request: buildHttpRequest({ + path: '/simple', + hostHeader: `${host}:${port}`, + }), + connectionHost: host, + connectionPort: port, + }); + + expect(response).toContain('HTTP/1.1 400 Bad Request'); + }); +}); + +describe('HTTP request parsing', () => { + it('should decode chunked POST bodies before forwarding', async () => { + const captured = await captureParsedRequest( + `POST /echo HTTP/1.1\r\nHost: playground.internal\r\ntransfer-encoding: chunked\r\n\r\n5\r\nabcde\r\n0\r\n\r\n` + ); + + expect(captured.method).toBe('POST'); + expect(captured.body).toBe('abcde'); + }); + + it('should preserve path and query string', async () => { + const captured = await captureParsedRequest( + buildHttpRequest({ + path: '/core/version-check/1.7/?channel=beta', + hostHeader: 'playground.internal', + }) + ); + + expect(captured.url).toBe( + 'http://playground.internal/core/version-check/1.7/?channel=beta' + ); + }); + + it('should handle a simple path without query', async () => { + const captured = await captureParsedRequest( + buildHttpRequest({ + path: '/api/users', + hostHeader: 'example.com', + }) + ); + + expect(captured.url).toBe('http://example.com/api/users'); + }); + + it('should handle the root path', async () => { + const captured = await captureParsedRequest( + buildHttpRequest({ + path: '/', + hostHeader: 'example.com', + }) + ); + + expect(captured.url).toBe('http://example.com/'); + }); + + it('should preserve URL-encoded characters in path', async () => { + const captured = await captureParsedRequest( + buildHttpRequest({ + path: '/search/hello%20world', + hostHeader: 'example.com', + }) + ); + + expect(captured.url).toBe('http://example.com/search/hello%20world'); + }); + + it('should preserve URL-encoded characters in query string', async () => { + const captured = await captureParsedRequest( + buildHttpRequest({ + path: '/search?q=hello+world&filter=a%26b', + hostHeader: 'example.com', + }) + ); + + expect(captured.url).toBe( + 'http://example.com/search?q=hello+world&filter=a%26b' + ); + }); + + it('should preserve empty query parameter values', async () => { + const captured = await captureParsedRequest( + buildHttpRequest({ + path: '/api?key1=&key2=value2', + hostHeader: 'example.com', + }) + ); + + expect(captured.url).toBe('http://example.com/api?key1=&key2=value2'); + }); + + it('should handle paths with hash fragments', async () => { + const captured = await captureParsedRequest( + buildHttpRequest({ + path: '/page#section', + hostHeader: 'example.com', + }) + ); + + expect(captured.url).toBe('http://example.com/page#section'); + }); + + it('should handle path with query and hash fragments', async () => { + const captured = await captureParsedRequest( + buildHttpRequest({ + path: '/page?param=value#section', + hostHeader: 'example.com', + }) + ); + + expect(captured.url).toBe( + 'http://example.com/page?param=value#section' + ); + }); + + it('should prefer the Host header over the connection host', async () => { + const captured = await captureParsedRequest( + buildHttpRequest({ + path: '/api', + hostHeader: 'custom.host.com', + }), + { + connectionHost: 'default.host.com', + } + ); + + expect(captured.url).toBe('http://custom.host.com/api'); + }); +}); + +type RunConnectorOptions = { + connectionHost: string; + connectionPort: number; + connectorOptions?: HttpFetchConnectorOptions; + send: ( + writer: WritableStreamDefaultWriter + ) => Promise | void; +}; + +type SendRawHttpRequestOptions = { + request: string; + connectionHost: string; + connectionPort: number; + connectorOptions?: HttpFetchConnectorOptions; +}; + +type CaptureOptions = { + connectionHost?: string; + connectionPort?: number; + connectorOptions?: HttpFetchConnectorOptions; +}; + +async function runConnector({ + connectionHost, + connectionPort, + connectorOptions, + send, +}: RunConnectorOptions): Promise { + const connector = createHttpConnector(connectorOptions); + const upstream = new TransformStream(); + const upstreamWriter = upstream.writable.getWriter(); + + const responseChunks: Uint8Array[] = []; + const downstream = new WritableStream({ + write(chunk) { + responseChunks.push(chunk); + }, + }); + + const connectPromise = connector.connect({ + host: connectionHost, + port: connectionPort, + upstream: upstream.readable, + downstream, + }); + + await send(upstreamWriter); + await upstreamWriter.close(); + + await connectPromise; + + return decodeResponseChunks(responseChunks); +} + +async function sendRawHttpRequest({ + request, + connectionHost, + connectionPort, + connectorOptions, +}: SendRawHttpRequestOptions): Promise { + return runConnector({ + connectionHost, + connectionPort, + connectorOptions, + send: async (writer) => { + await writer.write(encoder.encode(request)); + }, + }); +} + +async function captureParsedRequest( + request: string, + options: CaptureOptions = {} +) { + let captured: + | { + url: string; + method: string; + headers: Headers; + body?: string; + } + | undefined; + + vi.spyOn(fetchWithCorsProxyModule, 'fetchWithCorsProxy').mockImplementation( + async (input: RequestInfo) => { + const req = typeof input === 'string' ? new Request(input) : input; + const clone = req.clone(); + const body = + clone.method === 'GET' || clone.method === 'HEAD' + ? undefined + : await clone.text(); + captured = { + url: clone.url, + method: clone.method, + headers: clone.headers, + body, + }; + return new Response('OK'); + } + ); + + await sendRawHttpRequest({ + request, + connectionHost: options.connectionHost ?? 'playground.internal', + connectionPort: options.connectionPort ?? 80, + connectorOptions: options.connectorOptions, + }); + + if (!captured) { + throw new Error('Expected fetchWithCorsProxy to be called'); + } + + return captured; +} + +type BuildHttpRequestOptions = { + method?: string; + path: string; + hostHeader: string; + body?: string; + additionalHeaders?: string; + skipContentLength?: boolean; +}; + +function buildHttpRequest({ + method = 'GET', + path, + hostHeader, + body = '', + additionalHeaders = '', + skipContentLength = false, +}: BuildHttpRequestOptions): string { + const contentLengthHeader = + !skipContentLength && body.length > 0 + ? `Content-Length: ${encoder.encode(body).length}\r\n` + : ''; + return `${method} ${path} HTTP/1.1\r\nHost: ${hostHeader}\r\n${additionalHeaders}${contentLengthHeader}\r\n${body}`; +} + +function decodeResponseChunks(chunks: Uint8Array[]): string { + if (chunks.length === 0) { + return ''; + } + return decoder.decode(concatUint8Arrays(chunks)); +} diff --git a/packages/php-wasm/web/src/lib/connectors/http-fetch-connector.ts b/packages/php-wasm/web/src/lib/connectors/http-fetch-connector.ts new file mode 100644 index 0000000000..83533b9e98 --- /dev/null +++ b/packages/php-wasm/web/src/lib/connectors/http-fetch-connector.ts @@ -0,0 +1,496 @@ +/** + * HTTP/HTTPS connector using fetch() API. + * + * Handles HTTP and HTTPS connections by translating raw TCP bytes + * into fetch() calls. For HTTPS, performs TLS handshake using + * auto-generated certificates. + */ + +import type { GeneratedCertificate } from '../tls/certificates'; +import { generateCertificate } from '../tls/certificates'; +import { TLS_1_2_Connection } from '../tls/1_2/connection'; +import { fetchWithCorsProxy } from '../fetch-with-cors-proxy'; +import { ChunkedDecoderStream } from '../chunked-decoder'; +import { + concatUint8Arrays, + type ConnectionInfo, + type NetworkConnection, + type NetworkConnector, +} from '@php-wasm/util'; + +export interface HttpFetchConnectorOptions { + /** + * Root CA certificate for TLS connections. + * Required for HTTPS support. + */ + CAroot?: GeneratedCertificate; + + /** + * Optional CORS proxy URL for cross-origin requests. + */ + corsProxyUrl?: string; +} + +const HTTP_METHODS = [ + 'GET', + 'POST', + 'HEAD', + 'PATCH', + 'OPTIONS', + 'DELETE', + 'PUT', + 'TRACE', +]; + +/** + * Creates an HTTP/HTTPS connector that uses the fetch() API. + */ +export function createHttpConnector( + options: HttpFetchConnectorOptions = {} +): NetworkConnector { + return { + name: 'HTTP/HTTPS Fetch', + matches: (info: ConnectionInfo) => { + return info.port === 80 || info.port === 443; + }, + connect: async (connection: NetworkConnection) => { + const reader = connection.upstream.getReader(); + const writer = connection.downstream.getWriter(); + + // Buffer initial bytes to detect protocol + let bufferedBytes = new Uint8Array(0); + let protocol: 'http' | 'https' | null = null; + + try { + // Read initial bytes to detect protocol + while (protocol === null && bufferedBytes.length < 8) { + const { done, value } = await reader.read(); + if (done) { + throw new Error( + 'Connection closed before protocol detection' + ); + } + bufferedBytes = concatUint8Arrays([bufferedBytes, value]); + protocol = guessProtocol(connection.port, bufferedBytes); + } + + reader.releaseLock(); + + // Recreate upstream with buffered bytes + const upstreamWithBuffer = new ReadableStream({ + async start(controller) { + controller.enqueue(bufferedBytes); + }, + async pull(controller) { + const newReader = connection.upstream.getReader(); + const { done, value } = await newReader.read(); + if (done) { + controller.close(); + } else { + controller.enqueue(value); + } + newReader.releaseLock(); + }, + }); + + if (protocol === 'https') { + await handleHttps( + connection.host, + upstreamWithBuffer, + writer, + options + ); + } else { + await handleHttp( + connection.host, + upstreamWithBuffer, + writer, + options.corsProxyUrl + ); + } + } catch (error) { + console.error('HTTP connector error:', error); + try { + await writer.close(); + } catch { + // Already closed + } + } + }, + }; +} + +/** + * Detects whether the connection uses HTTP or HTTPS based on initial bytes. + */ +function guessProtocol( + port: number, + data: Uint8Array +): 'http' | 'https' | null { + if (data.length < 8) { + return null; + } + + // TLS handshake detection + const looksLikeTls = + port === 443 && + data[0] === 0x16 && // ContentTypes.Handshake + data[1] === 0x03 && // TLS version major + data[2] >= 0x01 && + data[2] <= 0x03; // TLS version minor (1.0-1.2) + + if (looksLikeTls) { + return 'https'; + } + + // HTTP method detection + const decodedFirstLine = new TextDecoder('latin1', { + fatal: true, + }).decode(data); + const looksLikeHttp = HTTP_METHODS.some((method) => + decodedFirstLine.startsWith(method + ' ') + ); + + if (looksLikeHttp) { + return 'http'; + } + + return null; +} + +/** + * Handles HTTP connections using fetch(). + */ +async function handleHttp( + host: string, + upstream: ReadableStream, + writer: WritableStreamDefaultWriter, + corsProxyUrl?: string +) { + try { + const request = await parseHttpRequest(upstream, host, 'http'); + const responseStream = fetchRawResponseBytes(request, corsProxyUrl); + await responseStream.pipeTo( + new WritableStream({ + write: async (chunk) => { + await writer.write(chunk); + }, + close: async () => { + await writer.close(); + }, + abort: async (error) => { + console.error('HTTP response stream aborted:', error); + await writer.close(); + }, + }) + ); + } catch (error) { + console.error('HTTP handling error:', error); + throw error; + } +} + +/** + * Handles HTTPS connections using TLS and fetch(). + */ +async function handleHttps( + host: string, + upstream: ReadableStream, + writer: WritableStreamDefaultWriter, + options: HttpFetchConnectorOptions +) { + if (!options.CAroot) { + throw new Error( + 'HTTPS connector requires CAroot certificate in options' + ); + } + + try { + // Generate site certificate + const siteCert = await generateCertificate( + { + subject: { + commonName: host, + organizationName: host, + countryName: 'US', + }, + issuer: options.CAroot.tbsDescription.subject, + }, + options.CAroot.keyPair + ); + + // Create TLS connection + const tlsConnection = new TLS_1_2_Connection(); + + // Pipe encrypted bytes to TLS connection + upstream.pipeTo(tlsConnection.clientEnd.upstream.writable).catch(() => { + // Ignore pipeTo errors + }); + + // Pipe decrypted bytes from TLS connection to output + tlsConnection.clientEnd.downstream.readable + .pipeTo( + new WritableStream({ + write: async (chunk) => { + await writer.write(chunk); + }, + close: async () => { + await writer.close(); + }, + abort: async () => { + await writer.close(); + }, + }) + ) + .catch(() => { + // Ignore pipeTo errors + }); + + // Perform TLS handshake + await tlsConnection.TLSHandshake(siteCert.keyPair.privateKey, [ + siteCert.certificate, + options.CAroot.certificate, + ]); + + // Parse HTTP request from decrypted stream + const request = await parseHttpRequest( + tlsConnection.serverEnd.upstream.readable, + host, + 'https' + ); + + // Fetch response and pipe to TLS connection + await fetchRawResponseBytes(request, options.corsProxyUrl).pipeTo( + tlsConnection.serverEnd.downstream.writable + ); + } catch (error) { + console.error('HTTPS handling error:', error); + throw error; + } +} + +/** + * Parses a raw HTTP request from a byte stream. + */ +async function parseHttpRequest( + requestBytesStream: ReadableStream, + host: string, + protocol: 'http' | 'https' +): Promise { + let inputBuffer: Uint8Array = new Uint8Array(0); + let requestDataExhausted = false; + let headersEndIndex = -1; + const requestBytesReader = requestBytesStream.getReader(); + + // Read until we find headers end (\r\n\r\n) + while (headersEndIndex === -1) { + const { done, value } = await requestBytesReader.read(); + if (done) { + requestDataExhausted = true; + break; + } + inputBuffer = concatUint8Arrays([inputBuffer, value]); + headersEndIndex = findSequenceInBuffer( + inputBuffer, + new Uint8Array([0x0d, 0x0a, 0x0d, 0x0a]) + ); + } + requestBytesReader.releaseLock(); + + const headersBuffer = inputBuffer.slice(0, headersEndIndex); + const parsedHeaders = parseRequestHeaders(headersBuffer); + const terminationMode = + parsedHeaders.headers.get('Transfer-Encoding') !== null + ? 'chunked' + : 'content-length'; + const contentLength = + parsedHeaders.headers.get('Content-Length') !== null + ? parseInt(parsedHeaders.headers.get('Content-Length')!, 10) + : undefined; + + const bodyBytes = inputBuffer.slice(headersEndIndex + 4); + let outboundBodyStream: ReadableStream | undefined; + + if (parsedHeaders.method !== 'GET') { + const requestBytesReader = requestBytesStream.getReader(); + let seenBytes = bodyBytes.length; + let last5Bytes = bodyBytes.slice(-6); + const emptyChunk = new TextEncoder().encode('0\r\n\r\n'); + + outboundBodyStream = new ReadableStream({ + async start(controller) { + if (bodyBytes.length > 0) { + controller.enqueue(bodyBytes); + } + if (requestDataExhausted) { + controller.close(); + } + }, + async pull(controller) { + const { done, value } = await requestBytesReader.read(); + seenBytes += value?.length || 0; + if (value) { + controller.enqueue(value); + last5Bytes = concatUint8Arrays([ + last5Bytes, + value || new Uint8Array(), + ]).slice(-5); + } + const shouldTerminate = + done || + (terminationMode === 'content-length' && + contentLength !== undefined && + seenBytes >= contentLength) || + (terminationMode === 'chunked' && + last5Bytes.every( + (byte, index) => byte === emptyChunk[index] + )); + if (shouldTerminate) { + controller.close(); + return; + } + }, + }); + + if (terminationMode === 'chunked') { + outboundBodyStream = outboundBodyStream.pipeThrough( + new ChunkedDecoderStream() + ); + } + } + + const hostname = parsedHeaders.headers.get('Host') ?? host; + const url = new URL(parsedHeaders.path, protocol + '://' + hostname); + + return new Request(url.toString(), { + method: parsedHeaders.method, + headers: parsedHeaders.headers, + body: outboundBodyStream, + // @ts-expect-error - duplex required in Node.js + duplex: 'half', + }); +} + +/** + * Parses HTTP request headers from raw bytes. + */ +function parseRequestHeaders(httpRequestBytes: Uint8Array) { + const httpRequest = new TextDecoder().decode(httpRequestBytes); + const statusLineMaybe = httpRequest.split('\n')[0]; + const [method, path] = statusLineMaybe.split(' '); + + const headers = new Headers(); + for (const line of httpRequest.split('\r\n').slice(1)) { + if (line === '') { + break; + } + const [name, value] = line.split(': '); + headers.set(name, value); + } + + return { method, path, headers }; +} + +/** + * Fetches a response and returns it as a raw byte stream. + */ +function fetchRawResponseBytes( + request: Request, + corsProxyUrl?: string +): ReadableStream { + return new ReadableStream({ + async start(controller) { + let response: Response; + try { + response = await fetchWithCorsProxy( + request, + undefined, + corsProxyUrl + ); + } catch { + // Return 400 Bad Request on fetch failure + controller.enqueue( + new TextEncoder().encode( + 'HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n' + ) + ); + controller.close(); + return; + } + + controller.enqueue(headersAsBytes(response)); + const reader = response.body?.getReader(); + if (!reader) { + controller.close(); + return; + } + + const encoder = new TextEncoder(); + while (true) { + const { done, value } = await reader.read(); + if (value) { + // Use chunked transfer encoding + controller.enqueue( + encoder.encode(`${value.length.toString(16)}\r\n`) + ); + controller.enqueue(value); + controller.enqueue(encoder.encode('\r\n')); + } + if (done) { + controller.enqueue(encoder.encode('0\r\n\r\n')); + controller.close(); + return; + } + } + }, + }); +} + +/** + * Converts HTTP response headers to raw bytes. + */ +function headersAsBytes(response: Response): Uint8Array { + const status = `HTTP/1.1 ${response.status} ${response.statusText}`; + + const headersObject: Record = {}; + response.headers.forEach((value, name) => { + headersObject[name.toLowerCase()] = value; + }); + + // Strip content-length and use chunked encoding instead + delete headersObject['content-length']; + headersObject['transfer-encoding'] = 'chunked'; + + const headers: string[] = []; + for (const [name, value] of Object.entries(headersObject)) { + headers.push(`${name}: ${value}`); + } + const string = [status, ...headers].join('\r\n') + '\r\n\r\n'; + return new TextEncoder().encode(string); +} + +/** + * Finds a byte sequence in a buffer. + */ +function findSequenceInBuffer( + buffer: Uint8Array, + sequence: Uint8Array +): number { + const bufferLength = buffer.length; + const sequenceLength = sequence.length; + const lastPossibleIndex = bufferLength - sequenceLength; + + for (let i = 0; i <= lastPossibleIndex; i++) { + let found = true; + for (let j = 0; j < sequenceLength; j++) { + if (buffer[i + j] !== sequence[j]) { + found = false; + break; + } + } + if (found) { + return i; + } + } + return -1; +} diff --git a/packages/php-wasm/web/src/lib/index.ts b/packages/php-wasm/web/src/lib/index.ts index 6d0ddadbd0..d2dd82e756 100644 --- a/packages/php-wasm/web/src/lib/index.ts +++ b/packages/php-wasm/web/src/lib/index.ts @@ -14,8 +14,26 @@ export type { } from './directory-handle-mount'; export * from './tls/certificates'; -export type { TCPOverFetchOptions } from './tcp-over-fetch-websocket'; export { fetchWithCorsProxy } from './fetch-with-cors-proxy'; + +// Network connectors +export { createHttpConnector } from './connectors/http-fetch-connector'; +export type { HttpFetchConnectorOptions } from './connectors/http-fetch-connector'; +export { withNetworkConnectors } from './network-websocket'; +export { + createSmtpConnector, + createMysqlConnector, + type SmtpConnectorOptions, + type SmtpEmail, + type MysqlConnectorOptions, + createPortConnector, + createCustomConnector, + createFindConnector, + type NetworkConnector, + type NetworkConnection, + type ConnectionInfo, + type ConnectToFunction, +} from '@php-wasm/util'; export { consumeAPI, exposeAPI, diff --git a/packages/php-wasm/web/src/lib/load-runtime.ts b/packages/php-wasm/web/src/lib/load-runtime.ts index 6cca77c298..ad885404dd 100644 --- a/packages/php-wasm/web/src/lib/load-runtime.ts +++ b/packages/php-wasm/web/src/lib/load-runtime.ts @@ -5,14 +5,36 @@ import type { } from '@php-wasm/universal'; import { loadPHPRuntime } from '@php-wasm/universal'; import { getPHPLoaderModule } from './get-php-loader-module'; -import type { TCPOverFetchOptions } from './tcp-over-fetch-websocket'; -import { tcpOverFetchWebsocket } from './tcp-over-fetch-websocket'; import { withICUData } from './with-icu-data'; +import { createFindConnector, type NetworkConnector } from '@php-wasm/util'; +import { withNetworkConnectors } from './network-websocket'; export interface LoaderOptions { emscriptenOptions?: EmscriptenOptions; onPhpLoaderModuleLoaded?: (module: PHPLoaderModule) => void; - tcpOverFetch?: TCPOverFetchOptions; + /** + * Function to find the appropriate network connector for a connection. + * + * If not provided, a default HTTP/HTTPS connector with auto-generated + * CA certificate will be created automatically. + * + * Example: + * ``` + * import { createHttpConnector, createSmtpConnector, generateCertificate } from '@php-wasm/web'; + * + * const httpConnector = createHttpConnector({ CAroot }); + * const smtpConnector = createSmtpConnector(); + * + * function findConnector(info) { + * if (info.port === 80 || info.port === 443) return httpConnector; + * if (info.port === 25 || info.port === 587) return smtpConnector; + * return undefined; + * } + * + * const php = await loadWebRuntime('8.0', { findConnector }); + * ``` + */ + networkConnectors?: NetworkConnector[]; withICU?: boolean; } @@ -51,11 +73,10 @@ export async function loadWebRuntime( ...(loaderOptions.emscriptenOptions || {}), }; - if (loaderOptions.tcpOverFetch) { - emscriptenOptions = tcpOverFetchWebsocket( - emscriptenOptions, - loaderOptions.tcpOverFetch - ); + if (loaderOptions.networkConnectors) { + emscriptenOptions = withNetworkConnectors(emscriptenOptions, { + connectTo: createFindConnector(loaderOptions.networkConnectors), + }); } if (loaderOptions.withICU) { diff --git a/packages/php-wasm/web/src/lib/network-websocket.ts b/packages/php-wasm/web/src/lib/network-websocket.ts new file mode 100644 index 0000000000..f8b96a6633 --- /dev/null +++ b/packages/php-wasm/web/src/lib/network-websocket.ts @@ -0,0 +1,235 @@ +/** + * Network WebSocket that routes connections through a findConnector function. + * + * This is a replacement for TCPOverFetchWebsocket that uses the new + * connector-based architecture, making it easy to add custom handlers + * for different protocols and ports. + */ + +import type { EmscriptenOptions } from '@php-wasm/universal'; +import type { NetworkConnection, ConnectToFunction } from '@php-wasm/util'; + +export interface NetworkWebsocketOptions { + /** + * Function to find the appropriate connector for a connection. + */ + connectTo: ConnectToFunction; + + /** + * Output type for the websocket. + * - 'messages': Emit 'message' events (default, for Emscripten) + * - 'stream': Return clientDownstream stream directly + */ + outputType?: 'messages' | 'stream'; +} + +/** + * Creates Emscripten options with network connector support. + */ +export function withNetworkConnectors( + emOptions: EmscriptenOptions, + options: NetworkWebsocketOptions +): EmscriptenOptions { + const { connectTo } = options; + + return { + ...emOptions, + websocket: { + url: (_: any, host: string, port: string) => { + const query = new URLSearchParams({ + host, + port, + }).toString(); + return `ws://playground.internal/?${query}`; + }, + subprotocol: 'binary', + decorator: () => { + return class extends NetworkWebsocket { + constructor(url: string, wsOptions: string[]) { + super(url, wsOptions, { + connectTo, + outputType: options.outputType || 'messages', + }); + } + }; + }, + }, + }; +} + +interface NetworkWebsocketConstructorOptions { + connectTo: ConnectToFunction; + outputType?: 'messages' | 'stream'; +} + +export class NetworkWebsocket { + CONNECTING = 0; + OPEN = 1; + CLOSING = 2; + CLOSED = 3; + readyState = this.CONNECTING; + binaryType = 'blob'; + bufferedAmount = 0; + extensions = ''; + protocol = 'ws'; + host = ''; + port = 0; + listeners = new Map>(); + + clientUpstream = new TransformStream(); + clientUpstreamWriter = this.clientUpstream.writable.getWriter(); + clientDownstream = new TransformStream(); + + url: string; + options: string[]; + connectTo: ConnectToFunction; + + constructor( + url: string, + options: string[], + { + connectTo, + outputType = 'messages', + }: NetworkWebsocketConstructorOptions + ) { + console.log('NetworkWebsocket constructor', url, options, connectTo); + this.url = url; + this.options = options; + this.connectTo = connectTo; + + const wsUrl = new URL(url); + this.host = wsUrl.searchParams.get('host')!; + this.port = parseInt(wsUrl.searchParams.get('port')!, 10); + this.binaryType = 'arraybuffer'; + + if (outputType === 'messages') { + this.clientDownstream.readable + .pipeTo( + new WritableStream({ + write: (chunk) => { + this.emit('message', { data: chunk }); + }, + abort: () => { + this.emit('error', new Error('ECONNREFUSED')); + this.close(); + }, + close: () => { + this.close(); + }, + }) + ) + .catch(() => { + // Errors communicated via 'error' event + }); + } + + this.readyState = this.OPEN; + this.emit('open'); + + // Start connection handling + this.handleConnection(); + } + + async handleConnection() { + try { + const connector = this.connectTo({ + port: this.port, + host: this.host, + }); + + if (!connector) { + throw new Error( + `No connector found for ${this.host}:${this.port}` + ); + } + + const connection: NetworkConnection = { + host: this.host, + port: this.port, + upstream: this.clientUpstream.readable, + downstream: this.clientDownstream.writable, + }; + + await connector.connect(connection); + } catch (error) { + this.emit('error', error); + this.close(); + } + } + + on(eventName: string, callback: (e: any) => void) { + this.addEventListener(eventName, callback); + } + + once(eventName: string, callback: (e: any) => void) { + const wrapper = (e: any) => { + callback(e); + this.removeEventListener(eventName, wrapper); + }; + this.addEventListener(eventName, wrapper); + } + + addEventListener(eventName: string, callback: (e: any) => void) { + if (!this.listeners.has(eventName)) { + this.listeners.set(eventName, new Set()); + } + this.listeners.get(eventName)!.add(callback); + } + + removeListener(eventName: string, callback: (e: any) => void) { + this.removeEventListener(eventName, callback); + } + + removeEventListener(eventName: string, callback: (e: any) => void) { + const listeners = this.listeners.get(eventName); + if (listeners) { + listeners.delete(callback); + } + } + + emit(eventName: string, data: any = {}) { + if (eventName === 'message') { + this.onmessage(data); + } else if (eventName === 'close') { + this.onclose(data); + } else if (eventName === 'error') { + this.onerror(data); + } else if (eventName === 'open') { + this.onopen(data); + } + const listeners = this.listeners.get(eventName); + if (listeners) { + for (const listener of listeners) { + listener(data); + } + } + } + + // Default event handlers + onclose(data: any) {} + onerror(data: any) {} + onmessage(data: any) {} + onopen(data: any) {} + + /** + * Emscripten calls this when WASM writes to the socket + */ + send(data: ArrayBuffer) { + if ( + this.readyState === this.CLOSING || + this.readyState === this.CLOSED + ) { + return; + } + this.clientUpstreamWriter.write(new Uint8Array(data)); + } + + close() { + // Send empty data chunk before closing (PHP.wasm workaround) + this.emit('message', { data: new Uint8Array(0) }); + + this.readyState = this.CLOSING; + this.emit('close'); + this.readyState = this.CLOSED; + } +} diff --git a/packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.spec.ts b/packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.spec.ts deleted file mode 100644 index 6b43c1b02c..0000000000 --- a/packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.spec.ts +++ /dev/null @@ -1,561 +0,0 @@ -import { - TCPOverFetchWebsocket, - RawBytesFetch, -} from './tcp-over-fetch-websocket'; -import express from 'express'; -import type http from 'http'; -import type { AddressInfo } from 'net'; -import zlib from 'zlib'; - -const pygmalion = `PREFACE TO PYGMALION. - -A Professor of Phonetics. - -As will be seen later on, Pygmalion needs, not a preface, but a sequel, -which I have supplied in its due place. The English have no respect for -their language, and will not teach their children to speak it. They -spell it so abominably that no man can teach himself what it sounds -like. It is impossible for an Englishman to open his mouth without -making some other Englishman hate or despise him. German and Spanish -are accessible to foreigners: English is not accessible even to -Englishmen. The reformer England needs today is an energetic phonetic -enthusiast: that is why I have made such a one the hero of a popular -play. There have been heroes of that kind crying in the wilderness for -many years past. When I became interested in the subject towards the -end of the eighteen-seventies, Melville Bell was dead; but Alexander J. -Ellis was still a living patriarch, with an impressive head always -covered by a velvet skull cap, for which he would apologize to public -meetings in a very courtly manner. He and Tito Pagliardini, another -phonetic veteran, were men whom it was impossible to dislike. Henry -Sweet, then a young man, lacked their sweetness of character: he was -about as conciliatory to conventional mortals as Ibsen or Samuel -Butler. His great ability as a phonetician (he was, I think, the best -of them all at his job) would have entitled him to high official -recognition, and perhaps enabled him to popularize his subject, but for -his Satanic contempt for all academic dignitaries and persons in -general who thought more of Greek than of phonetics. Once, in the days -when the Imperial Institute rose in South Kensington, and Joseph -Chamberlain was booming the Empire, I induced the editor of a leading -monthly review to commission an article from Sweet on the imperial -importance of his subject. When it arrived, it contained nothing but a -savagely derisive attack on a professor of language and literature -whose chair Sweet regarded as proper to a phonetic expert only. The -article, being libelous, had to be returned as impossible; and I had to -renounce my dream of dragging its author into the limelight. When I met -him afterwards, for the first time for many years, I found to my -astonishment that he, who had been a quite tolerably presentable young -man, had actually managed by sheer scorn to alter his personal -appearance until he had become a sort of walking repudiation of Oxford -and all its traditions. It must have been largely in his own despite -that he was squeezed into something called a Readership of phonetics -there. The future of phonetics rests probably with his pupils, who all -swore by him; but nothing could bring the man himself into any sort of -compliance with the university, to which he nevertheless clung by -divine right in an intensely Oxonian way. I daresay his papers, if he -has left any, include some satires that may be published without too -destructive results fifty years hence. He was, I believe, not in the -least an ill-natured man: very much the opposite, I should say; but he -would not suffer fools gladly.`; - -describe('TCPOverFetchWebsocket', () => { - let server: http.Server; - let host: string; - let port: number; - - beforeAll(async () => { - const app = express(); - server = app.listen(0); - const address = server.address() as AddressInfo; - host = `127.0.0.1`; - port = address.port; - app.get('/simple', (req, res) => { - res.send('Hello, World!'); - }); - - app.get('/slow', (req, res) => { - setTimeout(() => { - res.send('Slow response'); - }, 1000); - }); - - app.get('/stream', (req, res) => { - res.flushHeaders(); - res.write('Part 1'); - setTimeout(() => { - res.write('Part 2'); - res.end(); - }, 1500); - }); - - app.get('/headers', (req, res) => { - res.set('X-Custom-Header', 'TestValue'); - res.send('OK'); - }); - - app.get('/gzipped', (req, res) => { - const gzip = zlib.createGzip(); - gzip.write(pygmalion); - gzip.end(); - - const gzippedChunks: Uint8Array[] = []; - gzip.on('data', (chunk) => { - gzippedChunks.push(chunk); - }); - gzip.on('end', () => { - const length = gzippedChunks.reduce( - (acc, chunk) => acc + chunk.length, - 0 - ); - res.setHeader('Content-Encoding', 'gzip'); - res.setHeader('Content-Length', length.toString()); - for (const chunk of gzippedChunks) { - res.write(chunk); - } - res.end(); - }); - }); - - app.post('/echo', (req, res) => { - // Set appropriate headers - res.setHeader( - 'Content-Type', - req.headers['content-type'] || 'text/plain' - ); - res.setHeader('Transfer-Encoding', 'chunked'); - // Create readable stream from request body - const stream = req; - - // Pipe the input stream directly to the response - stream.pipe(res); - - // Handle errors - stream.on('error', (error) => { - console.error('Stream error:', error); - res.status(500).end(); - }); - }); - - app.get('/error', (req, res) => { - res.status(500).send('Internal Server Error'); - }); - }); - - afterAll(() => { - server.close(); - }); - - it('should handle a simple HTTP request', async () => { - const socket = await makeRequest({ - host, - port, - path: '/simple', - outputType: 'stream', - }); - const response = await bufferResponse(socket); - expect(response).toContain('HTTP/1.1 200 OK'); - expect(response).toContain('Hello, World!'); - }); - - it('should handle a slow response', async () => { - const socket = await makeRequest({ - host, - port, - path: '/slow', - outputType: 'stream', - }); - const response = await bufferResponse(socket); - expect(response).toContain('HTTP/1.1 200 OK'); - expect(response).toContain('Slow response'); - }); - - it('should handle a streaming response', async () => { - const socket = await makeRequest({ - host, - port, - path: '/stream', - outputType: 'stream', - }); - const response = await bufferResponse(socket); - expect(response).toContain('HTTP/1.1 200 OK'); - expect(response).toContain('Part 1'); - expect(response).toContain('Part 2'); - }); - - it('should handle an error response', async () => { - const socket = await makeRequest({ - host, - port, - path: '/error', - outputType: 'stream', - }); - const response = await bufferResponse(socket); - expect(response).toContain('HTTP/1.1 500 Internal Server Error'); - expect(response).toContain('Internal Server Error'); - }); - - it('should handle a request with a large payload', async () => { - const largePayload = 'X'.repeat(1024 * 1024); // 1MB of data - const socket = await makeRequest({ - host, - port, - path: '/echo', - method: 'POST', - body: largePayload, - outputType: 'stream', - }); - const response = await bufferResponse(socket); - expect(response).toContain('HTTP/1.1 200 OK'); - expect(response.length).toBeGreaterThanOrEqual(largePayload.length); - }); - - it('should handle a basic POST request', async () => { - const socket = await makeRequest({ - host, - port, - path: '/echo', - method: 'POST', - body: 'Hello, World!', - outputType: 'stream', - }); - const response = await bufferResponse(socket); - expect(response).toContain('HTTP/1.1 200 OK'); - expect(response).toContain('Hello, World!'); - }); - - it('should handle a request with paused streaming', async () => { - const socket = new TCPOverFetchWebsocket( - `ws://playground.internal/?host=${host}&port=${port}`, - [], - { outputType: 'stream' } - ); - const headers = `POST /echo HTTP/1.1\r\nHost: ${host}:${port}\r\nContent-Length: 18\r\n\r\n`; - socket.send(new TextEncoder().encode(headers)); - socket.send(new TextEncoder().encode(`Part 1`)); - - const responseStream = socket.clientDownstream.readable.pipeThrough( - new TextDecoderStream() - ); - - const reader = responseStream.getReader(); - - const responseHeaders = await reader.read(); - expect(responseHeaders.value).toContain('HTTP/1.1 200 OK'); - expect(responseHeaders.done).toBe(false); - - await reader.read(); // Skip the chunk length (Transfer-Encoding: chunked) - const responseBodyPart1 = await reader.read(); - await reader.read(); // Skip the chunk delimiter - expect(responseBodyPart1.value).toContain('Part 1'); - expect(responseBodyPart1.done).toBe(false); - - // Wait for a bit, ensure the connection remains open - await new Promise((resolve) => setTimeout(resolve, 500)); - - socket.send(new TextEncoder().encode(`Part 2`)); - await reader.read(); // Skip the chunk length - const responseBodyPart2 = await reader.read(); - await reader.read(); // Skip the chunk delimiter - expect(responseBodyPart2.value).toContain('Part 2'); - expect(responseBodyPart2.done).toBe(false); - - socket.send(new TextEncoder().encode(`Part 3`)); - await reader.read(); // Skip the chunk length - const responseBodyPart3 = await reader.read(); - await reader.read(); // Skip the chunk delimiter - expect(responseBodyPart3.done).toBe(false); - expect(responseBodyPart3.value).toContain('Part 3'); - - await reader.read(); // Skip the final empty chunk - const responseBodyPart4 = await reader.read(); - expect(responseBodyPart4.done).toBe(true); - }); - - it('should handle a non-existent endpoint', async () => { - const socket = await makeRequest({ - host, - port, - path: '/non-existent', - outputType: 'stream', - }); - const response = await bufferResponse(socket); - expect(response).toContain('HTTP/1.1 404 Not Found'); - }); - - it('should handle a malformed request', async () => { - const socket = new TCPOverFetchWebsocket( - `ws://playground.internal/?host=${host}&port=${port}`, - [] - ); - - const promise = new Promise((resolve) => { - socket.on('error', (error) => { - resolve(error); - }); - }); - socket.send(new TextEncoder().encode('INVALID REQUEST\r\n\r\n')); - expect(promise).resolves.toEqual(new Error('Unsupported protocol')); - }); - - it('should handle connection to a non-existent server', async () => { - const badHost = 'non-existent-server.local'; - const badPort = 1; - const socket = new TCPOverFetchWebsocket( - `ws://playground.internal/?host=${badHost}&port=${badPort}`, - [] - ); - const promise = new Promise((resolve) => { - socket.on('error', (error) => { - resolve(error); - }); - }); - const request = `GET /non-existent HTTP/1.1\r\nHost: ${badHost}:${badPort}\r\n\r\n`; - socket.send(new TextEncoder().encode(request)); - - await expect(promise).resolves.toEqual(new Error('ECONNREFUSED')); - }); - - it('should handle a request with custom headers', async () => { - const socket = await makeRequest({ - host, - port, - path: '/headers', - method: 'GET', - additionalHeaders: 'X-Custom-Header: TestValue\r\n', - outputType: 'stream', - }); - const response = await bufferResponse(socket); - expect(response).toContain('HTTP/1.1 200 OK'); - expect(response).toContain('x-custom-header: TestValue'); - }); - - it('should handle a gzipped response', async () => { - const socket = await makeRequest({ - host, - port, - path: '/gzipped', - outputType: 'stream', - }); - const response = await bufferResponse(socket); - expect(response).toContain('HTTP/1.1 200 OK'); - // Confirm we're using transfer-encoding: chunked - expect(response).not.toContain('content-length'); - expect(response).toContain('transfer-encoding: chunked'); - - // Confirm the response is not truncated - expect(response.length).toBeGreaterThan(pygmalion.length); - expect(response).toContain(pygmalion.slice(-100)); - }); -}); - -describe('RawBytesFetch', () => { - it('parseHttpRequest should handle an transfer-encoding: chunked POST requests', async () => { - const encodedBodyBytes = 'abcde'; - const encodedChunkedBodyBytes = `${encodedBodyBytes.length}\r\n${encodedBodyBytes}\r\n0\r\n\r\n`; - const requestBytes = `POST /echo HTTP/1.1\r\nHost: playground.internal\r\ntransfer-encoding: chunked\r\n\r\n${encodedChunkedBodyBytes}`; - const request = await RawBytesFetch.parseHttpRequest( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(requestBytes)); - controller.close(); - }, - }), - 'playground.internal', - 'http' - ); - const parsedRequestBody = await request.body?.getReader().read(); - const decodedRequestBody = new TextDecoder().decode( - parsedRequestBody?.value - ); - expect(decodedRequestBody).toEqual(encodedBodyBytes); - }); - - it('parseHttpRequest should handle a path and query string', async () => { - const requestBytes = `GET /core/version-check/1.7/?channel=beta HTTP/1.1\r\nHost: playground.internal\r\n\r\n`; - const request = await RawBytesFetch.parseHttpRequest( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(requestBytes)); - controller.close(); - }, - }), - 'playground.internal', - 'http' - ); - expect(request.url).toEqual( - 'http://playground.internal/core/version-check/1.7/?channel=beta' - ); - }); - - it('parseHttpRequest should handle a simple path without query string', async () => { - const requestBytes = `GET /api/users HTTP/1.1\r\nHost: example.com\r\n\r\n`; - const request = await RawBytesFetch.parseHttpRequest( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(requestBytes)); - controller.close(); - }, - }), - 'example.com', - 'http' - ); - expect(request.url).toEqual('http://example.com/api/users'); - }); - - it('parseHttpRequest should handle root path', async () => { - const requestBytes = `GET / HTTP/1.1\r\nHost: example.com\r\n\r\n`; - const request = await RawBytesFetch.parseHttpRequest( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(requestBytes)); - controller.close(); - }, - }), - 'example.com', - 'https' - ); - expect(request.url).toEqual('https://example.com/'); - }); - - it('parseHttpRequest should handle URL-encoded characters in path', async () => { - const requestBytes = `GET /search/hello%20world HTTP/1.1\r\nHost: example.com\r\n\r\n`; - const request = await RawBytesFetch.parseHttpRequest( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(requestBytes)); - controller.close(); - }, - }), - 'example.com', - 'http' - ); - expect(request.url).toEqual('http://example.com/search/hello%20world'); - }); - - it('parseHttpRequest should handle URL-encoded characters in query string', async () => { - const requestBytes = `GET /search?q=hello+world&filter=a%26b HTTP/1.1\r\nHost: example.com\r\n\r\n`; - const request = await RawBytesFetch.parseHttpRequest( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(requestBytes)); - controller.close(); - }, - }), - 'example.com', - 'http' - ); - expect(request.url).toEqual( - 'http://example.com/search?q=hello+world&filter=a%26b' - ); - }); - - it('parseHttpRequest should handle empty query parameter values', async () => { - const requestBytes = `GET /api?key1=&key2=value2 HTTP/1.1\r\nHost: example.com\r\n\r\n`; - const request = await RawBytesFetch.parseHttpRequest( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(requestBytes)); - controller.close(); - }, - }), - 'example.com', - 'http' - ); - expect(request.url).toEqual('http://example.com/api?key1=&key2=value2'); - }); - - it('parseHttpRequest should handle path with hash fragment', async () => { - // Note: Hash fragments are typically not sent in HTTP requests, - // but if they are, the URL constructor should handle them - const requestBytes = `GET /page#section HTTP/1.1\r\nHost: example.com\r\n\r\n`; - const request = await RawBytesFetch.parseHttpRequest( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(requestBytes)); - controller.close(); - }, - }), - 'example.com', - 'http' - ); - expect(request.url).toEqual('http://example.com/page#section'); - }); - - it('parseHttpRequest should handle path with query and hash', async () => { - const requestBytes = `GET /page?param=value#section HTTP/1.1\r\nHost: example.com\r\n\r\n`; - const request = await RawBytesFetch.parseHttpRequest( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(requestBytes)); - controller.close(); - }, - }), - 'example.com', - 'http' - ); - expect(request.url).toEqual( - 'http://example.com/page?param=value#section' - ); - }); - - it('parseHttpRequest should preserve Host header over default host', async () => { - const requestBytes = `GET /api HTTP/1.1\r\nHost: custom.host.com\r\n\r\n`; - const request = await RawBytesFetch.parseHttpRequest( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(requestBytes)); - controller.close(); - }, - }), - 'default.host.com', // Different from Host header - 'https' - ); - // Should use the Host header, not the default host parameter - expect(request.url).toEqual('https://custom.host.com/api'); - }); -}); - -type MakeRequestOptions = { - host: string; - port: number; - path: string; - method?: string; - body?: string; - additionalHeaders?: string; - outputType?: 'messages' | 'stream'; -}; -async function makeRequest({ - host, - port, - path, - method = 'GET', - body = '', - additionalHeaders = '', - outputType = 'messages', -}: MakeRequestOptions) { - const socket = new TCPOverFetchWebsocket( - `ws://playground.internal/?host=${host}&port=${port}`, - [], - { outputType } - ); - const request = `${method} ${path} HTTP/1.1\r\nHost: ${host}:${port}\r\n${additionalHeaders}${ - body ? `Content-Length: ${body.length}\r\n` : '' - }\r\n${body}`; - socket.send(new TextEncoder().encode(request)); - return socket; -} - -async function bufferResponse(socket: TCPOverFetchWebsocket): Promise { - return new Promise((resolve) => { - let response = ''; - socket.clientDownstream.readable.pipeTo( - new WritableStream({ - write(chunk) { - response += new TextDecoder().decode(chunk); - }, - close() { - resolve(response); - }, - }) - ); - }); -} diff --git a/packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.ts b/packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.ts deleted file mode 100644 index 918edf65cd..0000000000 --- a/packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.ts +++ /dev/null @@ -1,740 +0,0 @@ -/** - * This is the secret sauce that enables HTTPS requests from PHP - * via file_get_contents(), curl, and all other networking mechanisms. - * - * This is effectively a MITM attack on the PHP instance. The code - * in this module decrypts the outbound traffic, runs the request - * using fetch(), and then provides an encrypted response. From - * PHP's perspective, it is indistinguishable from a direct connection - * to the right server. - * - * ## How does it work? - * - * Emscripten can be configured to stream all network traffic through a - * WebSocket. `@php-wasm/node` and `wp-now` use that to access the internet - * via a local WebSocket->TCP proxy, but the in-browser version of WordPress - * Playground exposes no such proxy. - * - * This module implements a "fake" WebSocket class. Instead of starting a `ws://` - * connection, it translates the raw HTTP/HTTPS bytes into a `fetch()` call. - * - * In case of HTTP, the raw request bytes are parsed into a Request object with - * a body stream and passes it to `fetch()`. Then, as the response status, headers, - * and the body arrive, they're stream-encoded as raw response bytes and exposed as - * incoming WebSocket data. - * - * In case of HTTPS, we the raw bytes are first piped through a custom TCPConnection - * class as follows: - * - * 1. We generate a self-signed CA certificate and tell PHP to trust it using the - * `openssl.cafile` PHP.ini option - * 2. We create a domain-specific child certificate and sign it with the CA private key. - * 3. We start accepting raw encrypted bytes, process them as structured TLS records, - * and perform the TLS handshake. - * 4. Encrypted tunnel is established - * * TLSConnection decrypts the encrypted outbound data sent by PHP - * * TLSConnection encrypts the unencrypted inbound data fed back to PHP - * - * From there, the plaintext data is treated by the same HTTP<->fetch() machinery as - * described in the previous paragraph. - */ -import { TLS_1_2_Connection } from './tls/1_2/connection'; -import type { GeneratedCertificate } from './tls/certificates'; -import { generateCertificate } from './tls/certificates'; -import { ContentTypes } from './tls/1_2/types'; -import { fetchWithCorsProxy } from './fetch-with-cors-proxy'; -import { ChunkedDecoderStream } from './chunked-decoder'; -import type { EmscriptenOptions } from '@php-wasm/universal'; -import { concatUint8Arrays } from '@php-wasm/util'; - -export type TCPOverFetchOptions = { - CAroot: GeneratedCertificate; - corsProxyUrl?: string; -}; - -/** - * Sets up a WebSocket that analyzes the received bytes and, if they look like - * TLS or HTTP, handles the network transmission using fetch(). - */ -export const tcpOverFetchWebsocket = ( - emOptions: EmscriptenOptions, - tcpOptions: TCPOverFetchOptions -) => { - return { - ...emOptions, - websocket: { - url: (_: any, host: string, port: string) => { - const query = new URLSearchParams({ - host, - port, - }).toString(); - return `ws://playground.internal/?${query}`; - }, - subprotocol: 'binary', - decorator: () => { - return class extends TCPOverFetchWebsocket { - constructor(url: string, wsOptions: string[]) { - super(url, wsOptions, { - CAroot: tcpOptions.CAroot, - corsProxyUrl: tcpOptions.corsProxyUrl, - }); - } - }; - }, - }, - }; -}; - -export interface TCPOverFetchWebsocketOptions { - CAroot?: GeneratedCertificate; - /** - * If true, the WebSocket will emit 'message' events with the received bytes - * and the 'close' event when the WebSocket is closed. - * - * If false, the consumer will be responsible for reading the bytes from the - * clientDownstream stream and tracking the closure of that stream. - */ - outputType?: 'messages' | 'stream'; - corsProxyUrl?: string; -} - -export class TCPOverFetchWebsocket { - CONNECTING = 0; - OPEN = 1; - CLOSING = 2; - CLOSED = 3; - readyState = this.CONNECTING; - binaryType = 'blob'; - bufferedAmount = 0; - extensions = ''; - protocol = 'ws'; - host = ''; - port = 0; - listeners = new Map(); - CAroot?: GeneratedCertificate; - corsProxyUrl?: string; - - clientUpstream = new TransformStream(); - clientUpstreamWriter = this.clientUpstream.writable.getWriter(); - clientDownstream = new TransformStream(); - fetchInitiated = false; - bufferedBytesFromClient: Uint8Array = new Uint8Array(0); - - url: string; - options: string[]; - - constructor( - url: string, - options: string[], - { - CAroot, - corsProxyUrl, - outputType = 'messages', - }: TCPOverFetchWebsocketOptions = {} - ) { - this.url = url; - this.options = options; - - const wsUrl = new URL(url); - this.host = wsUrl.searchParams.get('host')!; - this.port = parseInt(wsUrl.searchParams.get('port')!, 10); - this.binaryType = 'arraybuffer'; - - this.corsProxyUrl = corsProxyUrl; - this.CAroot = CAroot; - if (outputType === 'messages') { - this.clientDownstream.readable - .pipeTo( - new WritableStream({ - write: (chunk) => { - /** - * Emscripten expects the message event to be emitted - * so let's emit it. - */ - this.emit('message', { data: chunk }); - }, - abort: () => { - // We don't know what went wrong and the browser - // won't tell us much either, so let's just pretend - // the server is unreachable. - this.emit('error', new Error('ECONNREFUSED')); - this.close(); - }, - close: () => { - this.close(); - }, - }) - ) - .catch(() => { - // Ignore failures arising from stream errors. - // This class communicates problems to the caller - // via the 'error' event. - }); - } - this.readyState = this.OPEN; - this.emit('open'); - } - - on(eventName: string, callback: (e: any) => void) { - this.addEventListener(eventName, callback); - } - - once(eventName: string, callback: (e: any) => void) { - const wrapper = (e: any) => { - callback(e); - this.removeEventListener(eventName, wrapper); - }; - this.addEventListener(eventName, wrapper); - } - - addEventListener(eventName: string, callback: (e: any) => void) { - if (!this.listeners.has(eventName)) { - this.listeners.set(eventName, new Set()); - } - this.listeners.get(eventName).add(callback); - } - - removeListener(eventName: string, callback: (e: any) => void) { - this.removeEventListener(eventName, callback); - } - - removeEventListener(eventName: string, callback: (e: any) => void) { - const listeners = this.listeners.get(eventName); - if (listeners) { - listeners.delete(callback); - } - } - - emit(eventName: string, data: any = {}) { - if (eventName === 'message') { - this.onmessage(data); - } else if (eventName === 'close') { - this.onclose(data); - } else if (eventName === 'error') { - this.onerror(data); - } else if (eventName === 'open') { - this.onopen(data); - } - const listeners = this.listeners.get(eventName); - if (listeners) { - for (const listener of listeners) { - listener(data); - } - } - } - - // Default event handlers that can be overridden by the user - // eslint-disable-next-line @typescript-eslint/no-unused-vars - onclose(data: any) {} - // eslint-disable-next-line @typescript-eslint/no-unused-vars - onerror(data: any) {} - // eslint-disable-next-line @typescript-eslint/no-unused-vars - onmessage(data: any) {} - // eslint-disable-next-line @typescript-eslint/no-unused-vars - onopen(data: any) {} - - /** - * Emscripten calls this method whenever the WASM module - * writes bytes to the TCP socket. - */ - send(data: ArrayBuffer) { - if ( - this.readyState === this.CLOSING || - this.readyState === this.CLOSED - ) { - return; - } - - this.clientUpstreamWriter.write(new Uint8Array(data)); - - if (this.fetchInitiated) { - return; - } - - // Guess the protocol type first so we can learn - // what to do with the incoming bytes. - this.bufferedBytesFromClient = concatUint8Arrays([ - this.bufferedBytesFromClient, - new Uint8Array(data), - ]); - switch (guessProtocol(this.port, this.bufferedBytesFromClient)) { - case false: - // Not enough data to classify the protocol, - // let's wait for more. - return; - case 'other': - this.emit('error', new Error('Unsupported protocol')); - this.close(); - break; - case 'tls': - this.fetchOverTLS(); - this.fetchInitiated = true; - break; - case 'http': - this.fetchOverHTTP(); - this.fetchInitiated = true; - break; - } - } - - async fetchOverTLS() { - if (!this.CAroot) { - throw new Error( - 'TLS protocol is only supported when the TCPOverFetchWebsocket is ' + - 'instantiated with a CAroot' - ); - } - const siteCert = await generateCertificate( - { - subject: { - commonName: this.host, - organizationName: this.host, - countryName: 'US', - }, - issuer: this.CAroot.tbsDescription.subject, - }, - this.CAroot.keyPair - ); - - const tlsConnection = new TLS_1_2_Connection(); - - // Connect this WebSocket's client end to the TLS connection. - // Forward the encrypted bytes from the WebSocket to the TLS connection. - this.clientUpstream.readable - .pipeTo(tlsConnection.clientEnd.upstream.writable) - .catch(() => { - // Ignore failures arising from pipeTo() errors. - // The caller will observe the clientEnd.downstream.writable stream - // erroring out. - }); - - // Forward the decrypted bytes from the TLS connection to this WebSocket. - tlsConnection.clientEnd.downstream.readable - .pipeTo(this.clientDownstream.writable) - .catch(() => { - // Ignore failures arising from pipeTo() errors. - // The caller will observe the clientEnd.downstream.writable stream - // erroring out. - }); - - // Perform the TLS handshake - await tlsConnection.TLSHandshake(siteCert.keyPair.privateKey, [ - siteCert.certificate, - this.CAroot.certificate, - ]); - - // Connect the TLS server end to the fetch() request - const request = await RawBytesFetch.parseHttpRequest( - tlsConnection.serverEnd.upstream.readable, - this.host, - 'https' - ); - try { - await RawBytesFetch.fetchRawResponseBytes( - request, - this.corsProxyUrl - ).pipeTo(tlsConnection.serverEnd.downstream.writable); - } catch { - // Ignore errors from fetch() - // They are handled in the constructor - // via this.clientDownstream.readable.pipeTo() - // and if we let the failures they would be logged - // as an unhandled promise rejection. - } - } - - async fetchOverHTTP() { - // Connect this WebSocket's client end to the fetch() request - const request = await RawBytesFetch.parseHttpRequest( - this.clientUpstream.readable, - this.host, - 'http' - ); - try { - await RawBytesFetch.fetchRawResponseBytes( - request, - this.corsProxyUrl - ).pipeTo(this.clientDownstream.writable); - } catch { - // Ignore errors from fetch() - // They are handled in the constructor - // via this.clientDownstream.readable.pipeTo() - // and if we let the failures they would be logged - // as an unhandled promise rejection. - } - } - - close() { - /** - * Workaround a PHP.wasm issue – if the WebSocket is - * closed asynchronously after the last chunk is received, - * the PHP.wasm runtime enters an infinite polling loop. - * - * The root cause of the problem is unclear at the time - * of writing this comment. There's a chance it's a regular - * POSIX behavior. - * - * Either way, sending an empty data chunk before closing - * the WebSocket resolves the problem. - */ - this.emit('message', { data: new Uint8Array(0) }); - - this.readyState = this.CLOSING; - this.emit('close'); - this.readyState = this.CLOSED; - } -} - -const HTTP_METHODS = [ - 'GET', - 'POST', - 'HEAD', - 'PATCH', - 'OPTIONS', - 'DELETE', - 'PUT', - 'TRACE', -]; - -function guessProtocol(port: number, data: Uint8Array) { - if (data.length < 8) { - // Not enough data to classify the protocol, let's wait for more. - return false; - } - - // Assume TLS if we're on the usual HTTPS port and the - // first three bytes look like a TLS handshake record. - const looksLikeTls = - port === 443 && - data[0] === ContentTypes.Handshake && - // TLS versions between 1.0 and 1.2 - data[1] === 0x03 && - data[2] >= 0x01 && - data[2] <= 0x03; - if (looksLikeTls) { - return 'tls'; - } - - // Assume HTTP if we're on the usual HTTP port and the - // first starts with an HTTP method and a space. - const decodedFirstLine = new TextDecoder('latin1', { - fatal: true, - }).decode(data); - const looksLikeHttp = HTTP_METHODS.some((method) => - decodedFirstLine.startsWith(method + ' ') - ); - if (looksLikeHttp) { - return 'http'; - } - - return 'other'; -} - -export class RawBytesFetch { - /** - * Streams a HTTP response including the status line and headers. - */ - static fetchRawResponseBytes(request: Request, corsProxyUrl?: string) { - // This initially used a TransformStream and piped the response - // body to the writable side of the TransformStream. - // - // Unfortunately, the first response body chunk was not correctly - // enqueued so we switched to a customReadableStream. - return new ReadableStream({ - async start(controller) { - let response: Response; - try { - response = await fetchWithCorsProxy( - request, - undefined, - corsProxyUrl - ); - } catch (error) { - /** - * Pretend we've got a 400 Bad Request response whenever - * the fetch() call fails. - * - * Just propagating an error and closing a WebSocket does - * not make PHP aware the socket closed abruptly. This means - * the AsyncHttp\Client will keep polling the socket indefinitely - * until the request times out. This isn't perfect, as we want - * to close the socket as soon as possible to avoid, e.g., 10 seconds - * of unnecessary waitin for the timeout - * - * The root cause is unknown and likely related to the low-level - * implementation of polling file descriptors. The following - * workaround is far from ideal, but it must suffice until we - * have a platform-level resolution. - */ - controller.enqueue( - new TextEncoder().encode( - 'HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n' - ) - ); - controller.error(error); - return; - } - - controller.enqueue(RawBytesFetch.headersAsBytes(response)); - const reader = response.body?.getReader(); - if (!reader) { - controller.close(); - return; - } - - const encoder = new TextEncoder(); - while (true) { - const { done, value } = await reader.read(); - if (value) { - /** - * Pass the body stream assuming the response uses - * chunked transfer encoding. - * - * See `headersAsBytes()` for the details. - */ - controller.enqueue( - encoder.encode(`${value.length.toString(16)}\r\n`) - ); - controller.enqueue(value); - controller.enqueue(encoder.encode('\r\n')); - } - if (done) { - controller.enqueue(encoder.encode('0\r\n\r\n')); - controller.close(); - return; - } - } - }, - }); - } - - private static headersAsBytes(response: Response) { - const status = `HTTP/1.1 ${response.status} ${response.statusText}`; - - const headersObject: Record = {}; - response.headers.forEach((value, name) => { - headersObject[name.toLowerCase()] = value; - }); - - /** - * Best-effort attempt to provide the correct content-length - * to the PHP-side request handler. - * - * Web servers often respond with a combination of Content-Length - * and Content-Encoding. For example, a 16kb text file may be compressed - * to 4kb with gzip and served with a Content-Encoding of `gzip` and a - * Content-Length of 4KB. - * - * The web browser, however, exposes neither the Content-Encoding header - * nor the gzipped data stream. All we have access to is the original - * Content-Length value of the gzipped file and a decompressed data stream. - * - * If we just pass that along to the PHP-side request handler, it would - * see a 16KB body stream with a Content-Length of 4KB. It would then - * truncate the body stream at 4KB and discard the rest of the data. - * - * This is not what we want. - * - * To correct that behavior, we're stripping the Content-Length entirely. - * We do that for every single response because we don't have any way - * of knowing whether any Content-Encoding was used. Furthermore, we can't - * just calculate the correct Content-Length value without consuming the - * entire content stream – and we want to pass each data chunk to PHP - * as we receive it. - * - * Instead of a fixed Content-Length, we'll use Content-Encoding: Chunked, - * and then provide a per-chunk Content-Length. See fetchRawResponseBytes() - * for the details. - */ - delete headersObject['content-length']; - headersObject['transfer-encoding'] = 'chunked'; - - const headers: string[] = []; - for (const [name, value] of Object.entries(headersObject)) { - headers.push(`${name}: ${value}`); - } - const string = [status, ...headers].join('\r\n') + '\r\n\r\n'; - return new TextEncoder().encode(string); - } - - /** - * Parses a raw, streamed HTTP request into a Request object - * with known headers and a readable body stream. - */ - static async parseHttpRequest( - requestBytesStream: ReadableStream, - host: string, - protocol: 'http' | 'https' - ) { - let inputBuffer: Uint8Array = new Uint8Array(0); - - let requestDataExhausted = false; - let headersEndIndex = -1; - const requestBytesReader = requestBytesStream.getReader(); - while (headersEndIndex === -1) { - const { done, value } = await requestBytesReader.read(); - if (done) { - requestDataExhausted = true; - break; - } - inputBuffer = concatUint8Arrays([inputBuffer, value]); - // Find the end of the headers (\r\n\r\n). This is - // not optimal as we may end up scanning the same - // bytes multiple times, but the overhead is negligible - // and the code is much simpler this way. - headersEndIndex = findSequenceInBuffer( - inputBuffer, - new Uint8Array([0x0d, 0x0a, 0x0d, 0x0a]) - ); - } - requestBytesReader.releaseLock(); - - const headersBuffer = inputBuffer.slice(0, headersEndIndex); - const parsedHeaders = RawBytesFetch.parseRequestHeaders(headersBuffer); - const terminationMode = - parsedHeaders.headers.get('Transfer-Encoding') !== null - ? 'chunked' - : 'content-length'; - const contentLength = - parsedHeaders.headers.get('Content-Length') !== null - ? parseInt(parsedHeaders.headers.get('Content-Length')!, 10) - : undefined; - - const bodyBytes = inputBuffer.slice( - headersEndIndex + 4 /* Skip \r\n\r\n */ - ); - let outboundBodyStream: ReadableStream | undefined; - if (parsedHeaders.method !== 'GET') { - const requestBytesReader = requestBytesStream.getReader(); - let seenBytes = bodyBytes.length; - let last5Bytes = bodyBytes.slice(-6); - const emptyChunk = new TextEncoder().encode('0\r\n\r\n'); - outboundBodyStream = new ReadableStream({ - async start(controller) { - if (bodyBytes.length > 0) { - controller.enqueue(bodyBytes); - } - if (requestDataExhausted) { - controller.close(); - } - }, - async pull(controller) { - const { done, value } = await requestBytesReader.read(); - seenBytes += value?.length || 0; - if (value) { - controller.enqueue(value); - last5Bytes = concatUint8Arrays([ - last5Bytes, - value || new Uint8Array(), - ]).slice(-5); - } - const shouldTerminate = - done || - (terminationMode === 'content-length' && - contentLength !== undefined && - seenBytes >= contentLength) || - (terminationMode === 'chunked' && - last5Bytes.every( - (byte, index) => byte === emptyChunk[index] - )); - if (shouldTerminate) { - controller.close(); - return; - } - }, - }); - - if (terminationMode === 'chunked') { - // Strip chunked transfer encoding from the request body stream. - // PHP may encode the request body with chunked transfer encoding, - // giving us a stream of chunks with a size line ending in \r\n, - // a body chunk, and a chunk trailer ending in \r\n. - // - // We must not include the chunk headers and trailers in the - // transmitted data. fetch() trusts us to provide the body stream - // in its original form and will pass treat the chunked encoding - // artifacts as a part of the data to be transmitted to the server. - // This, in turn, means sending over a corrupted request body. - // - // Therefore, let's just strip any chunked encoding-related bytes. - outboundBodyStream = outboundBodyStream.pipeThrough( - new ChunkedDecoderStream() - ); - } - } - - /** - * Prefer the Host header to the host from the URL used in a PHP - * function call. - * - * There are tradeoffs involved in this decision. - * - * The URL from the PHP function call is the actual network location - * the caller intended to reach, e.g. `http://192.168.1.100` or - * `http://127.0.0.1`. - * - * The Host header is what the developer wanted to provide to the - * web server, e.g. `wordpress.org` or `localhost`. - * - * The Host header is not a reliable indication of the target URL. - * However, `fetch()` does not support Host spoofing. Furthermore, - * a webserver running on 127.0.0.1 may only respond correctly - * when it is provided with the Host header `localhost`. - * - * Prefering the Host header over the host from the PHP function call - * is not perfect, but it seems like the lesser of two evils. - */ - const hostname = parsedHeaders.headers.get('Host') ?? host; - const url = new URL(parsedHeaders.path, protocol + '://' + hostname); - - return new Request(url.toString(), { - method: parsedHeaders.method, - headers: parsedHeaders.headers, - body: outboundBodyStream, - // In Node.js, duplex: 'half' is required when - // the body stream is provided. - // @ts-expect-error - duplex: 'half', - }); - } - - private static parseRequestHeaders(httpRequestBytes: Uint8Array) { - const httpRequest = new TextDecoder().decode(httpRequestBytes); - const statusLineMaybe = httpRequest.split('\n')[0]; - const [method, path] = statusLineMaybe.split(' '); - - const headers = new Headers(); - for (const line of httpRequest.split('\r\n').slice(1)) { - if (line === '') { - break; - } - const [name, value] = line.split(': '); - headers.set(name, value); - } - - return { method, path, headers }; - } -} - -function findSequenceInBuffer( - buffer: Uint8Array, - sequence: Uint8Array -): number { - const bufferLength = buffer.length; - const sequenceLength = sequence.length; - const lastPossibleIndex = bufferLength - sequenceLength; - - for (let i = 0; i <= lastPossibleIndex; i++) { - let found = true; - for (let j = 0; j < sequenceLength; j++) { - if (buffer[i + j] !== sequence[j]) { - found = false; - break; - } - } - if (found) { - return i; - } - } - return -1; -} diff --git a/packages/playground/remote/src/lib/playground-worker-endpoint.ts b/packages/playground/remote/src/lib/playground-worker-endpoint.ts index 6a5282662d..74cb6fe6fd 100644 --- a/packages/playground/remote/src/lib/playground-worker-endpoint.ts +++ b/packages/playground/remote/src/lib/playground-worker-endpoint.ts @@ -2,14 +2,15 @@ import type { FilesystemOperation } from '@php-wasm/fs-journal'; import { journalFSEvents, replayFSJournal } from '@php-wasm/fs-journal'; import type { EmscriptenDownloadMonitor } from '@php-wasm/progress'; import { setURLScope } from '@php-wasm/scopes'; -import { joinPaths } from '@php-wasm/util'; +import { createSmtpConnector, joinPaths } from '@php-wasm/util'; import type { MountDevice, + NetworkConnector, SyncProgressCallback, - TCPOverFetchOptions, } from '@php-wasm/web'; import { createDirectoryHandleMountHandler, + createHttpConnector, loadWebRuntime, } from '@php-wasm/web'; import { createMemoizedFetch } from '@wp-playground/common'; @@ -148,7 +149,7 @@ export abstract class PlaygroundWorkerEndpoint extends PHPWorker { .join(','); } - let tcpOverFetch: TCPOverFetchOptions | undefined = undefined; + const networkConnectors: NetworkConnector[] = []; let caBundleContent = ''; if (withNetworking) { // @TODO: Is it fine this is only set in this code branch? That @@ -167,10 +168,13 @@ export abstract class PlaygroundWorkerEndpoint extends PHPWorker { }, }); caBundleContent = certificateToPEM(CAroot.certificate); - tcpOverFetch = { - CAroot, - corsProxyUrl, - }; + networkConnectors.push( + createHttpConnector({ + CAroot, + corsProxyUrl, + }), + createSmtpConnector() + ); phpIniEntries['disable_functions'] = ( phpIniEntries['disable_functions'] ?? '' ) @@ -195,7 +199,7 @@ export abstract class PlaygroundWorkerEndpoint extends PHPWorker { let wasmUrl = ''; return await loadWebRuntime(phpVersion, { withICU, - tcpOverFetch, + networkConnectors, onPhpLoaderModuleLoaded: (phpLoaderModule) => { wasmUrl = phpLoaderModule.dependencyFilename; this.downloadMonitor.expectAssets({