Skip to content

Commit

Permalink
Add multisite rewrite rules (#1083)
Browse files Browse the repository at this point in the history
## What is this PR doing?

This PR adds support for multisite URL rewrites.

## What problem is it solving?

It ensures that all types of WordPress URLs work on Playground.

## How is the problem addressed?

By adding support for rewrite rules in PHP WASM and adding a rule to
resolve WordPress multisite URLs.

## Testing Instructions

- Checkout this branch
- [Start a new
multisite](https://playground.test/website-server/#{%20%22landingPage%22:%20%22/test/%22,%20%22phpExtensionBundles%22:%20[%22kitchen-sink%22],%20%22features%22:%20{%20%22networking%22:%20true%20},%20%22steps%22:%20[%20{%20%22step%22:%20%22enableMultisite%22%20},%20{%20%22step%22:%20%22login%22%20},%20{%20%22step%22:%20%22runPHP%22,%20%22code%22:%20%22%3C?php%20require_once%20'wordpress/wp-load.php';%20global%20$playground_scope;%20wp_insert_site(array('path'=%3E%20'/scope:'.$playground_scope.'/test/',%20'domain'=%3E%20parse_url(%20get_site_url(),%20PHP_URL_HOST%20),%20'user_id'=%3E1));%20?%3E%22%20}%20]%20})
(ensure playground.test proxy is running)
- Confirm that the subsite loaded
- Open WP-admin of that site and confirm that all assets loaded
correctly

---------

Co-authored-by: Adam Zieliński <adam@adamziel.com>
  • Loading branch information
bgrgicak and adamziel authored Mar 20, 2024
1 parent 8461072 commit c988f06
Show file tree
Hide file tree
Showing 8 changed files with 149 additions and 123 deletions.
7 changes: 5 additions & 2 deletions packages/php-wasm/universal/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,11 @@ export { rethrowFileSystemError } from './rethrow-file-system-error';
export { isLocalPHP } from './is-local-php';
export { isRemotePHP } from './is-remote-php';

export type { PHPRequestHandlerConfiguration } from './php-request-handler';
export { PHPRequestHandler } from './php-request-handler';
export type {
PHPRequestHandlerConfiguration,
RewriteRule,
} from './php-request-handler';
export { PHPRequestHandler, applyRewriteRules } from './php-request-handler';
export type { PHPBrowserConfiguration } from './php-browser';
export { PHPBrowser } from './php-browser';
export { rotatePHPRuntime } from './rotate-php-runtime';
Expand Down
55 changes: 34 additions & 21 deletions packages/php-wasm/universal/src/lib/php-request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import { PHPResponse } from './php-response';
import { PHPRequest, PHPRunOptions, RequestHandler } from './universal-php';
import { encodeAsMultipart } from './encode-as-multipart';

export type RewriteRule = {
match: RegExp;
replacement: string;
};

export interface PHPRequestHandlerConfiguration {
/**
* The directory in the PHP filesystem where the server will look
Expand All @@ -20,6 +25,11 @@ export interface PHPRequestHandlerConfiguration {
* Request Handler URL. Used to populate $_SERVER details like HTTP_HOST.
*/
absoluteUrl?: string;

/**
* Rewrite rules
*/
rewriteRules?: RewriteRule[];
}

/** @inheritDoc */
Expand All @@ -32,6 +42,7 @@ export class PHPRequestHandler implements RequestHandler {
#PATHNAME: string;
#ABSOLUTE_URL: string;
#semaphore: Semaphore;
rewriteRules: RewriteRule[];

/**
* The PHP instance
Expand All @@ -47,6 +58,7 @@ export class PHPRequestHandler implements RequestHandler {
const {
documentRoot = '/www/',
absoluteUrl = typeof location === 'object' ? location?.href : '',
rewriteRules = [],
} = config;
this.php = php;
this.#DOCROOT = documentRoot;
Expand All @@ -70,6 +82,7 @@ export class PHPRequestHandler implements RequestHandler {
this.#HOST,
this.#PATHNAME,
].join('');
this.rewriteRules = rewriteRules;
}

/** @inheritDoc */
Expand Down Expand Up @@ -110,9 +123,9 @@ export class PHPRequestHandler implements RequestHandler {
isAbsolute ? undefined : DEFAULT_BASE_URL
);

const normalizedRequestedPath = removePathPrefix(
requestedUrl.pathname,
this.#PATHNAME
const normalizedRequestedPath = applyRewriteRules(
removePathPrefix(requestedUrl.pathname, this.#PATHNAME),
this.rewriteRules
);
const fsPath = `${this.#DOCROOT}${normalizedRequestedPath}`;
if (seemsLikeAPHPRequestHandlerPath(fsPath)) {
Expand Down Expand Up @@ -214,24 +227,7 @@ export class PHPRequestHandler implements RequestHandler {

let scriptPath;
try {
/**
* Support .htaccess-like URL rewriting.
* If the request was rewritten by a service worker,
* the pathname requested by the user will be in
* the `requestedUrl.pathname` property, while the
* rewritten target URL will be in `request.headers['x-rewrite-url']`.
*/
let requestedPath = requestedUrl.pathname;
if (request.headers?.['x-rewrite-url']) {
try {
requestedPath = new URL(
request.headers['x-rewrite-url']
).pathname;
} catch (error) {
// Ignore
}
}
scriptPath = this.#resolvePHPFilePath(requestedPath);
scriptPath = this.#resolvePHPFilePath(requestedUrl.pathname);
} catch (error) {
return new PHPResponse(
404,
Expand Down Expand Up @@ -267,6 +263,7 @@ export class PHPRequestHandler implements RequestHandler {
*/
#resolvePHPFilePath(requestedPath: string): string {
let filePath = removePathPrefix(requestedPath, this.#PATHNAME);
filePath = applyRewriteRules(filePath, this.rewriteRules);

if (filePath.includes('.php')) {
// If the path mentions a .php extension, that's our file's path.
Expand Down Expand Up @@ -370,3 +367,19 @@ function seemsLikeADirectoryRoot(path: string) {
const lastSegment = path.split('/').pop();
return !lastSegment!.includes('.');
}

/**
* Applies the given rewrite rules to the given path.
*
* @param path The path to apply the rules to.
* @param rules The rules to apply.
* @returns The path with the rules applied.
*/
export function applyRewriteRules(path: string, rules: RewriteRule[]): string {
for (const rule of rules) {
if (new RegExp(rule.match).test(path)) {
return path.replace(rule.match, rule.replacement);
}
}
return path;
}
136 changes: 38 additions & 98 deletions packages/playground/remote/service-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@

declare const self: ServiceWorkerGlobalScope;

import { getURLScope, removeURLScope, setURLScope } from '@php-wasm/scopes';
import { getURLScope, removeURLScope } from '@php-wasm/scopes';
import { applyRewriteRules } from '@php-wasm/universal';
import {
awaitReply,
convertFetchEventToPHPRequest,
initializeServiceWorker,
cloneRequest,
broadcastMessageExpectReply,
getRequestHeaders,
} from '@php-wasm/web-service-worker';
import { wordPressRewriteRules } from '@wp-playground/wordpress';

if (!(self as any).document) {
// Workaround: vite translates import.meta.url
Expand Down Expand Up @@ -47,66 +48,44 @@ initializeServiceWorker({

const { staticAssetsDirectory } = await getScopedWpDetails(scope!);

let workerResponse = await convertFetchEventToPHPRequest(event);
// If we get a 404, try to apply the WordPress URL rewrite rules.
let rewrittenUrlString: string | undefined = undefined;
if (workerResponse.status === 404) {
for (const url of rewriteWordPressUrl(unscopedUrl, scope!)) {
rewrittenUrlString = url.toString();
workerResponse = await convertFetchEventToPHPRequest(
await cloneFetchEvent(event, rewrittenUrlString)
);
if (
workerResponse.status !== 404 ||
workerResponse.headers.get('x-file-type') === 'static'
) {
break;
}
const workerResponse = await convertFetchEventToPHPRequest(event);
if (
workerResponse.status === 404 &&
workerResponse.headers.get('x-file-type') === 'static'
) {
// If we get a 404 for a static file, try to fetch it from
// the from the static assets directory at the remote server.
const requestedUrl = new URL(event.request.url);
const resolvedUrl = removeURLScope(requestedUrl);
resolvedUrl.pathname = applyRewriteRules(
resolvedUrl.pathname,
wordPressRewriteRules
);
if (
// Vite dev server requests
!resolvedUrl.pathname.startsWith('/@fs') &&
!resolvedUrl.pathname.startsWith('/assets')
) {
resolvedUrl.pathname = `/${staticAssetsDirectory}${resolvedUrl.pathname}`;
}
}

if (workerResponse.status === 404) {
if (workerResponse.headers.get('x-file-type') === 'static') {
// If we get a 404 for a static file, try to fetch it from
// the from the static assets directory at the remote server.
const requestedUrl = new URL(
rewrittenUrlString || event.request.url
);
const resolvedUrl = removeURLScope(requestedUrl);
if (
// Vite dev server requests
!resolvedUrl.pathname.startsWith('/@fs') &&
!resolvedUrl.pathname.startsWith('/assets')
) {
resolvedUrl.pathname = `/${staticAssetsDirectory}${resolvedUrl.pathname}`;
const request = await cloneRequest(event.request, {
url: resolvedUrl,
});
return fetch(request).catch((e) => {
if (e?.name === 'TypeError') {
// This could be an ERR_HTTP2_PROTOCOL_ERROR that sometimes
// happen on playground.wordpress.net. Let's add a randomized
// delay and retry once
return new Promise((resolve) => {
setTimeout(() => {
resolve(fetch(request));
}, Math.random() * 1500);
}) as Promise<Response>;
}
const request = await cloneRequest(event.request, {
url: resolvedUrl,
});
return fetch(request).catch((e) => {
if (e?.name === 'TypeError') {
// This could be an ERR_HTTP2_PROTOCOL_ERROR that sometimes
// happen on playground.wordpress.net. Let's add a randomized
// delay and retry once
return new Promise((resolve) => {
setTimeout(() => {
resolve(fetch(request));
}, Math.random() * 1500);
}) as Promise<Response>;
}

// Otherwise let's just re-throw the error
throw e;
});
} else {
const indexPhp = setURLScope(
new URL('/index.php', unscopedUrl),
scope!
);
workerResponse = await convertFetchEventToPHPRequest(
await cloneFetchEvent(event, indexPhp.toString())
);
}
// Otherwise let's just re-throw the error
throw e;
});
}

// Path the block-editor.js file to ensure the site editor's iframe
Expand Down Expand Up @@ -240,49 +219,10 @@ function emptyHtml() {
);
}

async function cloneFetchEvent(event: FetchEvent, rewriteUrl: string) {
return new FetchEvent(event.type, {
...event,
request: await cloneRequest(event.request, {
headers: {
...getRequestHeaders(event.request),
'x-rewrite-url': rewriteUrl,
},
}),
});
}

type WPModuleDetails = {
staticAssetsDirectory: string;
};

/**
* Rewrite the URL according to WordPress .htaccess rules.
*/
function* rewriteWordPressUrl(unscopedUrl: URL, scope: string) {
// RewriteRule ^([_0-9a-zA-Z-]+/)?(wp-(content|admin|includes).*) wordpress/$2 [L]
const rewrittenUrl = unscopedUrl.pathname
.toString()
.replace(
/^\/([_0-9a-zA-Z-]+\/)?(wp-(content|admin|includes).*)/,
'/$2'
);
if (rewrittenUrl !== unscopedUrl.pathname) {
// Something changed, let's try the rewritten URL
const url = new URL(rewrittenUrl, unscopedUrl);
yield setURLScope(url, scope);
}

// RewriteRule ^([_0-9a-zA-Z-]+/)?(.*\.php)$ wordpress/$2 [L]
if (unscopedUrl.pathname.endsWith('.php')) {
// The URL ends with .php, let's try to rewrite it to
// a .php file in the WordPress root directory
const filename = unscopedUrl.pathname.split('/').pop();
const url = new URL('/' + filename, unscopedUrl);
yield setURLScope(url, scope);
}
}

const scopeToWpModule: Record<string, WPModuleDetails> = {};
async function getScopedWpDetails(scope: string): Promise<WPModuleDetails> {
if (!scopeToWpModule[scope]) {
Expand Down
9 changes: 7 additions & 2 deletions packages/playground/remote/src/lib/worker-thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
LatestSupportedWordPressVersion,
SupportedWordPressVersions,
SupportedWordPressVersionsList,
wordPressRewriteRules,
} from '@wp-playground/wordpress';
import {
PHPResponse,
Expand Down Expand Up @@ -120,6 +121,7 @@ if (!wordPressAvailableInOPFS) {
const php = new WebPHP(undefined, {
documentRoot: DOCROOT,
absoluteUrl: scopedSiteUrl,
rewriteRules: wordPressRewriteRules,
});

const recreateRuntime = async () =>
Expand All @@ -128,6 +130,9 @@ const recreateRuntime = async () =>
// We don't yet support loading specific PHP extensions one-by-one.
// Let's just indicate whether we want to load all of them.
loadAllExtensions: phpExtensions?.length > 0,
requestHandler: {
rewriteRules: wordPressRewriteRules,
},
});

// Rotate the PHP runtime periodically to avoid memory leak-related crashes.
Expand Down Expand Up @@ -380,13 +385,13 @@ try {
// @TODO: Run the actual PHP CLI SAPI instead of
// interpreting the arguments and emulating
// the CLI constants and globals.
const cliBootstrapScript = `<?php
const cliBootstrapScript = `<?php
// Set the argv global.
$GLOBALS['argv'] = array_merge([
"/wordpress/wp-cli.phar",
"--path=/wordpress"
], ${phpVar(args.slice(2))});
// Provide stdin, stdout, stderr streams outside of
// the CLI SAPI.
define('STDIN', fopen('php://stdin', 'rb'));
Expand Down
3 changes: 3 additions & 0 deletions packages/playground/wordpress/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,8 @@
"engines": {
"node": ">=18.18.2",
"npm": ">=8.11.0"
},
"dependencies": {
"@php-wasm/universal": "^0.6.6"
}
}
1 change: 1 addition & 0 deletions packages/playground/wordpress/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { getWordPressModuleDetails } from './wordpress/get-wordpress-module-details';
export { getWordPressModule } from './wordpress/get-wordpress-module';
export * from './rewrite-rules';
import SupportedWordPressVersions from './wordpress/wp-versions.json';

export { SupportedWordPressVersions };
Expand Down
11 changes: 11 additions & 0 deletions packages/playground/wordpress/src/rewrite-rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { RewriteRule } from '@php-wasm/universal';

/**
* The default rewrite rules for WordPress.
*/
export const wordPressRewriteRules: RewriteRule[] = [
{
match: /^\/(.*?)(\/wp-(content|admin|includes).*)/g,
replacement: '$2',
},
];
Loading

0 comments on commit c988f06

Please sign in to comment.