Skip to content

Commit 27d41fc

Browse files
committed
Decouple PHPRequestHandler from PHPProcessManager
Introduces a PHPInstanceManager interface that abstracts PHP instance lifecycle management. Both PHPProcessManager (for web contexts with multiple concurrent instances) and the new SinglePHPInstanceManager (for CLI contexts with a single instance) implement this interface. PHPRequestHandler now accepts either: - instanceManager: A pre-built PHPInstanceManager instance - phpFactory + maxPhpInstances: Creates PHPProcessManager internally This allows CLI contexts to use a simpler single-instance manager where runtime rotation is handled separately via php.enableRuntimeRotation(), while web contexts continue using PHPProcessManager for concurrency.
1 parent 6ba8368 commit 27d41fc

File tree

6 files changed

+186
-39
lines changed

6 files changed

+186
-39
lines changed

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ export { HttpCookieStore } from './http-cookie-store';
2424
export type { IteratePhpFilesOptions as IterateFilesOptions } from './iterate-files';
2525
export { iteratePhpFiles as iterateFiles } from './iterate-files';
2626
export { writeFilesStreamToPhp } from './write-files-stream-to-php';
27+
export type { PHPInstanceManager, AcquiredPHP } from './php-instance-manager';
28+
export { SinglePHPInstanceManager } from './single-php-instance-manager';
29+
export type { SinglePHPInstanceManagerOptions } from './single-php-instance-manager';
2730
export { PHPProcessManager } from './php-process-manager';
2831
export type {
2932
MaxPhpInstancesError,
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { PHP } from './php';
2+
3+
/**
4+
* Result of acquiring a PHP instance.
5+
* The `reap` function should be called when done with the instance.
6+
*/
7+
export interface AcquiredPHP {
8+
php: PHP;
9+
/**
10+
* Release the PHP instance back to the pool (for multi-instance managers)
11+
* or mark it as idle (for single-instance managers).
12+
*/
13+
reap: () => void;
14+
}
15+
16+
/**
17+
* Minimal interface for managing PHP instances.
18+
*
19+
* This interface allows PHPRequestHandler to work with different
20+
* instance management strategies:
21+
* - PHPProcessManager: Multiple PHP instances with concurrency control
22+
* - SinglePHPInstanceManager: Single PHP instance for CLI contexts
23+
*/
24+
export interface PHPInstanceManager extends AsyncDisposable {
25+
/**
26+
* Get the primary PHP instance.
27+
* This instance is persistent and never killed.
28+
*/
29+
getPrimaryPhp(): Promise<PHP>;
30+
31+
/**
32+
* Acquire a PHP instance for processing a request.
33+
*
34+
* @param options.considerPrimary - Whether the primary instance can be used.
35+
* Set to false for operations that would corrupt the primary (e.g., CLI commands).
36+
* @returns An acquired PHP instance with a reap function.
37+
*/
38+
acquirePHPInstance(options?: {
39+
considerPrimary?: boolean;
40+
}): Promise<AcquiredPHP>;
41+
}

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

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import { AcquireTimeoutError, Semaphore } from '@php-wasm/util';
22
import type { PHP } from './php';
3+
import type { PHPInstanceManager, AcquiredPHP } from './php-instance-manager';
34

45
export type PHPFactoryOptions = {
56
isPrimary: boolean;
67
};
78

89
export type PHPFactory = (options: PHPFactoryOptions) => Promise<PHP>;
910

11+
/**
12+
* @deprecated Use AcquiredPHP from './php-instance-manager' instead.
13+
*/
14+
export type SpawnedPHP = AcquiredPHP;
15+
1016
export interface ProcessManagerOptions {
1117
/**
1218
* The maximum number of PHP instances that can exist at
@@ -33,11 +39,6 @@ export interface ProcessManagerOptions {
3339
phpFactory?: PHPFactory;
3440
}
3541

36-
export interface SpawnedPHP {
37-
php: PHP;
38-
reap: () => void;
39-
}
40-
4142
export class MaxPhpInstancesError extends Error {
4243
constructor(limit: number) {
4344
super(
@@ -71,7 +72,7 @@ export class MaxPhpInstancesError extends Error {
7172
* requests requires extra time to spin up a few PHP instances. This is a more
7273
* resource-friendly tradeoff than keeping 5 idle instances at all times.
7374
*/
74-
export class PHPProcessManager implements AsyncDisposable {
75+
export class PHPProcessManager implements PHPInstanceManager {
7576
private primaryPhp?: PHP;
7677
private primaryPhpPromise?: Promise<SpawnedPHP>;
7778
private primaryIdle = true;

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

Lines changed: 56 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ import { normalizeHeaders } from './php';
1010
import { PHPResponse } from './php-response';
1111
import type { PHPRequest, PHPRunOptions } from './universal-php';
1212
import { encodeAsMultipart } from './encode-as-multipart';
13-
import type { PHPFactoryOptions, SpawnedPHP } from './php-process-manager';
13+
import type { PHPFactoryOptions } from './php-process-manager';
1414
import { MaxPhpInstancesError, PHPProcessManager } from './php-process-manager';
15+
import type { PHPInstanceManager, AcquiredPHP } from './php-instance-manager';
1516
import { HttpCookieStore } from './http-cookie-store';
1617
import mimeTypes from './mime-types.json';
1718

@@ -85,15 +86,31 @@ export type PHPRequestHandlerFactoryArgs = PHPFactoryOptions & {
8586
};
8687

8788
export type PHPRequestHandlerConfiguration = BaseConfiguration & {
88-
phpFactory: (requestHandler: PHPRequestHandlerFactoryArgs) => Promise<PHP>;
89-
/**
90-
* The maximum number of PHP instances that can exist at
91-
* the same time.
92-
*/
93-
maxPhpInstances?: number;
94-
9589
cookieStore?: CookieStore | false;
96-
};
90+
} & (
91+
| {
92+
/**
93+
* Provide a custom instance manager for advanced use cases.
94+
* Use SinglePHPInstanceManager for CLI contexts with a single PHP instance.
95+
* Use PHPProcessManager for web contexts with multiple concurrent instances.
96+
*/
97+
instanceManager: PHPInstanceManager;
98+
}
99+
| {
100+
/**
101+
* Provide a factory function to create PHP instances.
102+
* PHPRequestHandler will create a PHPProcessManager internally.
103+
*/
104+
phpFactory: (
105+
requestHandler: PHPRequestHandlerFactoryArgs
106+
) => Promise<PHP>;
107+
/**
108+
* The maximum number of PHP instances that can exist at
109+
* the same time. Only used when phpFactory is provided.
110+
*/
111+
maxPhpInstances?: number;
112+
}
113+
);
97114

98115
/**
99116
* Handles HTTP requests using PHP runtime as a backend.
@@ -159,7 +176,12 @@ export class PHPRequestHandler implements AsyncDisposable {
159176
#ABSOLUTE_URL: string;
160177
#cookieStore: CookieStore | false;
161178
rewriteRules: RewriteRule[];
162-
processManager: PHPProcessManager;
179+
/**
180+
* The instance manager used for PHP instance lifecycle.
181+
* This is either a provided instanceManager or a PHPProcessManager
182+
* created from the phpFactory.
183+
*/
184+
processManager: PHPInstanceManager;
163185
getFileNotFoundAction: FileNotFoundGetActionCallback;
164186

165187
/**
@@ -183,25 +205,29 @@ export class PHPRequestHandler implements AsyncDisposable {
183205
getFileNotFoundAction = () => ({ type: '404' }),
184206
} = config;
185207

186-
this.processManager = new PHPProcessManager({
187-
phpFactory: async (info) => {
188-
const php = await config.phpFactory!({
189-
...info,
190-
requestHandler: this,
191-
});
192-
193-
// Always set managed PHP's cwd to the document root.
194-
if (!php.isDir(documentRoot)) {
195-
php.mkdir(documentRoot);
196-
}
197-
php.chdir(documentRoot);
198-
199-
// @TODO: Decouple PHP and request handler
200-
(php as any).requestHandler = this;
201-
return php;
202-
},
203-
maxPhpInstances: config.maxPhpInstances,
204-
});
208+
if ('instanceManager' in config) {
209+
this.processManager = config.instanceManager;
210+
} else {
211+
this.processManager = new PHPProcessManager({
212+
phpFactory: async (info) => {
213+
const php = await config.phpFactory({
214+
...info,
215+
requestHandler: this,
216+
});
217+
218+
// Always set managed PHP's cwd to the document root.
219+
if (!php.isDir(documentRoot)) {
220+
php.mkdir(documentRoot);
221+
}
222+
php.chdir(documentRoot);
223+
224+
// @TODO: Decouple PHP and request handler
225+
(php as any).requestHandler = this;
226+
return php;
227+
},
228+
maxPhpInstances: config.maxPhpInstances,
229+
});
230+
}
205231

206232
/**
207233
* By default, config.cookieStore is undefined, so we use the
@@ -553,7 +579,7 @@ export class PHPRequestHandler implements AsyncDisposable {
553579
rewrittenRequestUrl: URL,
554580
scriptPath: string
555581
): Promise<PHPResponse> {
556-
let spawnedPHP: SpawnedPHP | undefined = undefined;
582+
let spawnedPHP: AcquiredPHP | undefined = undefined;
557583
try {
558584
spawnedPHP = await this.processManager!.acquirePHPInstance({
559585
considerPrimary: true,

packages/php-wasm/universal/src/lib/sandboxed-spawn-handler-factory.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createSpawnHandler } from '@php-wasm/util';
2-
import type { PHPProcessManager } from './php-process-manager';
2+
import type { PHPInstanceManager } from './php-instance-manager';
33

44
/**
55
* An isomorphic proc_open() handler that implements typical shell in TypeScript
@@ -12,7 +12,7 @@ import type { PHPProcessManager } from './php-process-manager';
1212
* parser.
1313
*/
1414
export function sandboxedSpawnHandlerFactory(
15-
processManager: PHPProcessManager
15+
instanceManager: PHPInstanceManager
1616
) {
1717
return createSpawnHandler(async function (args, processApi, options) {
1818
processApi.notifySpawn();
@@ -63,7 +63,7 @@ export function sandboxedSpawnHandlerFactory(
6363
return;
6464
}
6565

66-
const { php, reap } = await processManager.acquirePHPInstance({
66+
const { php, reap } = await instanceManager.acquirePHPInstance({
6767
considerPrimary: false,
6868
});
6969

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { PHP } from './php';
2+
import type { PHPInstanceManager, AcquiredPHP } from './php-instance-manager';
3+
4+
export interface SinglePHPInstanceManagerOptions {
5+
/**
6+
* Either provide an existing PHP instance...
7+
*/
8+
php?: PHP;
9+
/**
10+
* ...or a factory to create one on demand.
11+
*/
12+
phpFactory?: () => Promise<PHP>;
13+
}
14+
15+
/**
16+
* A minimal PHP instance manager that manages a single PHP instance.
17+
*
18+
* Unlike PHPProcessManager, this does not maintain a pool of instances
19+
* or implement concurrency control. It simply returns the same PHP
20+
* instance for every request.
21+
*
22+
* This is suitable for CLI contexts where:
23+
* - Only one PHP instance is needed
24+
* - Runtime rotation is handled separately via php.enableRuntimeRotation()
25+
* - Concurrency is not a concern (each worker has its own instance)
26+
*/
27+
export class SinglePHPInstanceManager implements PHPInstanceManager {
28+
private php: PHP | undefined;
29+
private phpPromise: Promise<PHP> | undefined;
30+
private phpFactory?: () => Promise<PHP>;
31+
32+
constructor(options: SinglePHPInstanceManagerOptions) {
33+
if (!options.php && !options.phpFactory) {
34+
throw new Error(
35+
'SinglePHPInstanceManager requires either php or phpFactory'
36+
);
37+
}
38+
this.php = options.php;
39+
this.phpFactory = options.phpFactory;
40+
}
41+
42+
async getPrimaryPhp(): Promise<PHP> {
43+
if (!this.php) {
44+
if (!this.phpPromise) {
45+
this.phpPromise = this.phpFactory!().then((php) => {
46+
this.php = php;
47+
this.phpPromise = undefined;
48+
return php;
49+
});
50+
}
51+
return this.phpPromise;
52+
}
53+
return this.php;
54+
}
55+
56+
async acquirePHPInstance(
57+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
58+
_options: { considerPrimary?: boolean } = {}
59+
): Promise<AcquiredPHP> {
60+
const php = await this.getPrimaryPhp();
61+
62+
return {
63+
php,
64+
reap: () => {
65+
// For single-instance manager, reap is a no-op.
66+
// The instance is reused for all requests.
67+
},
68+
};
69+
}
70+
71+
async [Symbol.asyncDispose](): Promise<void> {
72+
if (this.php) {
73+
this.php.exit();
74+
}
75+
}
76+
}

0 commit comments

Comments
 (0)