Skip to content

Commit c988f06

Browse files
bgrgicakadamziel
andauthored
Add multisite rewrite rules (#1083)
## 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>
1 parent 8461072 commit c988f06

File tree

8 files changed

+149
-123
lines changed

8 files changed

+149
-123
lines changed

packages/php-wasm/universal/src/lib/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,11 @@ export { rethrowFileSystemError } from './rethrow-file-system-error';
5454
export { isLocalPHP } from './is-local-php';
5555
export { isRemotePHP } from './is-remote-php';
5656

57-
export type { PHPRequestHandlerConfiguration } from './php-request-handler';
58-
export { PHPRequestHandler } from './php-request-handler';
57+
export type {
58+
PHPRequestHandlerConfiguration,
59+
RewriteRule,
60+
} from './php-request-handler';
61+
export { PHPRequestHandler, applyRewriteRules } from './php-request-handler';
5962
export type { PHPBrowserConfiguration } from './php-browser';
6063
export { PHPBrowser } from './php-browser';
6164
export { rotatePHPRuntime } from './rotate-php-runtime';

packages/php-wasm/universal/src/lib/php-request-handler.ts

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import { PHPResponse } from './php-response';
1010
import { PHPRequest, PHPRunOptions, RequestHandler } from './universal-php';
1111
import { encodeAsMultipart } from './encode-as-multipart';
1212

13+
export type RewriteRule = {
14+
match: RegExp;
15+
replacement: string;
16+
};
17+
1318
export interface PHPRequestHandlerConfiguration {
1419
/**
1520
* The directory in the PHP filesystem where the server will look
@@ -20,6 +25,11 @@ export interface PHPRequestHandlerConfiguration {
2025
* Request Handler URL. Used to populate $_SERVER details like HTTP_HOST.
2126
*/
2227
absoluteUrl?: string;
28+
29+
/**
30+
* Rewrite rules
31+
*/
32+
rewriteRules?: RewriteRule[];
2333
}
2434

2535
/** @inheritDoc */
@@ -32,6 +42,7 @@ export class PHPRequestHandler implements RequestHandler {
3242
#PATHNAME: string;
3343
#ABSOLUTE_URL: string;
3444
#semaphore: Semaphore;
45+
rewriteRules: RewriteRule[];
3546

3647
/**
3748
* The PHP instance
@@ -47,6 +58,7 @@ export class PHPRequestHandler implements RequestHandler {
4758
const {
4859
documentRoot = '/www/',
4960
absoluteUrl = typeof location === 'object' ? location?.href : '',
61+
rewriteRules = [],
5062
} = config;
5163
this.php = php;
5264
this.#DOCROOT = documentRoot;
@@ -70,6 +82,7 @@ export class PHPRequestHandler implements RequestHandler {
7082
this.#HOST,
7183
this.#PATHNAME,
7284
].join('');
85+
this.rewriteRules = rewriteRules;
7386
}
7487

7588
/** @inheritDoc */
@@ -110,9 +123,9 @@ export class PHPRequestHandler implements RequestHandler {
110123
isAbsolute ? undefined : DEFAULT_BASE_URL
111124
);
112125

113-
const normalizedRequestedPath = removePathPrefix(
114-
requestedUrl.pathname,
115-
this.#PATHNAME
126+
const normalizedRequestedPath = applyRewriteRules(
127+
removePathPrefix(requestedUrl.pathname, this.#PATHNAME),
128+
this.rewriteRules
116129
);
117130
const fsPath = `${this.#DOCROOT}${normalizedRequestedPath}`;
118131
if (seemsLikeAPHPRequestHandlerPath(fsPath)) {
@@ -214,24 +227,7 @@ export class PHPRequestHandler implements RequestHandler {
214227

215228
let scriptPath;
216229
try {
217-
/**
218-
* Support .htaccess-like URL rewriting.
219-
* If the request was rewritten by a service worker,
220-
* the pathname requested by the user will be in
221-
* the `requestedUrl.pathname` property, while the
222-
* rewritten target URL will be in `request.headers['x-rewrite-url']`.
223-
*/
224-
let requestedPath = requestedUrl.pathname;
225-
if (request.headers?.['x-rewrite-url']) {
226-
try {
227-
requestedPath = new URL(
228-
request.headers['x-rewrite-url']
229-
).pathname;
230-
} catch (error) {
231-
// Ignore
232-
}
233-
}
234-
scriptPath = this.#resolvePHPFilePath(requestedPath);
230+
scriptPath = this.#resolvePHPFilePath(requestedUrl.pathname);
235231
} catch (error) {
236232
return new PHPResponse(
237233
404,
@@ -267,6 +263,7 @@ export class PHPRequestHandler implements RequestHandler {
267263
*/
268264
#resolvePHPFilePath(requestedPath: string): string {
269265
let filePath = removePathPrefix(requestedPath, this.#PATHNAME);
266+
filePath = applyRewriteRules(filePath, this.rewriteRules);
270267

271268
if (filePath.includes('.php')) {
272269
// If the path mentions a .php extension, that's our file's path.
@@ -370,3 +367,19 @@ function seemsLikeADirectoryRoot(path: string) {
370367
const lastSegment = path.split('/').pop();
371368
return !lastSegment!.includes('.');
372369
}
370+
371+
/**
372+
* Applies the given rewrite rules to the given path.
373+
*
374+
* @param path The path to apply the rules to.
375+
* @param rules The rules to apply.
376+
* @returns The path with the rules applied.
377+
*/
378+
export function applyRewriteRules(path: string, rules: RewriteRule[]): string {
379+
for (const rule of rules) {
380+
if (new RegExp(rule.match).test(path)) {
381+
return path.replace(rule.match, rule.replacement);
382+
}
383+
}
384+
return path;
385+
}

packages/playground/remote/service-worker.ts

Lines changed: 38 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,16 @@
22

33
declare const self: ServiceWorkerGlobalScope;
44

5-
import { getURLScope, removeURLScope, setURLScope } from '@php-wasm/scopes';
5+
import { getURLScope, removeURLScope } from '@php-wasm/scopes';
6+
import { applyRewriteRules } from '@php-wasm/universal';
67
import {
78
awaitReply,
89
convertFetchEventToPHPRequest,
910
initializeServiceWorker,
1011
cloneRequest,
1112
broadcastMessageExpectReply,
12-
getRequestHeaders,
1313
} from '@php-wasm/web-service-worker';
14+
import { wordPressRewriteRules } from '@wp-playground/wordpress';
1415

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

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

50-
let workerResponse = await convertFetchEventToPHPRequest(event);
51-
// If we get a 404, try to apply the WordPress URL rewrite rules.
52-
let rewrittenUrlString: string | undefined = undefined;
53-
if (workerResponse.status === 404) {
54-
for (const url of rewriteWordPressUrl(unscopedUrl, scope!)) {
55-
rewrittenUrlString = url.toString();
56-
workerResponse = await convertFetchEventToPHPRequest(
57-
await cloneFetchEvent(event, rewrittenUrlString)
58-
);
59-
if (
60-
workerResponse.status !== 404 ||
61-
workerResponse.headers.get('x-file-type') === 'static'
62-
) {
63-
break;
64-
}
51+
const workerResponse = await convertFetchEventToPHPRequest(event);
52+
if (
53+
workerResponse.status === 404 &&
54+
workerResponse.headers.get('x-file-type') === 'static'
55+
) {
56+
// If we get a 404 for a static file, try to fetch it from
57+
// the from the static assets directory at the remote server.
58+
const requestedUrl = new URL(event.request.url);
59+
const resolvedUrl = removeURLScope(requestedUrl);
60+
resolvedUrl.pathname = applyRewriteRules(
61+
resolvedUrl.pathname,
62+
wordPressRewriteRules
63+
);
64+
if (
65+
// Vite dev server requests
66+
!resolvedUrl.pathname.startsWith('/@fs') &&
67+
!resolvedUrl.pathname.startsWith('/assets')
68+
) {
69+
resolvedUrl.pathname = `/${staticAssetsDirectory}${resolvedUrl.pathname}`;
6570
}
66-
}
67-
68-
if (workerResponse.status === 404) {
69-
if (workerResponse.headers.get('x-file-type') === 'static') {
70-
// If we get a 404 for a static file, try to fetch it from
71-
// the from the static assets directory at the remote server.
72-
const requestedUrl = new URL(
73-
rewrittenUrlString || event.request.url
74-
);
75-
const resolvedUrl = removeURLScope(requestedUrl);
76-
if (
77-
// Vite dev server requests
78-
!resolvedUrl.pathname.startsWith('/@fs') &&
79-
!resolvedUrl.pathname.startsWith('/assets')
80-
) {
81-
resolvedUrl.pathname = `/${staticAssetsDirectory}${resolvedUrl.pathname}`;
71+
const request = await cloneRequest(event.request, {
72+
url: resolvedUrl,
73+
});
74+
return fetch(request).catch((e) => {
75+
if (e?.name === 'TypeError') {
76+
// This could be an ERR_HTTP2_PROTOCOL_ERROR that sometimes
77+
// happen on playground.wordpress.net. Let's add a randomized
78+
// delay and retry once
79+
return new Promise((resolve) => {
80+
setTimeout(() => {
81+
resolve(fetch(request));
82+
}, Math.random() * 1500);
83+
}) as Promise<Response>;
8284
}
83-
const request = await cloneRequest(event.request, {
84-
url: resolvedUrl,
85-
});
86-
return fetch(request).catch((e) => {
87-
if (e?.name === 'TypeError') {
88-
// This could be an ERR_HTTP2_PROTOCOL_ERROR that sometimes
89-
// happen on playground.wordpress.net. Let's add a randomized
90-
// delay and retry once
91-
return new Promise((resolve) => {
92-
setTimeout(() => {
93-
resolve(fetch(request));
94-
}, Math.random() * 1500);
95-
}) as Promise<Response>;
96-
}
9785

98-
// Otherwise let's just re-throw the error
99-
throw e;
100-
});
101-
} else {
102-
const indexPhp = setURLScope(
103-
new URL('/index.php', unscopedUrl),
104-
scope!
105-
);
106-
workerResponse = await convertFetchEventToPHPRequest(
107-
await cloneFetchEvent(event, indexPhp.toString())
108-
);
109-
}
86+
// Otherwise let's just re-throw the error
87+
throw e;
88+
});
11089
}
11190

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

243-
async function cloneFetchEvent(event: FetchEvent, rewriteUrl: string) {
244-
return new FetchEvent(event.type, {
245-
...event,
246-
request: await cloneRequest(event.request, {
247-
headers: {
248-
...getRequestHeaders(event.request),
249-
'x-rewrite-url': rewriteUrl,
250-
},
251-
}),
252-
});
253-
}
254-
255222
type WPModuleDetails = {
256223
staticAssetsDirectory: string;
257224
};
258225

259-
/**
260-
* Rewrite the URL according to WordPress .htaccess rules.
261-
*/
262-
function* rewriteWordPressUrl(unscopedUrl: URL, scope: string) {
263-
// RewriteRule ^([_0-9a-zA-Z-]+/)?(wp-(content|admin|includes).*) wordpress/$2 [L]
264-
const rewrittenUrl = unscopedUrl.pathname
265-
.toString()
266-
.replace(
267-
/^\/([_0-9a-zA-Z-]+\/)?(wp-(content|admin|includes).*)/,
268-
'/$2'
269-
);
270-
if (rewrittenUrl !== unscopedUrl.pathname) {
271-
// Something changed, let's try the rewritten URL
272-
const url = new URL(rewrittenUrl, unscopedUrl);
273-
yield setURLScope(url, scope);
274-
}
275-
276-
// RewriteRule ^([_0-9a-zA-Z-]+/)?(.*\.php)$ wordpress/$2 [L]
277-
if (unscopedUrl.pathname.endsWith('.php')) {
278-
// The URL ends with .php, let's try to rewrite it to
279-
// a .php file in the WordPress root directory
280-
const filename = unscopedUrl.pathname.split('/').pop();
281-
const url = new URL('/' + filename, unscopedUrl);
282-
yield setURLScope(url, scope);
283-
}
284-
}
285-
286226
const scopeToWpModule: Record<string, WPModuleDetails> = {};
287227
async function getScopedWpDetails(scope: string): Promise<WPModuleDetails> {
288228
if (!scopeToWpModule[scope]) {

packages/playground/remote/src/lib/worker-thread.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
LatestSupportedWordPressVersion,
88
SupportedWordPressVersions,
99
SupportedWordPressVersionsList,
10+
wordPressRewriteRules,
1011
} from '@wp-playground/wordpress';
1112
import {
1213
PHPResponse,
@@ -120,6 +121,7 @@ if (!wordPressAvailableInOPFS) {
120121
const php = new WebPHP(undefined, {
121122
documentRoot: DOCROOT,
122123
absoluteUrl: scopedSiteUrl,
124+
rewriteRules: wordPressRewriteRules,
123125
});
124126

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

133138
// Rotate the PHP runtime periodically to avoid memory leak-related crashes.
@@ -380,13 +385,13 @@ try {
380385
// @TODO: Run the actual PHP CLI SAPI instead of
381386
// interpreting the arguments and emulating
382387
// the CLI constants and globals.
383-
const cliBootstrapScript = `<?php
388+
const cliBootstrapScript = `<?php
384389
// Set the argv global.
385390
$GLOBALS['argv'] = array_merge([
386391
"/wordpress/wp-cli.phar",
387392
"--path=/wordpress"
388393
], ${phpVar(args.slice(2))});
389-
394+
390395
// Provide stdin, stdout, stderr streams outside of
391396
// the CLI SAPI.
392397
define('STDIN', fopen('php://stdin', 'rb'));

packages/playground/wordpress/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,8 @@
2424
"engines": {
2525
"node": ">=18.18.2",
2626
"npm": ">=8.11.0"
27+
},
28+
"dependencies": {
29+
"@php-wasm/universal": "^0.6.6"
2730
}
2831
}

packages/playground/wordpress/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { getWordPressModuleDetails } from './wordpress/get-wordpress-module-details';
22
export { getWordPressModule } from './wordpress/get-wordpress-module';
3+
export * from './rewrite-rules';
34
import SupportedWordPressVersions from './wordpress/wp-versions.json';
45

56
export { SupportedWordPressVersions };
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { RewriteRule } from '@php-wasm/universal';
2+
3+
/**
4+
* The default rewrite rules for WordPress.
5+
*/
6+
export const wordPressRewriteRules: RewriteRule[] = [
7+
{
8+
match: /^\/(.*?)(\/wp-(content|admin|includes).*)/g,
9+
replacement: '$2',
10+
},
11+
];

0 commit comments

Comments
 (0)