Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 58 additions & 8 deletions packages/php-wasm/universal/src/lib/comlink-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,9 +392,8 @@ export type ProxyOrClone<T> = T extends ProxyMarked ? Remote<T> : T;
/**
* Inverse of `ProxyOrClone<T>`.
*/
export type UnproxyOrClone<T> = T extends RemoteObject<ProxyMarked>
? Local<T>
: T;
export type UnproxyOrClone<T> =
T extends RemoteObject<ProxyMarked> ? Local<T> : T;

/**
* Takes the raw type of a remote object in the other thread and returns the type as it is visible
Expand Down Expand Up @@ -439,7 +438,7 @@ export type Remote<T> =
...args: {
[I in keyof TArguments]: UnproxyOrClone<TArguments[I]>;
}
) => Promisify<ProxyOrClone<Unpromisify<TReturn>>>
) => Promisify<ProxyOrClone<Unpromisify<TReturn>>>
: unknown) &
// Handle construct signature (if present)
// The return of construct signatures is always proxied (whether marked or not)
Expand All @@ -452,7 +451,7 @@ export type Remote<T> =
>;
}
): Promisify<Remote<TInstance>>;
}
}
: unknown) &
// Include additional special comlink methods available on the proxy.
ProxyMethods;
Expand All @@ -478,8 +477,8 @@ export type Local<T> =
...args: {
[I in keyof TArguments]: ProxyOrClone<TArguments[I]>;
}
) => // The raw function could either be sync or async, but is always proxied automatically
MaybePromise<UnproxyOrClone<Unpromisify<TReturn>>>
) => // The raw function could either be sync or async, but is always proxied automatically
MaybePromise<UnproxyOrClone<Unpromisify<TReturn>>>
: unknown) &
// Handle construct signature (if present)
// The return of construct signatures is always proxied (whether marked or not)
Expand All @@ -493,7 +492,7 @@ export type Local<T> =
}
): // The raw constructor could either be sync or async, but is always proxied automatically
MaybePromise<Local<Unpromisify<TInstance>>>;
}
}
: unknown);

const isObject = (val: unknown): val is object =>
Expand Down Expand Up @@ -806,19 +805,64 @@ function unregisterProxy(proxy: object) {
}
}

/**
* Cache for proxy objects keyed by endpoint and path.
*
* This prevents creating new proxy objects on every property access,
* which would otherwise accumulate in the FinalizationRegistry and
* cause progressive performance degradation over many calls.
*
* Using WeakMap ensures the cache is automatically cleaned up when
* the endpoint is garbage collected.
*/
const proxyCache = new WeakMap<Endpoint, Map<string, object>>();

function getCachedProxy<T>(
ep: Endpoint,
pathKey: string
): Remote<T> | undefined {
const cache = proxyCache.get(ep);
return cache?.get(pathKey) as Remote<T> | undefined;
}

function setCachedProxy(ep: Endpoint, pathKey: string, proxy: object): void {
let cache = proxyCache.get(ep);
if (!cache) {
cache = new Map();
proxyCache.set(ep, cache);
}
cache.set(pathKey, proxy);
}

function clearProxyCache(ep: Endpoint): void {
proxyCache.delete(ep);
}

function createProxy<T>(
ep: Endpoint,
pendingListeners: PendingListenersMap,
path: (string | number | symbol)[] = [],
target: object = function () {}
): Remote<T> {
// Check cache for existing proxy (only for string/number paths, not symbols)
const isCacheable = path.every((p) => typeof p !== 'symbol');
const pathKey = isCacheable ? path.join('\0') : '';

if (isCacheable) {
const cached = getCachedProxy<T>(ep, pathKey);
if (cached) {
return cached;
}
}

let isProxyReleased = false;
const proxy = new Proxy(target, {
get(_target, prop) {
throwIfProxyReleased(isProxyReleased);
if (prop === releaseProxy) {
return () => {
unregisterProxy(proxy);
clearProxyCache(ep);
releaseEndpoint(ep);
pendingListeners.clear();
isProxyReleased = true;
Expand Down Expand Up @@ -893,6 +937,12 @@ function createProxy<T>(
).then(fromWireValue);
},
});

// Cache the proxy before registering and returning
if (isCacheable) {
setCachedProxy(ep, pathKey, proxy);
}

registerProxy(proxy, ep);
return proxy as any;
}
Expand Down
57 changes: 33 additions & 24 deletions packages/php-wasm/universal/src/lib/php-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,18 +213,23 @@ async function parseHeadersStream(
async function streamToText(
stream: ReadableStream<Uint8Array>
): Promise<string> {
const decoderStream = new TextDecoderStream();
const reader = (stream as ReadableStream<BufferSource>)
.pipeThrough(new TextDecoderStream())
.pipeThrough(decoderStream)
.getReader();
const text: string[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) {
return text.join('');
}
if (value) {
text.push(value);
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
return text.join('');
}
if (value) {
text.push(value);
}
}
} finally {
reader.releaseLock();
}
}

Expand All @@ -234,24 +239,28 @@ async function streamToBytes(
const reader = stream.getReader();
const chunks: Uint8Array[] = [];

while (true) {
const { done, value } = await reader.read();
if (done) {
const totalLength = chunks.reduce(
(acc, chunk) => acc + chunk.byteLength,
0
);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.byteLength;
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
const totalLength = chunks.reduce(
(acc, chunk) => acc + chunk.byteLength,
0
);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.byteLength;
}
return result;
}
if (value) {
chunks.push(value);
}
return result;
}
if (value) {
chunks.push(value);
}
} finally {
reader.releaseLock();
}
}

Expand Down
18 changes: 18 additions & 0 deletions packages/php-wasm/universal/src/lib/php-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ const _private = new WeakMap<
}
>();

/**
* Tracks PHP instances that have already had worker listeners registered.
*
* This prevents duplicate event listeners from being added when the same
* PHP instance is reused across multiple run() calls, which would otherwise
* cause progressive performance degradation as each event fires N times
* for N accumulated listeners.
*/
const registeredPHPInstances = new WeakSet<PHP>();

export type LimitedPHPApi = Pick<
PHP,
| 'request'
Expand Down Expand Up @@ -343,6 +353,14 @@ export class PHPWorker implements LimitedPHPApi, AsyncDisposable {
}

protected registerWorkerListeners(php: PHP) {
// Prevent duplicate listener registration when the same PHP instance
// is reused across multiple run() calls. Without this check, listeners
// would accumulate and cause progressive performance degradation.
if (registeredPHPInstances.has(php)) {
return;
}
registeredPHPInstances.add(php);

php.addEventListener('*', async (event) => {
this.dispatchEvent(event);
});
Expand Down
Loading