Skip to content

Commit

Permalink
PHP SAPI module (#107)
Browse files Browse the repository at this point in the history
# What is this PR all about?

Introduces a PHP SAPI module that enables setting `$_POST`, `$_SERVER`, `php://input` and all the other PHP values from JavaScript.

## What problem does it solve?

Before this PR, most superglobal values were set by prepending code snippets like `$_SERVER['DOCUMENT_ROOT'] = ${JSON.stringify(documentRoot)};` every time some code was evaluated. Unfortunately, that technique couldn't populate everything, e.g. `php://input` remained empty.

## How does it work?

PHP SAPI is used to integrate PHP with webservers and runtimes. A few SAPIs you might be familiar with are `php-cgi`, `php-fpm`, and `php-cli`. A SAPI consumes the request information from the runtime, passes it to PHP, triggers the code execution, and passes the response back to the runtime.

This PR introduces a WASM SAPI that accepts input information from JavaScript, sets up a PHP request, and passes a response back to JS. The most important changes are in the `php_wasm.c` file. The rest of the PR is adjusting the existing codebase to the new way of working with PHP.

Briefly speaking, the SAPI module exposes a few setters like `wasm_set_query_string` or `wasm_add_SERVER_entry` and a `wasm_sapi_handle_request()` function that triggers the request execution. The output information are written to `/tmp/stdout`, `/tmp/stderr`, and `/tmp/headers.json` by the C module and read by the PHP JavaScript class.

Because the request body and the query string are parsed by the same PHP internal functions as they would on a webserver, array syntax like `settings[newsletter]=1` is handled correctly.

One surprising thing is the ability to set arbitrary `$_FILES` entries with `wasm_add_uploaded_file`. This is because JavaScript typically has access to any uploaded `File` objects and it would be wasteful to re-serialize them only so that PHP can parse them all over again. With `wasm_add_uploaded_file` you can first write the uploaded files to the filesystem and then simply let PHP know about their existence.

Solves #103
  • Loading branch information
adamziel authored Jan 12, 2023
1 parent 233c66d commit 0074653
Show file tree
Hide file tree
Showing 24 changed files with 1,954 additions and 1,016 deletions.
4 changes: 1 addition & 3 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,7 @@ module.exports = {
// ],

// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// testPathIgnorePatterns: [
// "/node_modules/"
// ],
testPathIgnorePatterns: ['/node_modules/', '__tests__/utils.ts'],

// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
Expand Down
2 changes: 1 addition & 1 deletion src/php-wasm-browser/emscripten-download-monitor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DEFAULT_BASE_URL } from './utils';
import { DEFAULT_BASE_URL } from '../php-wasm/utils';

/*
* An approximate total file size to use when the actual
Expand Down
2 changes: 1 addition & 1 deletion src/php-wasm-browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ export type {
DownloadProgressEvent,
DownloadProgressCallback,
} from './emscripten-download-monitor';
export { DEFAULT_BASE_URL, getPathQueryFragment } from './utils';

82 changes: 55 additions & 27 deletions src/php-wasm-browser/service-worker/worker-library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ declare const self: ServiceWorkerGlobalScope;

import { awaitReply, getNextRequestId } from '../messaging';
import { getURLScope, isURLScoped, removeURLScope } from '../scope';
import { getPathQueryFragment } from '../utils';
import { getPathQueryFragment } from '../../php-wasm/utils';

/**
* Run this function in the service worker to install the required event
Expand Down Expand Up @@ -110,23 +110,26 @@ async function defaultRequestHandler(event) {
export async function PHPRequest(event) {
const url = new URL(event.request.url);

const { post, files } = await parsePost(event.request);
const { body, files, contentType } = await rewritePost(event.request);
const requestHeaders = {};
for (const pair of (event.request.headers as any).entries()) {
requestHeaders[pair[0]] = pair[1];
}

const requestedPath = getPathQueryFragment(url);
let phpResponse;
try {
const message = {
type: 'HTTPRequest',
request: {
path: requestedPath,
method: event.request.method,
body,
files,
_POST: post,
headers: requestHeaders,
absoluteUrl: url.toString(),
method: event.request.method,
headers: {
...requestHeaders,
Host: url.host,
'Content-type': contentType,
},
},
};
console.debug(
Expand All @@ -138,17 +141,22 @@ export async function PHPRequest(event) {
getURLScope(url)
);
phpResponse = await awaitReply(self, requestId);

// X-frame-options gets in a way when PHP is
// being displayed in an iframe.
delete phpResponse.headers['x-frame-options'];

console.debug('[ServiceWorker] Response received from the main app', {
phpResponse,
});
} catch (e) {
console.error(e, { requestedPath });
console.error(e, { url: url.toString() });
throw e;
}

return new Response(phpResponse.body, {
headers: phpResponse.headers,
status: phpResponse.statusCode,
status: phpResponse.httpStatusCode,
});
}

Expand Down Expand Up @@ -236,30 +244,50 @@ function seemsLikeADirectoryRoot(path) {
return !lastSegment.includes('.');
}

async function parsePost(request) {
async function rewritePost(request) {
const contentType = request.headers.get('content-type');
if (request.method !== 'POST') {
return { post: undefined, files: undefined };
return {
contentType,
body: undefined,
files: undefined,
};
}
// Try to parse the body as form data
try {
const formData = await request.clone().formData();
const post = {};
const files = {};

for (const key of formData.keys()) {
const value = formData.get(key);
if (value instanceof File) {
files[key] = value;
} else {
post[key] = value;
// If the request contains multipart form data, rewrite it
// to a regular form data and handle files separately.
const isMultipart = contentType
.toLowerCase()
.startsWith('multipart/form-data');
if (isMultipart) {
try {
const formData = await request.clone().formData();
const post = {};
const files = {};

for (const key of formData.keys()) {
const value = formData.get(key);
if (value instanceof File) {
files[key] = value;
} else {
post[key] = value;
}
}
}

return { post, files };
} catch (e) {}
return {
contentType: 'application/x-www-form-urlencoded',
body: new URLSearchParams(post).toString(),
files,
};
} catch (e) {}
}

// Try to parse the body as JSON
return { post: await request.clone().json(), files: {} };
// Otherwise, grab body as literal text
return {
contentType,
body: await request.clone().text(),
files: {},
};
}

/**
Expand Down
21 changes: 0 additions & 21 deletions src/php-wasm-browser/utils.ts

This file was deleted.

6 changes: 3 additions & 3 deletions src/php-wasm-browser/worker-thread/window-library.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { PHPOutput, PHPRequest, PHPResponse } from '../../php-wasm';
import type { PHPOutput, PHPServerRequest, PHPResponse } from '../../php-wasm';
import {
postMessageExpectReply,
awaitReply,
MessageResponse,
responseTo,
} from '../messaging';
import { removeURLScope } from '../scope';
import { getPathQueryFragment } from '..';
import { getPathQueryFragment } from '../../php-wasm/utils';
import type { DownloadProgressEvent } from '../emscripten-download-monitor';

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
Expand Down Expand Up @@ -152,7 +152,7 @@ export class SpawnedWorkerThread {
* @see {PHP.request}
*/
async HTTPRequest(
request: PHPRequest
request: PHPServerRequest
): Promise<PHPResponse & { text: string }> {
const response = (await this.#rpc('HTTPRequest', {
request,
Expand Down
14 changes: 3 additions & 11 deletions src/php-wasm-browser/worker-thread/worker-library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ declare const window: any; // For the web backend
import { startPHP, PHPBrowser, PHPServer } from '../../php-wasm';
import type { PHP, JavascriptRuntime } from '../../php-wasm';
import { responseTo } from '../messaging';
import { DEFAULT_BASE_URL } from '../utils';
import { DEFAULT_BASE_URL } from '../../php-wasm/utils';
import EmscriptenDownloadMonitor from '../emscripten-download-monitor';
import type { DownloadProgressEvent } from '../emscripten-download-monitor';
import { getURLScope } from '../scope';
import { FileInfo } from '../../php-wasm/php';
export * from '../scope';

/**
Expand Down Expand Up @@ -113,22 +114,13 @@ export async function initializeWorkerThread(
} else if (message.type === 'run') {
return phpBrowser.server.php.run(message.code);
} else if (message.type === 'HTTPRequest') {
return await renderRequest(message.request);
return await phpBrowser.request(message.request);
}
throw new Error(
`[Worker Thread] Received unexpected message: "${message.type}"`
);
}

async function renderRequest(request) {
const parsedUrl = new URL(request.path, DEFAULT_BASE_URL);
return await phpBrowser.request({
...request,
path: parsedUrl.pathname,
queryString: parsedUrl.search,
});
}

return currentBackend;
}

Expand Down
Loading

0 comments on commit 0074653

Please sign in to comment.