From d5702cbae6eefcb96e7ac853bf4c7a604570c43c Mon Sep 17 00:00:00 2001 From: Roberto Aranda Date: Mon, 15 Dec 2025 19:09:13 +0100 Subject: [PATCH] Use streaming responses to prevent memory allocation errors for large files Modified PHPRequestHandler to use php.runStream() instead of php.run() to avoid buffering large responses in memory. This prevents "Array buffer allocation failed" errors when downloading files larger than 2GB. Updated all related components to support both PHPResponse and StreamedPHPResponse return types, ensuring backward compatibility for smaller responses while enabling efficient streaming for large file downloads. --- .../universal/src/lib/php-request-handler.ts | 45 ++++++++++-------- .../php-wasm/universal/src/lib/php-worker.ts | 22 ++++++++- packages/php-wasm/universal/src/lib/php.ts | 4 +- .../blueprints/src/lib/steps/export-wxr.ts | 10 +++- packages/playground/cli/src/load-balancer.ts | 9 +++- packages/playground/cli/src/start-server.ts | 46 ++++++++++++++++--- 6 files changed, 106 insertions(+), 30 deletions(-) diff --git a/packages/php-wasm/universal/src/lib/php-request-handler.ts b/packages/php-wasm/universal/src/lib/php-request-handler.ts index 40da6c2540..aeae21831f 100644 --- a/packages/php-wasm/universal/src/lib/php-request-handler.ts +++ b/packages/php-wasm/universal/src/lib/php-request-handler.ts @@ -7,7 +7,7 @@ import { } from './urls'; import type { PHP, PHPExecutionFailureError } from './php'; import { normalizeHeaders } from './php'; -import { PHPResponse } from './php-response'; +import { PHPResponse, StreamedPHPResponse } from './php-response'; import type { PHPRequest, PHPRunOptions } from './universal-php'; import { encodeAsMultipart } from './encode-as-multipart'; import type { PHPFactoryOptions } from './php-process-manager'; @@ -371,7 +371,9 @@ export class PHPRequestHandler implements AsyncDisposable { * * @param request - PHP Request data. */ - async request(request: PHPRequest): Promise { + async request( + request: PHPRequest + ): Promise { const isAbsolute = looksLikeAbsoluteUrl(request.url); const originalRequestUrl = new URL( // Remove the hash part of the URL as it's not meant for the server. @@ -508,18 +510,23 @@ export class PHPRequestHandler implements AsyncDisposable { ); /** - * If the response is but the exit code is non-zero, let's rewrite the + * If the response is buffered and the exit code is non-zero, let's rewrite the * HTTP status code as 500. We're acting as a HTTP server here and * this behavior is in line with what Nginx and Apache do. + * + * For streamed responses, we can't rewrite the status code since the + * response is already being consumed, so we return it as-is. */ - if (response.ok() && response.exitCode !== 0) { - return new PHPResponse( - 500, - response.headers, - response.bytes, - response.errors, - response.exitCode - ); + if (response instanceof PHPResponse) { + if (response.ok() && response.exitCode !== 0) { + return new PHPResponse( + 500, + response.headers, + response.bytes, + response.errors, + response.exitCode + ); + } } return response; } else { @@ -587,7 +594,7 @@ export class PHPRequestHandler implements AsyncDisposable { originalRequestUrl: URL, rewrittenRequestUrl: URL, scriptPath: string - ): Promise { + ): Promise { let spawnedPHP: AcquiredPHP | undefined = undefined; try { spawnedPHP = await this.instanceManager!.acquirePHPInstance({ @@ -626,7 +633,7 @@ export class PHPRequestHandler implements AsyncDisposable { originalRequestUrl: URL, rewrittenRequestUrl: URL, scriptPath: string - ): Promise { + ): Promise { let preferredMethod: PHPRunOptions['method'] = 'GET'; const headers: Record = { @@ -646,7 +653,8 @@ export class PHPRequestHandler implements AsyncDisposable { } try { - const response = await php.run({ + // Use runStream() to keep responses as streams + const streamedResponse = await php.runStream({ relativeUri: ensurePathPrefix( toRelativeUrl(new URL(rewrittenRequestUrl.toString())), this.#PATHNAME @@ -662,13 +670,14 @@ export class PHPRequestHandler implements AsyncDisposable { scriptPath, headers, }); + if (this.#cookieStore) { - this.#cookieStore.rememberCookiesFromResponseHeaders( - response.headers - ); + const headers = await streamedResponse.headers; + this.#cookieStore.rememberCookiesFromResponseHeaders(headers); } - return response; + // Return the stream as-is to avoid buffering large files + return streamedResponse; } catch (error) { const executionError = error as PHPExecutionFailureError; if (executionError?.response) { diff --git a/packages/php-wasm/universal/src/lib/php-worker.ts b/packages/php-wasm/universal/src/lib/php-worker.ts index bbb191b410..80fea44241 100644 --- a/packages/php-wasm/universal/src/lib/php-worker.ts +++ b/packages/php-wasm/universal/src/lib/php-worker.ts @@ -173,7 +173,9 @@ export class PHPWorker implements LimitedPHPApi, AsyncDisposable { } /** @inheritDoc @php-wasm/universal!PHPRequestHandler.request */ - async request(request: PHPRequest): Promise { + async request( + request: PHPRequest + ): Promise { const requestHandler = _private.get(this)!.requestHandler!; return await requestHandler.request(request); } @@ -188,6 +190,24 @@ export class PHPWorker implements LimitedPHPApi, AsyncDisposable { } } + /** @inheritDoc @php-wasm/universal!/PHP.runStream */ + async runStream(request: PHPRunOptions): Promise { + const { php, reap } = await this.acquirePHPInstance(); + let response: StreamedPHPResponse; + try { + response = await php.runStream(request); + } catch (error) { + reap(); + throw error; + } + /** + * The StreamedPHPResponse object must be reapable. + * Let's ensure all streams complete before reaping. + */ + response.finished.then(reap, reap); + return response; + } + /** @inheritDoc @php-wasm/universal!/PHP.cli */ async cli( argv: string[], diff --git a/packages/php-wasm/universal/src/lib/php.ts b/packages/php-wasm/universal/src/lib/php.ts index d96154fb8a..b8631decc1 100644 --- a/packages/php-wasm/universal/src/lib/php.ts +++ b/packages/php-wasm/universal/src/lib/php.ts @@ -424,7 +424,9 @@ export class PHP implements Disposable { * Do not use. Use new PHPRequestHandler() instead. * @deprecated */ - async request(request: PHPRequest): Promise { + async request( + request: PHPRequest + ): Promise { logger.warn( 'PHP.request() is deprecated. Please use new PHPRequestHandler() instead.' ); diff --git a/packages/playground/blueprints/src/lib/steps/export-wxr.ts b/packages/playground/blueprints/src/lib/steps/export-wxr.ts index ef6a2b290d..53ddc7f6b5 100644 --- a/packages/playground/blueprints/src/lib/steps/export-wxr.ts +++ b/packages/playground/blueprints/src/lib/steps/export-wxr.ts @@ -1,4 +1,5 @@ import type { UniversalPHP } from '@php-wasm/universal'; +import { PHPResponse, StreamedPHPResponse } from '@php-wasm/universal'; /** * Exports the WordPress database as a WXR file using @@ -11,5 +12,12 @@ export async function exportWXR(playground: UniversalPHP) { const databaseExportResponse = await playground.request({ url: '/wp-admin/export.php?download=true&content=all', }); - return new File([databaseExportResponse.bytes], 'export.xml'); + + // Handle both buffered and streamed responses + const bytes = + databaseExportResponse instanceof StreamedPHPResponse + ? await databaseExportResponse.stdoutBytes + : databaseExportResponse.bytes; + + return new File([bytes], 'export.xml'); } diff --git a/packages/playground/cli/src/load-balancer.ts b/packages/playground/cli/src/load-balancer.ts index 40f2cadb57..0d90b0f9e6 100644 --- a/packages/playground/cli/src/load-balancer.ts +++ b/packages/playground/cli/src/load-balancer.ts @@ -1,4 +1,9 @@ -import type { PHPRequest, PHPResponse, RemoteAPI } from '@php-wasm/universal'; +import type { + PHPRequest, + PHPResponse, + StreamedPHPResponse, + RemoteAPI, +} from '@php-wasm/universal'; import type { PlaygroundCliBlueprintV1Worker as PlaygroundCliWorkerV1 } from './blueprints-v1/worker-thread-v1'; import type { PlaygroundCliBlueprintV2Worker as PlaygroundCliWorkerV2 } from './blueprints-v2/worker-thread-v2'; @@ -11,7 +16,7 @@ type PlaygroundCliWorker = PlaygroundCliWorkerV1 | PlaygroundCliWorkerV2; // TODO: Could we just spawn a worker using the factory function to PHPProcessManager? type WorkerLoad = { worker: RemoteAPI; - activeRequests: Set>; + activeRequests: Set>; }; export class LoadBalancer { workerLoads: WorkerLoad[] = []; diff --git a/packages/playground/cli/src/start-server.ts b/packages/playground/cli/src/start-server.ts index 396e40b625..ac209926af 100644 --- a/packages/playground/cli/src/start-server.ts +++ b/packages/playground/cli/src/start-server.ts @@ -1,4 +1,8 @@ -import { type PHPRequest, PHPResponse } from '@php-wasm/universal'; +import { + type PHPRequest, + PHPResponse, + StreamedPHPResponse, +} from '@php-wasm/universal'; import type { Request } from 'express'; import express from 'express'; import type { IncomingMessage, Server, ServerResponse } from 'http'; @@ -9,7 +13,9 @@ import { logger } from '@php-wasm/logger'; export interface ServerOptions { port: number; onBind: (server: Server, port: number) => Promise; - handleRequest: (request: PHPRequest) => Promise; + handleRequest: ( + request: PHPRequest + ) => Promise; } export async function startServer( @@ -31,7 +37,7 @@ export async function startServer( }); app.use('/', async (req, res) => { - let phpResponse: PHPResponse; + let phpResponse: PHPResponse | StreamedPHPResponse; try { phpResponse = await options.handleRequest({ url: req.url, @@ -44,11 +50,37 @@ export async function startServer( phpResponse = PHPResponse.forHttpCode(500); } - res.statusCode = phpResponse.httpStatusCode; - for (const key in phpResponse.headers) { - res.setHeader(key, phpResponse.headers[key]); + // Handle streamed responses + if (phpResponse instanceof StreamedPHPResponse) { + res.statusCode = await phpResponse.httpStatusCode; + const headers = await phpResponse.headers; + for (const key in headers) { + res.setHeader(key, headers[key]); + } + + // Stream the response body + const reader = phpResponse.stdout.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + res.write(value); + } + } + res.end(); + } catch (error) { + logger.error('Error streaming response:', error); + res.end(); + } + } else { + // Handle buffered responses + res.statusCode = phpResponse.httpStatusCode; + for (const key in phpResponse.headers) { + res.setHeader(key, phpResponse.headers[key]); + } + res.end(phpResponse.bytes); } - res.end(phpResponse.bytes); }); const address = server.address();