Skip to content

Commit

Permalink
Playground Public API (WordPress#149)
Browse files Browse the repository at this point in the history
## Description

With this PR, Playground exposes a consistent communication layer for its consumers:

```ts
const playground = connect( iframe.contentWindow );
playground.writeFile( '/wordpress/test.php', 'Hello, world!' );
playground.goTo( '/test.php' );
```

It means **your app can have the same powers as the official demo**. Just note this PR does not expose any public packages yet. That will be the next step.

## Under the hood

Technically, this PR:

1. Formalizes `php-wasm` public API
2. Formalizes Playground public API as an extension of the above
3. Uses the [comlink](https://github.com/GoogleChromeLabs/comlink) library to expose Playground's public API

There are a few layers to this:

* Playground worker initializes the PHP and WordPress and exposes an internal API using comlink
* Playground "connector" HTML page consumes the worker API, extends it, and re-exposes it publicly using comlink
* Playground UI is now a separate app that connects to the "connector" page and consumes its API using comlink

All the public-facing features, like plugin pre-installation or import/export, are now implemented by consuming the public API. Once I [publish these features in npm](WordPress/wordpress-playground#147), consuming Playground will be as simple as importing a package.

This PR also refactors the Playground website to use React – this process was indispensable in shaping the public API.

## Public API

Here's the raw interface – the documentation will be shipped with the npm package and provided in this repo.

```ts
interface PlaygroundAPI {
	absoluteUrl: string;
	goTo(requestedPath: string): Promise<void>;
	getCurrentURL(): Promise<void>;
	setIframeSandboxFlags(flags: string[]): Promise<void>;

	onNavigation(callback: (newURL: string) => void): Promise<void>;
	onDownloadProgress(
		callback: (progress: CustomEvent<ProgressEvent>) => void
	): Promise<void>;

	getWordPressModuleDetails(): Promise<{ staticAssetsDirectory: string; defaultTheme: string; }>;

	pathToInternalUrl(path: string): Promise<string>;
	internalUrlToPath(internalUrl: string): Promise<string>;

	request(
		request: PHPServerRequest,
		redirects?: number
	): Promise<PHPResponse>;
	run(request?: PHPRequest | undefined): Promise<PHPResponse>;
	setPhpIniPath(path: string): Promise<void>;
	setPhpIniEntry(key: string, value: string): Promise<void>;
	mkdirTree(path: string): Promise<void>;
	readFileAsText(path: string): Promise<string>;
	readFileAsBuffer(path: string): Promise<Uint8Array>;
	writeFile(path: string, data: string | Uint8Array): Promise<void>;
	unlink(path: string): Promise<void>;
	listFiles(path: string): Promise<string[]>;
	isDir(path: string): Promise<boolean>;
	fileExists(path: string): Promise<boolean>;
}
```
  • Loading branch information
adamziel authored Mar 14, 2023
1 parent 0d68591 commit bc3daa0
Show file tree
Hide file tree
Showing 89 changed files with 4,148 additions and 3,274 deletions.
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ module.exports = {
'jsx-a11y/click-events-have-key-events': 0,
'jsx-a11y/no-static-element-interactions': 0,
'@typescript-eslint/ban-ts-comment': 0,
'@typescript-eslint/ban-types': 0,
'@typescript-eslint/no-non-null-assertion': 0,
},
};
9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
"yarn": ">=3.4.0",
"node": ">=16.0.0"
},
"resolutions": {
"react": "18.2.0",
"react-dom": "18.2.0"
},
"author": "The WordPress contributors",
"license": "Apache-2.0",
"dependencies": {
Expand Down Expand Up @@ -58,10 +62,11 @@
"magic-string": "^0.26.7",
"npm-run-all": "^4.1.5",
"prettier": "^2.7.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-refresh": "^0.14.0",
"request": "^2.88.2",
"typescript-plugin-css-modules": "^4.2.3",
"vitepress": "^1.0.0-alpha.26",
"vue": "^3.2.41",
"ws": "^8.12.0",
Expand Down
27 changes: 19 additions & 8 deletions packages/php-wasm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,26 @@
"build"
],
"exports": {
".": "./build/web/index.js",
"./*": "./build/*",
"./web": "./build/web/index.js",
"./web/*": "./build/web/*",
"./node": "./build/node/index.js",
"./node/*": "./build/node/*",
"./package.json": "./package.json"
".": {
"import": "./build/web/index.js",
"default": "./build/web/index.js",
"types": "./build/web/dts/index.d.ts"
},
"./web/service-worker": {
"import": "./build/web/service-worker.js",
"types": "./build/web/dts/web/service-worker/index.d.ts"
},
"./node": {
"import": "./build/node/index.js",
"types": "./build/node/dts/index.d.ts"
},
"./node/*": {
"import": "./build/node/*",
"types": "./build/node/dts/*"
}
},
"type": "module",
"types": "build/web/types",
"types": "build/web/dts/index.d.ts",
"scripts": {
"dev": "rollup -c rollup.config.mjs -w",
"build": "rollup -c rollup.config.mjs",
Expand Down Expand Up @@ -47,6 +57,7 @@
"author": "The WordPress contributors",
"license": "Apache-2.0",
"dependencies": {
"comlink": "^4.4.1",
"glob": "^9.2.1",
"rollup-plugin-copy": "^3.4.0"
},
Expand Down
13 changes: 6 additions & 7 deletions packages/php-wasm/rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@ export default [
{
input: {
'service-worker':
'src/web/service-worker/worker-library.ts',
'worker-thread':
'src/web/worker-thread/worker-library.ts',
'src/web/service-worker/index.ts',
index: 'src/web.ts',
},
external: ['pnpapi'],
Expand All @@ -27,10 +25,10 @@ export default [
],
}),
typescript({
tsconfig: './tsconfig.json',
tsconfig: './tsconfig.build.json',
compilerOptions: {
declarationDir: 'build/web/types',
outDir: 'build/web/types',
outDir: 'build/web/types'
}
}),
url({
Expand All @@ -45,14 +43,15 @@ export default [
external: ['pnpapi', 'util'],
output: {
dir: 'build/node',
format: 'esm',
format: 'esm'
},
plugins: [
typescript({
tsconfig: './tsconfig.node.json',
tsconfig: './tsconfig.build.json',
compilerOptions: {
declarationDir: 'build/node/types',
outDir: 'build/node/types',
lib: []
}
}),
url({
Expand Down
96 changes: 96 additions & 0 deletions packages/php-wasm/src/php-library/comlink-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import Comlink from 'comlink';

export function consumeAPI<APIType>(remote: Worker | Window) {
setupTransferHandlers();

const endpoint =
remote instanceof Worker ? remote : Comlink.windowEndpoint(remote);

return Comlink.wrap<APIType>(endpoint);
}

type PublicAPI<Methods, PipedAPI> = Methods & PipedAPI & { isReady: () => Promise<void> };
export function exposeAPI<Methods, PipedAPI>(apiMethods?: Methods, pipedApi?: PipedAPI):
[() => void, PublicAPI<Methods, PipedAPI>]
{
setupTransferHandlers();

let setReady;
const ready = new Promise((resolve) => {
setReady = resolve;
});

const methods = proxyClone(apiMethods);
const exposedApi = new Proxy(methods, {
get: (target, prop) => {
if (prop === 'isReady') {
return () => ready;
}
if (prop in target) {
return target[prop];
}
return pipedApi?.[prop];
},
}) as unknown as PublicAPI<Methods, PipedAPI>;

Comlink.expose(
exposedApi,
typeof window !== 'undefined'
? Comlink.windowEndpoint(self.parent)
: undefined
);
return [
setReady,
exposedApi,
];
}

function setupTransferHandlers() {
Comlink.transferHandlers.set('EVENT', {
canHandle: (obj): obj is CustomEvent => obj instanceof CustomEvent,
serialize: (ev: CustomEvent) => {
return [
{
detail: ev.detail,
},
[],
];
},
deserialize: (obj) => obj,
});
Comlink.transferHandlers.set('FUNCTION', {
canHandle: (obj: unknown): obj is Function => typeof obj === 'function',
serialize(obj: Function) {
console.debug('[Comlink][Performance] Proxying a function');
const { port1, port2 } = new MessageChannel();
Comlink.expose(obj, port1);
return [port2, [port2]];
},
deserialize(port: any) {
port.start();
return Comlink.wrap(port);
},
});
}

function proxyClone(object: any) {
return new Proxy(object, {
get(target, prop) {
switch (typeof target[prop]) {
case 'function':
return (...args) => target[prop](...args);
case 'object':
if (target[prop] === null) {
return target[prop];
}
return proxyClone(target[prop]);
case 'undefined':
case 'number':
case 'string':
return target[prop];
default:
return Comlink.proxy(target[prop]);
}
},
});
}
3 changes: 2 additions & 1 deletion packages/php-wasm/src/php-library/index-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
* and re-exports everything from the main PHP module.
*/

import type { PHPLoaderModule } from './php';
import { TextEncoder, TextDecoder } from 'util';
global.TextEncoder = TextEncoder as any;
global.TextDecoder = TextDecoder as any;

export * from './php';

export async function getPHPLoaderModule(version = '8.2') {
export async function getPHPLoaderModule(version = '8.2'): Promise<PHPLoaderModule> {
switch (version) {
case '8.2':
// @ts-ignore
Expand Down
34 changes: 31 additions & 3 deletions packages/php-wasm/src/php-library/index.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,32 @@
export { PHP, startPHP } from './php';
export { PHP, loadPHPRuntime } from './php';
export type {
PHPOutput,
PHPRequest,
PHPResponse,
JavascriptRuntime,
ErrnoError,
ErrnoError,
DataModule,
PHPLoaderModule,
PHPRuntime,
PHPRuntimeId,
EmscriptenOptions,
MountSettings
} from './php';

export { toRelativeUrl } from './urls';

export * from './comlink-utils';
export type * from './comlink-utils';

import PHPServer from './php-server';
export { PHPServer };
export type { PHPServerConfigation, PHPServerRequest } from './php-server';

import PHPBrowser from './php-browser';
import { PHPLoaderModule } from './php';
export { PHPBrowser };

export async function getPHPLoaderModule(version = '8.2') {
export async function getPHPLoaderModule(version = '8.2'): Promise<PHPLoaderModule> {
switch (version) {
case '8.2':
// @ts-ignore
Expand Down Expand Up @@ -47,3 +59,19 @@ export async function getPHPLoaderModule(version = '8.2') {
throw new Error(`Unsupported PHP version ${version}`);
}

export type StartupOptions = Record<string, string>;
export function parseWorkerStartupOptions(): StartupOptions {
// Read the query string startup options
if (typeof self?.location?.href !== 'undefined') {
// Web
const startupOptions: StartupOptions = {};
const params = new URL(self.location.href).searchParams;
params.forEach((value, key) => {
startupOptions[key] = value;
});
return startupOptions;
} else {
// Node.js
return JSON.parse(process.env.WORKER_OPTIONS || '{}');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export function getNextRequestId() {
* @returns The reply from the messageTarget.
*/
export function awaitReply(
messageTarget: EventTarget,
messageTarget: IsomorphicEventTarget,
requestId: number,
timeout: number = DEFAULT_RESPONSE_TIMEOUT
): Promise<any> {
Expand Down Expand Up @@ -135,3 +135,8 @@ export interface MessageResponse<T> {
interface PostMessageTarget {
postMessage(message: any, ...args: any[]): void;
}

interface IsomorphicEventTarget {
addEventListener(type: string, listener: (event: any) => void): void;
removeEventListener(type: string, listener: (event: any) => void): void;
}
39 changes: 23 additions & 16 deletions packages/php-wasm/src/php-library/php-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,34 @@ import type PHPServer from './php-server';
import type { PHPServerRequest } from './php-server';
import type { PHPResponse } from './php';

export interface HandlesRequest {
/**
* Sends the request to the server.
*
* When cookies are present in the response, this method stores
* them and sends them with any subsequent requests.
*
* When a redirection is present in the response, this method
* follows it by discarding a response and sending a subsequent
* request.
*
* @param request - The request.
* @param redirects - Internal. The number of redirects handled so far.
* @returns PHPServer response.
*/
request(
request: PHPServerRequest,
redirects?: number
): Promise<PHPResponse>;
}

/**
* A fake web browser that handles PHPServer's cookies and redirects
* internally without exposing them to the consumer.
*
* @public
*/
export class PHPBrowser {
export class PHPBrowser implements HandlesRequest {
#cookies;
#config;

Expand All @@ -28,23 +49,9 @@ export class PHPBrowser {
};
}

/**
* Sends the request to the server.
*
* When cookies are present in the response, this method stores
* them and sends them with any subsequent requests.
*
* When a redirection is present in the response, this method
* follows it by discarding a response and sending a subsequent
* request.
*
* @param request - The request.
* @param redirects - Internal. The number of redirects handled so far.
* @returns PHPServer response.
*/
async request(
request: PHPServerRequest,
redirects: number = 0
redirects = 0
): Promise<PHPResponse> {
const response = await this.server.request({
...request,
Expand Down
Loading

0 comments on commit bc3daa0

Please sign in to comment.