Skip to content

Commit d5702cb

Browse files
committed
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.
1 parent 4d678b8 commit d5702cb

File tree

6 files changed

+106
-30
lines changed

6 files changed

+106
-30
lines changed

packages/php-wasm/universal/src/lib/php-request-handler.ts

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
} from './urls';
88
import type { PHP, PHPExecutionFailureError } from './php';
99
import { normalizeHeaders } from './php';
10-
import { PHPResponse } from './php-response';
10+
import { PHPResponse, StreamedPHPResponse } from './php-response';
1111
import type { PHPRequest, PHPRunOptions } from './universal-php';
1212
import { encodeAsMultipart } from './encode-as-multipart';
1313
import type { PHPFactoryOptions } from './php-process-manager';
@@ -371,7 +371,9 @@ export class PHPRequestHandler implements AsyncDisposable {
371371
*
372372
* @param request - PHP Request data.
373373
*/
374-
async request(request: PHPRequest): Promise<PHPResponse> {
374+
async request(
375+
request: PHPRequest
376+
): Promise<PHPResponse | StreamedPHPResponse> {
375377
const isAbsolute = looksLikeAbsoluteUrl(request.url);
376378
const originalRequestUrl = new URL(
377379
// Remove the hash part of the URL as it's not meant for the server.
@@ -508,18 +510,23 @@ export class PHPRequestHandler implements AsyncDisposable {
508510
);
509511

510512
/**
511-
* If the response is but the exit code is non-zero, let's rewrite the
513+
* If the response is buffered and the exit code is non-zero, let's rewrite the
512514
* HTTP status code as 500. We're acting as a HTTP server here and
513515
* this behavior is in line with what Nginx and Apache do.
516+
*
517+
* For streamed responses, we can't rewrite the status code since the
518+
* response is already being consumed, so we return it as-is.
514519
*/
515-
if (response.ok() && response.exitCode !== 0) {
516-
return new PHPResponse(
517-
500,
518-
response.headers,
519-
response.bytes,
520-
response.errors,
521-
response.exitCode
522-
);
520+
if (response instanceof PHPResponse) {
521+
if (response.ok() && response.exitCode !== 0) {
522+
return new PHPResponse(
523+
500,
524+
response.headers,
525+
response.bytes,
526+
response.errors,
527+
response.exitCode
528+
);
529+
}
523530
}
524531
return response;
525532
} else {
@@ -587,7 +594,7 @@ export class PHPRequestHandler implements AsyncDisposable {
587594
originalRequestUrl: URL,
588595
rewrittenRequestUrl: URL,
589596
scriptPath: string
590-
): Promise<PHPResponse> {
597+
): Promise<PHPResponse | StreamedPHPResponse> {
591598
let spawnedPHP: AcquiredPHP | undefined = undefined;
592599
try {
593600
spawnedPHP = await this.instanceManager!.acquirePHPInstance({
@@ -626,7 +633,7 @@ export class PHPRequestHandler implements AsyncDisposable {
626633
originalRequestUrl: URL,
627634
rewrittenRequestUrl: URL,
628635
scriptPath: string
629-
): Promise<PHPResponse> {
636+
): Promise<PHPResponse | StreamedPHPResponse> {
630637
let preferredMethod: PHPRunOptions['method'] = 'GET';
631638

632639
const headers: Record<string, string> = {
@@ -646,7 +653,8 @@ export class PHPRequestHandler implements AsyncDisposable {
646653
}
647654

648655
try {
649-
const response = await php.run({
656+
// Use runStream() to keep responses as streams
657+
const streamedResponse = await php.runStream({
650658
relativeUri: ensurePathPrefix(
651659
toRelativeUrl(new URL(rewrittenRequestUrl.toString())),
652660
this.#PATHNAME
@@ -662,13 +670,14 @@ export class PHPRequestHandler implements AsyncDisposable {
662670
scriptPath,
663671
headers,
664672
});
673+
665674
if (this.#cookieStore) {
666-
this.#cookieStore.rememberCookiesFromResponseHeaders(
667-
response.headers
668-
);
675+
const headers = await streamedResponse.headers;
676+
this.#cookieStore.rememberCookiesFromResponseHeaders(headers);
669677
}
670678

671-
return response;
679+
// Return the stream as-is to avoid buffering large files
680+
return streamedResponse;
672681
} catch (error) {
673682
const executionError = error as PHPExecutionFailureError;
674683
if (executionError?.response) {

packages/php-wasm/universal/src/lib/php-worker.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,9 @@ export class PHPWorker implements LimitedPHPApi, AsyncDisposable {
173173
}
174174

175175
/** @inheritDoc @php-wasm/universal!PHPRequestHandler.request */
176-
async request(request: PHPRequest): Promise<PHPResponse> {
176+
async request(
177+
request: PHPRequest
178+
): Promise<PHPResponse | StreamedPHPResponse> {
177179
const requestHandler = _private.get(this)!.requestHandler!;
178180
return await requestHandler.request(request);
179181
}
@@ -188,6 +190,24 @@ export class PHPWorker implements LimitedPHPApi, AsyncDisposable {
188190
}
189191
}
190192

193+
/** @inheritDoc @php-wasm/universal!/PHP.runStream */
194+
async runStream(request: PHPRunOptions): Promise<StreamedPHPResponse> {
195+
const { php, reap } = await this.acquirePHPInstance();
196+
let response: StreamedPHPResponse;
197+
try {
198+
response = await php.runStream(request);
199+
} catch (error) {
200+
reap();
201+
throw error;
202+
}
203+
/**
204+
* The StreamedPHPResponse object must be reapable.
205+
* Let's ensure all streams complete before reaping.
206+
*/
207+
response.finished.then(reap, reap);
208+
return response;
209+
}
210+
191211
/** @inheritDoc @php-wasm/universal!/PHP.cli */
192212
async cli(
193213
argv: string[],

packages/php-wasm/universal/src/lib/php.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,9 @@ export class PHP implements Disposable {
424424
* Do not use. Use new PHPRequestHandler() instead.
425425
* @deprecated
426426
*/
427-
async request(request: PHPRequest): Promise<PHPResponse> {
427+
async request(
428+
request: PHPRequest
429+
): Promise<PHPResponse | StreamedPHPResponse> {
428430
logger.warn(
429431
'PHP.request() is deprecated. Please use new PHPRequestHandler() instead.'
430432
);

packages/playground/blueprints/src/lib/steps/export-wxr.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { UniversalPHP } from '@php-wasm/universal';
2+
import { PHPResponse, StreamedPHPResponse } from '@php-wasm/universal';
23

34
/**
45
* Exports the WordPress database as a WXR file using
@@ -11,5 +12,12 @@ export async function exportWXR(playground: UniversalPHP) {
1112
const databaseExportResponse = await playground.request({
1213
url: '/wp-admin/export.php?download=true&content=all',
1314
});
14-
return new File([databaseExportResponse.bytes], 'export.xml');
15+
16+
// Handle both buffered and streamed responses
17+
const bytes =
18+
databaseExportResponse instanceof StreamedPHPResponse
19+
? await databaseExportResponse.stdoutBytes
20+
: databaseExportResponse.bytes;
21+
22+
return new File([bytes], 'export.xml');
1523
}

packages/playground/cli/src/load-balancer.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import type { PHPRequest, PHPResponse, RemoteAPI } from '@php-wasm/universal';
1+
import type {
2+
PHPRequest,
3+
PHPResponse,
4+
StreamedPHPResponse,
5+
RemoteAPI,
6+
} from '@php-wasm/universal';
27
import type { PlaygroundCliBlueprintV1Worker as PlaygroundCliWorkerV1 } from './blueprints-v1/worker-thread-v1';
38
import type { PlaygroundCliBlueprintV2Worker as PlaygroundCliWorkerV2 } from './blueprints-v2/worker-thread-v2';
49

@@ -11,7 +16,7 @@ type PlaygroundCliWorker = PlaygroundCliWorkerV1 | PlaygroundCliWorkerV2;
1116
// TODO: Could we just spawn a worker using the factory function to PHPProcessManager?
1217
type WorkerLoad = {
1318
worker: RemoteAPI<PlaygroundCliWorker>;
14-
activeRequests: Set<Promise<PHPResponse>>;
19+
activeRequests: Set<Promise<PHPResponse | StreamedPHPResponse>>;
1520
};
1621
export class LoadBalancer {
1722
workerLoads: WorkerLoad[] = [];

packages/playground/cli/src/start-server.ts

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { type PHPRequest, PHPResponse } from '@php-wasm/universal';
1+
import {
2+
type PHPRequest,
3+
PHPResponse,
4+
StreamedPHPResponse,
5+
} from '@php-wasm/universal';
26
import type { Request } from 'express';
37
import express from 'express';
48
import type { IncomingMessage, Server, ServerResponse } from 'http';
@@ -9,7 +13,9 @@ import { logger } from '@php-wasm/logger';
913
export interface ServerOptions {
1014
port: number;
1115
onBind: (server: Server, port: number) => Promise<RunCLIServer | void>;
12-
handleRequest: (request: PHPRequest) => Promise<PHPResponse>;
16+
handleRequest: (
17+
request: PHPRequest
18+
) => Promise<PHPResponse | StreamedPHPResponse>;
1319
}
1420

1521
export async function startServer(
@@ -31,7 +37,7 @@ export async function startServer(
3137
});
3238

3339
app.use('/', async (req, res) => {
34-
let phpResponse: PHPResponse;
40+
let phpResponse: PHPResponse | StreamedPHPResponse;
3541
try {
3642
phpResponse = await options.handleRequest({
3743
url: req.url,
@@ -44,11 +50,37 @@ export async function startServer(
4450
phpResponse = PHPResponse.forHttpCode(500);
4551
}
4652

47-
res.statusCode = phpResponse.httpStatusCode;
48-
for (const key in phpResponse.headers) {
49-
res.setHeader(key, phpResponse.headers[key]);
53+
// Handle streamed responses
54+
if (phpResponse instanceof StreamedPHPResponse) {
55+
res.statusCode = await phpResponse.httpStatusCode;
56+
const headers = await phpResponse.headers;
57+
for (const key in headers) {
58+
res.setHeader(key, headers[key]);
59+
}
60+
61+
// Stream the response body
62+
const reader = phpResponse.stdout.getReader();
63+
try {
64+
while (true) {
65+
const { done, value } = await reader.read();
66+
if (done) break;
67+
if (value) {
68+
res.write(value);
69+
}
70+
}
71+
res.end();
72+
} catch (error) {
73+
logger.error('Error streaming response:', error);
74+
res.end();
75+
}
76+
} else {
77+
// Handle buffered responses
78+
res.statusCode = phpResponse.httpStatusCode;
79+
for (const key in phpResponse.headers) {
80+
res.setHeader(key, phpResponse.headers[key]);
81+
}
82+
res.end(phpResponse.bytes);
5083
}
51-
res.end(phpResponse.bytes);
5284
});
5385

5486
const address = server.address();

0 commit comments

Comments
 (0)