Skip to content

Commit 22a000d

Browse files
committed
Route requests for missing static files using remote asset metadata - Second attempt (#1490)
Prior to this PR, we could not easily tell whether we should request a missing static asset from the web or delegate a request for a missing file to WordPress. Related to #1365 where we need better information to judge whether to handle a request for a missing static file as PHP. This PR: - Includes a list of remote asset paths with minified WP builds - Updates request handling to only handle missing files as static when they are explicitly listed as remote assets (perhaps this should be split into multiple PRs) - Updates request routing to behave more like a regular web server By including a list of remote asset paths with minified WP builds, we can judge which missing static file paths represent remote assets and which are truly missing and need to be delegated to WordPress. - CI tests - Test manually with `npm run dev` and observe that Playground loads normally with no unexpected errors in the console
1 parent bd72f2f commit 22a000d

File tree

10 files changed

+310
-77
lines changed

10 files changed

+310
-77
lines changed

packages/php-wasm/node/src/test/php-request-handler.spec.ts

+107-11
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,27 @@ describe.each(SupportedPHPVersions)(
3939
});
4040
});
4141

42+
it('should execute a non-default PHP file in a directory', async () => {
43+
php.mkdirTree('/folder');
44+
php.writeFile(
45+
'/folder/some.php',
46+
`<?php echo 'Some PHP file in a folder.';`
47+
);
48+
const response = await handler.request({
49+
url: '/folder/some.php',
50+
});
51+
expect(response).toEqual({
52+
httpStatusCode: 200,
53+
headers: {
54+
'content-type': ['text/html; charset=UTF-8'],
55+
'x-powered-by': [expect.any(String)],
56+
},
57+
bytes: new TextEncoder().encode('Some PHP file in a folder.'),
58+
errors: '',
59+
exitCode: 0,
60+
});
61+
});
62+
4263
it('should serve a static file', async () => {
4364
php.writeFile('/index.html', `Hello World`);
4465
const response = await handler.request({
@@ -102,7 +123,8 @@ describe.each(SupportedPHPVersions)(
102123
});
103124
});
104125

105-
it('should yield x-file-type=static when a static file is not found', async () => {
126+
it('should yield x-file-type=static when a static file is not found and is listed as a remote asset', async () => {
127+
handler.addRemoteAssetPaths(['index.html']);
106128
const response = await handler.request({
107129
url: '/index.html',
108130
});
@@ -117,6 +139,19 @@ describe.each(SupportedPHPVersions)(
117139
});
118140
});
119141

142+
it('should not yield x-file-type=static when a static file is not found and is not listed as a remote asset', async () => {
143+
const response = await handler.request({
144+
url: '/index.html',
145+
});
146+
expect(response).toEqual({
147+
httpStatusCode: 404,
148+
headers: {},
149+
bytes: expect.any(Uint8Array),
150+
errors: '',
151+
exitCode: 0,
152+
});
153+
});
154+
120155
it('should not yield x-file-type=static when a PHP file is not found', async () => {
121156
const response = await handler.request({
122157
url: '/index.php',
@@ -130,6 +165,77 @@ describe.each(SupportedPHPVersions)(
130165
});
131166
});
132167

168+
it('should redirect to add trailing slash to existing dir', async () => {
169+
php.mkdirTree('/folder');
170+
const response = await handler.request({
171+
url: '/folder',
172+
});
173+
expect(response).toEqual({
174+
httpStatusCode: 301,
175+
headers: {
176+
Location: ['/folder/'],
177+
},
178+
bytes: expect.any(Uint8Array),
179+
errors: '',
180+
exitCode: 0,
181+
});
182+
});
183+
184+
it('should return 200 and pass query strings when a valid request is made to a folder', async () => {
185+
php.mkdirTree('/folder');
186+
php.writeFile('/folder/index.php', `<?php echo $_GET['key'];`);
187+
const response = await handler.request({
188+
url: '/folder/?key=value',
189+
});
190+
expect(response.httpStatusCode).toEqual(200);
191+
expect(response.text).toEqual('value');
192+
});
193+
194+
it('should default a folder request to index.html if it exists and index.php does not', async () => {
195+
php.mkdirTree('/folder');
196+
php.writeFile('/folder/index.html', `INDEX DOT HTML`);
197+
const response = await handler.request({
198+
url: '/folder/?key=value',
199+
});
200+
expect(response.httpStatusCode).toEqual(200);
201+
expect(response.text).toEqual('INDEX DOT HTML');
202+
});
203+
204+
it('should default a folder request to index.php when when both index.php and index.html exist', async () => {
205+
php.mkdirTree('/folder');
206+
php.writeFile('/folder/index.php', `INDEX DOT PHP`);
207+
php.writeFile('/folder/index.html', `INDEX DOT HTML`);
208+
const response = await handler.request({
209+
url: '/folder/?key=value',
210+
});
211+
expect(response.httpStatusCode).toEqual(200);
212+
expect(response.text).toEqual('INDEX DOT PHP');
213+
});
214+
215+
it('should delegate request for non-existent PHP file to /index.php with query args', async () => {
216+
php.writeFile(
217+
'/index.php',
218+
`<?php echo "DEFAULT with key={$_GET['key']}";`
219+
);
220+
const response = await handler.request({
221+
url: '/non/existent/file.php?key=value',
222+
});
223+
expect(response.httpStatusCode).toEqual(200);
224+
expect(response.text).toEqual('DEFAULT with key=value');
225+
});
226+
227+
it('should delegate request for non-existent non-PHP file to /index.php with query args', async () => {
228+
php.writeFile(
229+
'/index.php',
230+
`<?php echo "DEFAULT with key={$_GET['key']}";`
231+
);
232+
const response = await handler.request({
233+
url: '/non/existent/file?key=value',
234+
});
235+
expect(response.httpStatusCode).toEqual(200);
236+
expect(response.text).toEqual('DEFAULT with key=value');
237+
});
238+
133239
it('should return httpStatus 500 if exit code is not 0', async () => {
134240
php.writeFile(
135241
'/index.php',
@@ -301,16 +407,6 @@ describe.each(SupportedPHPVersions)(
301407
expect(response.httpStatusCode).toEqual(200);
302408
expect(response.text).toEqual('value');
303409
});
304-
305-
it('should return 200 and pass query strings when a valid request is made to a folder', async () => {
306-
php.mkdirTree('/folder');
307-
php.writeFile('/folder/index.php', `<?php echo $_GET['key'];`);
308-
const response = await handler.request({
309-
url: '/folder/?key=value',
310-
});
311-
expect(response.httpStatusCode).toEqual(200);
312-
expect(response.text).toEqual('value');
313-
});
314410
}
315411
);
316412

packages/php-wasm/node/src/test/php.spec.ts

+10
Original file line numberDiff line numberDiff line change
@@ -934,6 +934,16 @@ describe.each(SupportedPHPVersions)('PHP %s', (phpVersion) => {
934934
expect(php.isDir(testFilePath)).toEqual(false);
935935
});
936936

937+
it('isFile() should correctly distinguish between a file and a directory', () => {
938+
php.writeFile(testFilePath, 'Hello World!');
939+
expect(php.fileExists(testFilePath)).toEqual(true);
940+
expect(php.isFile(testFilePath)).toEqual(true);
941+
942+
php.mkdir(testDirPath);
943+
expect(php.fileExists(testDirPath)).toEqual(true);
944+
expect(php.isFile(testDirPath)).toEqual(false);
945+
});
946+
937947
it('listFiles() should return a list of files in a directory', () => {
938948
php.mkdir(testDirPath);
939949
php.writeFile(testDirPath + '/test.txt', 'Hello World!');

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

+95-47
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ interface BaseConfiguration {
3838
* Rewrite rules
3939
*/
4040
rewriteRules?: RewriteRule[];
41+
42+
// TODO: Can we reduce coupling between the request handler and WP?
43+
/**
44+
* Answers whether the given path is a known remote asset path
45+
* for a minified WP build.
46+
*/
47+
isKnownRemoteAssetPath?: (wpRelativePath: string) => boolean;
4148
}
4249

4350
export type PHPRequestHandlerFactoryArgs = PHPFactoryOptions & {
@@ -137,6 +144,8 @@ export class PHPRequestHandler {
137144
#cookieStore: HttpCookieStore;
138145
rewriteRules: RewriteRule[];
139146
processManager: PHPProcessManager;
147+
// TODO: Can we reduce coupling between the request handler and WP?
148+
isKnownRemoteAssetPath: (wpRelativePath: string) => boolean = () => false;
140149

141150
/**
142151
* The request handler needs to decide whether to serve a static asset or
@@ -194,6 +203,10 @@ export class PHPRequestHandler {
194203
this.#PATHNAME,
195204
].join('');
196205
this.rewriteRules = rewriteRules;
206+
207+
if (config.isKnownRemoteAssetPath) {
208+
this.isKnownRemoteAssetPath = config.isKnownRemoteAssetPath;
209+
}
197210
}
198211

199212
async getPrimaryPhp() {
@@ -306,14 +319,89 @@ export class PHPRequestHandler {
306319
),
307320
this.rewriteRules
308321
);
309-
const fsPath = joinPaths(this.#DOCROOT, normalizedRequestedPath);
310-
if (!seemsLikeAPHPRequestHandlerPath(fsPath)) {
311-
return this.#serveStaticFile(
312-
await this.processManager.getPrimaryPhp(),
313-
fsPath
314-
);
322+
323+
const primaryPhp = await this.getPrimaryPhp();
324+
325+
let fsPath = joinPaths(this.#DOCROOT, normalizedRequestedPath);
326+
327+
if (primaryPhp.isDir(fsPath)) {
328+
// Ensure directory URIs have a trailing slash. Otherwise,
329+
// relative URIs in index.php or index.html files are relative
330+
// to the next directory up.
331+
//
332+
// Example:
333+
// For a request to "/wp-admin", the relative link "edit.php"
334+
// resolves to "/edit.php" rather than "/wp-admin/edit.php".
335+
//
336+
// This is correct behavior for the browser:
337+
// https://www.rfc-editor.org/rfc/rfc3986#section-5.2.3
338+
//
339+
// But the intent for `/wp-admin/index.php` is that its relative
340+
// URIs are relative to `/wp-admin/`.
341+
//
342+
// In fact, WordPress also redirects like this when given a chance.
343+
// - https://github.com/WordPress/wordpress-develop/blob/b6a3b9c7d1ce33cbeca6f95871a26c48141e524b/src/wp-includes/canonical.php#L696
344+
// - https://github.com/WordPress/wordpress-develop/blob/b6a3b9c7d1ce33cbeca6f95871a26c48141e524b/src/wp-includes/canonical.php#L1036-L1045
345+
// - https://github.com/WordPress/wordpress-develop/blob/b6a3b9c7d1ce33cbeca6f95871a26c48141e524b/src/wp-includes/link-template.php#L3558
346+
if (!fsPath.endsWith('/')) {
347+
return new PHPResponse(
348+
301,
349+
{ Location: [`${requestedUrl.pathname}/`] },
350+
new Uint8Array(0)
351+
);
352+
}
353+
354+
// We can only satisfy requests for directories with a default file
355+
// so let's first resolve to a default path when available.
356+
const defaultFilePath = ['index.php', 'index.html']
357+
.map((defaultFilename) => joinPaths(fsPath, defaultFilename))
358+
.find((possibleDefaultPath) =>
359+
primaryPhp.isFile(possibleDefaultPath)
360+
);
361+
362+
if (defaultFilePath) {
363+
fsPath = defaultFilePath;
364+
}
365+
}
366+
367+
if (fsPath.endsWith('.php')) {
368+
if (primaryPhp.isFile(fsPath)) {
369+
const effectiveRequest: PHPRequest = {
370+
...request,
371+
url: joinPaths(this.#ABSOLUTE_URL, fsPath),
372+
};
373+
return this.#spawnPHPAndDispatchRequest(
374+
effectiveRequest,
375+
requestedUrl
376+
);
377+
}
378+
} else {
379+
if (primaryPhp.isFile(fsPath)) {
380+
return this.#serveStaticFile(primaryPhp, fsPath);
381+
} else if (
382+
// Make sure fsPath doesn't describe any other entity on the filesystem
383+
!primaryPhp.fileExists(fsPath) &&
384+
this.isKnownRemoteAssetPath(normalizedRequestedPath)
385+
) {
386+
// This path is listed as a remote asset. Mark it as a static file
387+
// so the service worker knows it can issue a real fetch() to the server.
388+
return new PHPResponse(
389+
404,
390+
{ 'x-file-type': ['static'] },
391+
new TextEncoder().encode('404 File not found')
392+
);
393+
}
315394
}
316-
return this.#spawnPHPAndDispatchRequest(request, requestedUrl);
395+
396+
// TODO: Can we adjust this default to reduce coupling between the request handler and WP?
397+
// Delegate unresolved requests to WordPress. This makes WP magic possible,
398+
// like pretty permalinks and dynamically generated sitemaps.
399+
const wpDefaultPath = joinPaths(this.#DOCROOT, 'index.php');
400+
const effectiveRequest: PHPRequest = {
401+
...request,
402+
url: joinPaths(this.#ABSOLUTE_URL, wpDefaultPath),
403+
};
404+
return this.#spawnPHPAndDispatchRequest(effectiveRequest, requestedUrl);
317405
}
318406

319407
/**
@@ -323,17 +411,6 @@ export class PHPRequestHandler {
323411
* @returns The response.
324412
*/
325413
#serveStaticFile(php: PHP, fsPath: string): PHPResponse {
326-
if (!php.fileExists(fsPath)) {
327-
return new PHPResponse(
328-
404,
329-
// Let the service worker know that no static file was found
330-
// and that it's okay to issue a real fetch() to the server.
331-
{
332-
'x-file-type': ['static'],
333-
},
334-
new TextEncoder().encode('404 File not found')
335-
);
336-
}
337414
const arrayBuffer = php.readFileAsBuffer(fsPath);
338415
return new PHPResponse(
339416
200,
@@ -503,35 +580,6 @@ function inferMimeType(path: string): string {
503580
return mimeTypes[extension] || mimeTypes['_default'];
504581
}
505582

506-
/**
507-
* Guesses whether the given path looks like a PHP file.
508-
*
509-
* @example
510-
* ```js
511-
* seemsLikeAPHPRequestHandlerPath('/index.php') // true
512-
* seemsLikeAPHPRequestHandlerPath('/index.php') // true
513-
* seemsLikeAPHPRequestHandlerPath('/index.php/foo/bar') // true
514-
* seemsLikeAPHPRequestHandlerPath('/index.html') // false
515-
* seemsLikeAPHPRequestHandlerPath('/index.html/foo/bar') // false
516-
* seemsLikeAPHPRequestHandlerPath('/') // true
517-
* ```
518-
*
519-
* @param path The path to check.
520-
* @returns Whether the path seems like a PHP server path.
521-
*/
522-
export function seemsLikeAPHPRequestHandlerPath(path: string): boolean {
523-
return seemsLikeAPHPFile(path) || seemsLikeADirectoryRoot(path);
524-
}
525-
526-
function seemsLikeAPHPFile(path: string) {
527-
return path.endsWith('.php') || path.includes('.php/');
528-
}
529-
530-
function seemsLikeADirectoryRoot(path: string) {
531-
const lastSegment = path.split('/').pop();
532-
return !lastSegment!.includes('.');
533-
}
534-
535583
/**
536584
* Applies the given rewrite rules to the given path.
537585
*

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

+5
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,11 @@ export class PHPWorker implements LimitedPHPApi {
218218
return _private.get(this)!.php!.isDir(path);
219219
}
220220

221+
/** @inheritDoc @php-wasm/universal!/PHP.isFile */
222+
isFile(path: string): boolean {
223+
return _private.get(this)!.php!.isFile(path);
224+
}
225+
221226
/** @inheritDoc @php-wasm/universal!/PHP.fileExists */
222227
fileExists(path: string): boolean {
223228
return _private.get(this)!.php!.fileExists(path);

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

+18-8
Original file line numberDiff line numberDiff line change
@@ -923,14 +923,24 @@ export class PHP implements Disposable {
923923
return FSHelpers.isDir(this[__private__dont__use].FS, path);
924924
}
925925

926-
/**
927-
* Checks if a file (or a directory) exists in the PHP filesystem.
928-
*
929-
* @param path - The file path to check.
930-
* @returns True if the file exists, false otherwise.
931-
*/
932-
fileExists(path: string) {
933-
return FSHelpers.fileExists(this[__private__dont__use].FS, path);
926+
/** @inheritDoc */
927+
isFile(path: string): boolean {
928+
if (!this.fileExists(path)) {
929+
return false;
930+
}
931+
return this[__private__dont__use].FS.isFile(
932+
this[__private__dont__use].FS.lookupPath(path).node.mode
933+
);
934+
}
935+
936+
/** @inheritDoc */
937+
fileExists(path: string): boolean {
938+
try {
939+
this[__private__dont__use].FS.lookupPath(path);
940+
return true;
941+
} catch (e) {
942+
return false;
943+
}
934944
}
935945

936946
/**

0 commit comments

Comments
 (0)