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
5 changes: 4 additions & 1 deletion packages/meta/src/node-es-module-loader/loader.mts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ const playgroundPackageRootUrl = pathToFileURL(
);

const aliasMap = new Map<string, URL>();
for (const [alias, paths] of Object.entries(pathAliases)) {
const aliasesBySpecificity = Object.entries(pathAliases).sort(
([left], [right]) => right.length - left.length
);
for (const [alias, paths] of aliasesBySpecificity) {
// Our config is simple and doesn't use wildcards,
// so we can just use the first path
const resolvedPath = resolvePath(baseUrl, paths[0]);
Expand Down
136 changes: 136 additions & 0 deletions packages/php-wasm/universal/RPC-COMPATIBILITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<!-- SPDX-License-Identifier: GPL-2.0-or-later -->

# WordPress Playground RPC compatibility decisions

This record describes the first, deliberately narrow rollout of the independent
Playground RPC implementation. It is an engineering record, not a legal-clearance
determination.

## Rollout boundary

This change opts only `@wp-playground/cli` into the new implementation. The CLI's
main thread, Blueprint workers, and dedicated synchronous file-lock channels all
import `@php-wasm/universal/playground-rpc`.

The existing `@php-wasm/universal` entry point and its RPC exports remain in place
for every other consumer. In particular, the browser client, remote iframe,
website, PHP web worker, Telex, Studio, and direct users of the package root do not
move to the new wire protocol in this change.

The new implementation has a separate package subpath so the two implementations
can coexist without accidentally pairing a new client with a legacy remote. The
CLI packages both ends of each channel together and therefore upgrades them as one
unit.

## Playground-facing surface used by the CLI

The `@php-wasm/universal/playground-rpc` subpath provides:

- `consumeAPI()` and `exposeAPI()`;
- `consumeAPISync()` and `exposeSyncAPI()`;
- `Remote<T>` and `RemoteAPI<T>`;
- `PublicAPI`;
- `WithAPIState` and `WithIsReady`;
- `releaseApiProxy`;
- `ConsumeAPIOptions`;
- `RemoteAPIEndpointTerminatedError`;
- `APITransferable`;
- `APITransferPolicy` and `defineAPITransferPolicy()`;
- `streamToPort()` and `portToStream()`; and
- the `NodeProcess` transport type.

The CLI relies on asynchronous calls and nested property reads, receiver
preservation, callbacks, readiness, explicit release, structured-cloned values,
transfer lists, response and stream codecs, and synchronous calls for file locks.
The existing CLI API is intended to remain unchanged; the replacement is internal
to its worker channels.

## Deliberately omitted behavior

No approved CLI call site requires these general-purpose proxy features, so they
are outside the new protocol:

| Behavior | Decision |
| -------------------------- | ---------------------------------------------------------------------------------------------------- |
| Remote property assignment | Omitted. Use an explicit remote method. |
| Remote construction | Omitted. Expose and call a factory method. |
| Generic proxy marking | Omitted. Only callbacks and documented codecs cross by reference or special representation. |
| Automatic finalization | Omitted. The endpoint owner releases the proxy or aborts the session explicitly. |
| Special `.bind()` behavior | Omitted. Normal method invocation preserves the containing object as `this`. |
| Per-operation cancellation | Omitted. The owner lifecycle signal terminates the session; stream cancellation uses its own bridge. |

Assignment and construction throw `RPCUnsupportedOperationError`. Object
enumeration, reflective proxy identity, arbitrary symbols, and other generic
membrane behavior are not promised.

## Lifetime guarantees

Each consumed endpoint has one session. It owns pending requests, listeners,
derived property proxies, callback references, transferred ports, returned
streams, and deferred values.

Release, owner abort, a remote termination message, or an observed endpoint event
causes one terminal transition. That transition rejects pending work, prevents new
messages, detaches listeners, closes owned ports, errors returned streams, rejects
deferred values, and invalidates callbacks and derived proxies. Repeated cleanup is
safe.

Node worker exit, Node `MessagePort` close, and child-process disconnect, exit,
close, and error are observed automatically. Browser platforms cannot reliably
report every worker termination, iframe removal or navigation, renderer failure,
or remote self-termination. A future browser integration must pass one owner
`AbortSignal` when constructing the endpoint and abort it when the owner makes the
endpoint unusable. This first rollout does not change browser integration.

Asynchronous calls have no arbitrary deadline because PHP work can legitimately
run for a long time. Synchronous calls have a configurable bounded deadline, with
a 30-second default, because a thread blocked in `Atomics.wait()` cannot process
ordinary asynchronous close events. A lost sync endpoint can therefore surface as
`SyncRPCOperationTimeoutError` when loss cannot be observed before the wait begins.

## Protocol and mixed versions

The new protocol marker is `wordpress-playground-rpc`; its first wire version is
`1`. Here, “version” means the format of messages exchanged across an endpoint,
not the npm package version. The envelope, codecs, bootstrap, stream bridges, and
synchronous format are documented in [RPC-PROTOCOL.md](./RPC-PROTOCOL.md).

Peers that recognize the marker but use different versions terminate with
`RPCProtocolVersionMismatchError`. A legacy peer uses a different marker and is
not compatible with the new protocol. The separate import boundary prevents that
combination inside the CLI. Future changes to the CLI wire format must update both
ends together.

## Transfer and transport decisions

Structured clone is the default. Defined codecs cover callbacks, `CustomEvent`,
`MessagePort`, readable streams, branded PHP stdin events, `PHPResponse`,
`StreamedPHPResponse`, `Error` values, and non-`Error` thrown values. Streams use a
native transfer when selected and supported, or a private `MessagePort` bridge.

Transfer-policy hooks return the exact additional transfer list. The runtime does
not scan arbitrary object graphs, and it deduplicates codec and policy transfer
lists before posting.

Browser workers, browser and Node message ports, and Node worker-thread workers
can carry transfer lists. Node child-process IPC cannot; requesting one throws
`RPCUnsupportedTransferError`. Child-process use requires Node's advanced
serialization mode.

Although the engine includes adapters for the transports above and a private
Window bootstrap, only the CLI's Node worker-thread and `MessagePort` paths are
adopted by this PR. Browser adoption and its cross-browser lifecycle validation
remain a later integration step.

## Consumer impact

- Playground CLI: worker communication changes internally; no change to its public
commands, options, or return values is intended.
- Telex, Studio, browser Playground, and direct root-package consumers: no RPC
implementation change in this PR.
- wp-env and other CLI consumers: no intended API change; their packaged CLI path
exercises the new internal RPC and is covered by the passing built-package
tests.

Any later rollout to an independently deployed worker or iframe will need a
coordinated client/remote upgrade and an explicit browser lifecycle owner.
184 changes: 184 additions & 0 deletions packages/php-wasm/universal/RPC-PROTOCOL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
<!-- SPDX-License-Identifier: GPL-2.0-or-later -->

# WordPress Playground RPC protocol version 1

This document defines the independently designed wire contract exported from
`@php-wasm/universal/playground-rpc`. Version `1` identifies this message format;
it is independent of the npm package version. It is intended for version review
and coordinated deployment, not as an invitation to treat the implementation as
a general-purpose RPC framework.

The first rollout uses this protocol only between Playground CLI processes and
worker threads. The package-root RPC exports remain unchanged for browser and
other consumers. The Window, browser-worker, and child-process sections document
implemented transport contracts for later integrations; they do not expand the
scope of this rollout.

All object fields described as required must have the stated type. Receivers
ignore envelopes with another marker or session. They reject or ignore malformed
fields as described below; they never dispatch an unvalidated API path.
Protocol-version fields are nonnegative ECMAScript safe integers. A malformed
`protocol-error` (including a missing/invalid `remoteVersion` or a non-string
optional `message`) is ignored and cannot terminate a session.

## Main asynchronous protocol

Every asynchronous envelope has these required fields:

| Field | Version 1 value |
| ---------- | ---------------------------------------------------------- |
| `protocol` | The literal `wordpress-playground-rpc` |
| `version` | The number `1` |
| `session` | The opaque session identifier established by the handshake |
| `kind` | One of the kinds below |

The client chooses the session identifier. The server binds an unbound endpoint
to the first valid `hello`, or checks an expected identifier supplied by the
private Window bootstrap. A request identifier is unique within one direction
of one session. Locally generated request and callback identifiers use `c-` or
`s-` role prefixes as a diagnostic convention, not as an authorization
mechanism.

| `kind` | Sender | Additional required or optional fields | Meaning |
| ------------------ | ------------ | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `hello` | Client | None | Proposes the envelope version and session. It may be retried. |
| `hello-ack` | Server | None | Accepts the version and session. |
| `protocol-error` | Either peer | `remoteVersion: number`; optional `message: string` | Rejects an unsupported protocol version. The envelope's `version` identifies the sender's version. |
| `request` | Either peer | `requestId: string`, `operation`, `args`; operation-specific fields below | Starts a call, read, or callback invocation. |
| `response` | Either peer | `requestId: string` and exactly one of `value` or `error` | Settles one pending request. |
| `callback-release` | Either peer | `callbackId: string` | Removes the corresponding local callback reference. |
| `release` | Consumer | None | Releases the complete session. |
| `terminate` | Either owner | Optional `message: string` | Announces an owner-driven terminal transition. |

Version 1 request operations are deliberately limited:

| `operation` | Required fields | Dispatch |
| ----------- | -------------------------------------------- | ---------------------------------------------------------------------- |
| `get` | `path: string[]`, `args: []` | Reads and awaits the property at `path`. |
| `call` | `path: string[]`, `args: RPCWireValue[]` | Calls the value at `path`, preserving the containing object as `this`. |
| `callback` | `callbackId: string`, `args: RPCWireValue[]` | Invokes a callback registered in the opposite direction. |

Paths contain at most 64 components, each at most 1024 characters.
`__proto__`, `prototype`, and `constructor` are forbidden. Property assignment,
construction, reflective enumeration, generic proxy marking, and synthetic
`bind` operations have no version 1 message kind.

Responses can arrive out of order. A peer removes the matching pending entry
before resolving or rejecting it. Duplicate and unexpected responses do not
settle anything. A valid but unknown request kind receives a serialized error;
an unrelated or malformed envelope is ignored.

## Values and errors

An `RPCWireValue` has one of these shapes:

```text
{ representation: "clone", value: <structured-clone value> }
{ representation: "codec", codec: <identifier>, value: <codec payload> }
```

Version 1 defines these codec identifiers:

| Identifier | Payload purpose |
| ------------------------------------- | --------------------------------------------------------------- |
| `playground.callback.v1` | A session-owned callback identifier |
| `playground.custom-event.v1` | Event type, encoded detail, and event flags |
| `playground.message-port.v1` | A transferred `MessagePort` |
| `playground.readable-stream.v1` | A native stream or stream-bridge port |
| `playground.php-event-stdin.v1` | Branded PHP event data and its stdin stream |
| `playground.php-response.v1` | Buffered response status, headers, bytes, errors, and exit code |
| `playground.streamed-php-response.v1` | Headers/stdout/stderr streams and an exit-code bridge port |
| `playground.error-value.v1` | An `Error` used as an ordinary value |

An unknown codec identifier is a serialization failure. Codec and policy
transfer lists are combined and deduplicated by identity. Transfer-policy hooks
provide the exact additional list; the protocol does not discover nested
transferables by walking an arbitrary object graph.

A thrown non-`Error` uses `{ kind: "value", value: RPCWireValue }`. A thrown
`Error` uses `{ kind: "error", error }`, where `error` contains required
`name`, `message`, `originalClassName`, and `properties`, plus optional `stack`
and recursively encoded `cause`. Dangerous property names are not reconstructed.

## Window bootstrap protocol

The shared `Window` channel carries only this bootstrap envelope:

```text
{
protocol: "wordpress-playground-rpc-bootstrap",
version: 1,
kind: "connect",
session: <main-protocol session identifier>
}
```

It must transfer exactly one newly created `MessagePort`. The iframe accepts it
only from the configured parent window and an exact configured origin. The
client sends its main-protocol `hello` on that private port. It promotes a port
only after a matching current-version `hello-ack` or a well-formed
`protocol-error`; malformed attempts are left inactive and a later handshake
retry can create a new private port. Normal RPC traffic never returns to the
shared Window channel.

## Stream and deferred bridges

The portable stream marker is `wordpress-playground-stream-bridge`, version `1`.
Every bridge message has `protocol`, `version`, an opaque `channel` identifier,
and one of these kinds:

| Kind | Additional fields | Direction |
| -------- | -------------------------------------- | -------------------- |
| `open` | None | Producer to consumer |
| `chunk` | `bytes: ArrayBuffer` | Producer to consumer |
| `close` | None | Producer to consumer |
| `error` | Serialized name/message/optional stack | Producer to consumer |
| `cancel` | None | Consumer to producer |

The message-port stream codec payload carries the private port and its channel
identifier. The public port helper sends `open` before reading the source. Either
form lets the consumer identify the bridge and cancel it before the first chunk
or terminal message arrives.

The deferred marker is `wordpress-playground-deferred-bridge`, version `1`.
Its messages have the same common fields and either `resolve` with `value`, or
`reject` with a serialized error. Each bridge owns a private `MessagePort` and
closes it on settlement, cancellation, or session termination. Bridge versions
do not negotiate or fall back.

## Synchronous protocol

Synchronous RPC uses the main marker and version on a dedicated `MessagePort`.
It permits only method calls and these envelope kinds:

| `kind` | Additional fields | Meaning |
| ---------------- | ----------------------------------------------------------------------------- | ------------------------------- |
| `sync-hello` | None | Proposes a session. |
| `sync-hello-ack` | None | Accepts it. |
| `protocol-error` | `remoteVersion: number` | Reports a mismatch. |
| `sync-request` | `requestId`, safe `path`, string `payload`, `sharedBuffer: SharedArrayBuffer` | Starts one bounded method call. |
| `release` | None | Closes the session. |

The payload is JSON with explicit tagged representations for `bigint`,
`undefined`, non-finite numbers, `Map`, `Set`, `Uint8Array`, and `ArrayBuffer`.
Functions and symbols are unsupported. The shared buffer starts with two
`Int32` cells for status and byte length, followed by the UTF-8 response payload.
Status values distinguish success, remote error, oversized response, and known
endpoint termination. `Atomics.notify()` wakes the caller. A caller that is
already blocked cannot process an asynchronous close event, so every sync call
has a finite deadline and an externally lost endpoint may surface as timeout.

## Transport requirements and version changes

Browser and Node workers and message ports use their platform structured-clone
and transfer-list facilities. Node child-process endpoints require
`fork(..., { serialization: "advanced" })`; the default JSON IPC mode is not a
version 1 transport. Child-process transfer lists are always rejected.

Recognized peers with different main versions exchange `protocol-error` and
terminate with `RPCProtocolVersionMismatchError`. An endpoint with an unrelated
marker cannot participate in that exchange, so the recognized peer remains
pending until its owner aborts. A future incompatible change to an envelope,
operation, codec payload, bootstrap, or bridge must increment the corresponding
version and coordinate every independently cached client, worker, iframe,
remote page, and service worker that crosses that boundary.
Loading
Loading