Skip to content

Commit 5010b1d

Browse files
committed
Route requests more like a normal web server
This PR: - Updates PHPRequestHandler to support custom file-not-found responses - Updates worker-thread to configure PHPRequestHandler to handle file-not-found conditions properly for WordPress - Updates worker-thread to flag remote WP assets for retrieval by the Service Worker 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. - CI tests - Test manually with `npm run dev` and observe that Playground loads normally with no unexpected errors in the console
1 parent 327888c commit 5010b1d

File tree

15 files changed

+567
-160
lines changed

15 files changed

+567
-160
lines changed

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

+288-45
Large diffs are not rendered by default.

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/fs-helpers.ts

+13
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,19 @@ export class FSHelpers {
185185
return FS.isDir(FS.lookupPath(path).node.mode);
186186
}
187187

188+
/**
189+
* Checks if a file exists in the PHP filesystem.
190+
*
191+
* @param path – The path to check.
192+
* @returns True if the path is a file, false otherwise.
193+
*/
194+
static isFile(FS: Emscripten.RootFS, path: string): boolean {
195+
if (!FSHelpers.fileExists(FS, path)) {
196+
return false;
197+
}
198+
return FS.isFile(FS.lookupPath(path).node.mode);
199+
}
200+
188201
/**
189202
* Checks if a file (or a directory) exists in the PHP filesystem.
190203
*

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

+6
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ export type {
6464
RewriteRule,
6565
} from './php-request-handler';
6666
export { PHPRequestHandler, applyRewriteRules } from './php-request-handler';
67+
export type {
68+
FileNotFoundGetActionCallback,
69+
FileNotFoundToInternalRedirect,
70+
FileNotFoundToResponse,
71+
FileNotFoundAction,
72+
} from './php-request-handler';
6773
export { rotatePHPRuntime } from './rotate-php-runtime';
6874
export { writeFiles } from './write-files';
6975
export type { FileTree } from './write-files';

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

+118-99
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,25 @@ export type RewriteRule = {
2323
replacement: string;
2424
};
2525

26+
export type FileNotFoundToResponse = {
27+
type: 'response';
28+
response: PHPResponse;
29+
};
30+
export type FileNotFoundToInternalRedirect = {
31+
type: 'internal-redirect';
32+
uri: string;
33+
};
34+
export type FileNotFoundTo404 = { type: '404' };
35+
36+
export type FileNotFoundAction =
37+
| FileNotFoundToResponse
38+
| FileNotFoundToInternalRedirect
39+
| FileNotFoundTo404;
40+
41+
export type FileNotFoundGetActionCallback = (
42+
relativePath: string
43+
) => FileNotFoundAction;
44+
2645
interface BaseConfiguration {
2746
/**
2847
* The directory in the PHP filesystem where the server will look
@@ -38,6 +57,12 @@ interface BaseConfiguration {
3857
* Rewrite rules
3958
*/
4059
rewriteRules?: RewriteRule[];
60+
61+
/**
62+
* A callback that decides how to handle a file-not-found condition for a
63+
* given request URI.
64+
*/
65+
getFileNotFoundAction?: FileNotFoundGetActionCallback;
4166
}
4267

4368
export type PHPRequestHandlerFactoryArgs = PHPFactoryOptions & {
@@ -137,6 +162,7 @@ export class PHPRequestHandler {
137162
#cookieStore: HttpCookieStore;
138163
rewriteRules: RewriteRule[];
139164
processManager: PHPProcessManager;
165+
getFileNotFoundAction: FileNotFoundGetActionCallback;
140166

141167
/**
142168
* The request handler needs to decide whether to serve a static asset or
@@ -154,6 +180,7 @@ export class PHPRequestHandler {
154180
documentRoot = '/www/',
155181
absoluteUrl = typeof location === 'object' ? location?.href : '',
156182
rewriteRules = [],
183+
getFileNotFoundAction = () => ({ type: '404' }),
157184
} = config;
158185
if ('processManager' in config) {
159186
this.processManager = config.processManager;
@@ -194,6 +221,7 @@ export class PHPRequestHandler {
194221
this.#PATHNAME,
195222
].join('');
196223
this.rewriteRules = rewriteRules;
224+
this.getFileNotFoundAction = getFileNotFoundAction;
197225
}
198226

199227
async getPrimaryPhp() {
@@ -306,14 +334,94 @@ export class PHPRequestHandler {
306334
),
307335
this.rewriteRules
308336
);
309-
const fsPath = joinPaths(this.#DOCROOT, normalizedRequestedPath);
310-
if (!seemsLikeAPHPRequestHandlerPath(fsPath)) {
311-
return this.#serveStaticFile(
312-
await this.processManager.getPrimaryPhp(),
313-
fsPath
337+
338+
const primaryPhp = await this.getPrimaryPhp();
339+
340+
let fsPath = joinPaths(this.#DOCROOT, normalizedRequestedPath);
341+
342+
if (primaryPhp.isDir(fsPath)) {
343+
// Ensure directory URIs have a trailing slash. Otherwise,
344+
// relative URIs in index.php or index.html files are relative
345+
// to the next directory up.
346+
//
347+
// Example:
348+
// For an index page served for URI "/settings", we naturally expect
349+
// links to be relative to "/settings", but without the trailing
350+
// slash, a relative link "edit.php" resolves to "/edit.php"
351+
// rather than "/settings/edit.php".
352+
//
353+
// This treatment of relative links is correct behavior for the browser:
354+
// https://www.rfc-editor.org/rfc/rfc3986#section-5.2.3
355+
//
356+
// But user intent for `/settings/index.php` is that its relative
357+
// URIs are relative to `/settings/`. So we redirect to add a
358+
// trailing slash to directory URIs to meet this expecatation.
359+
//
360+
// This behavior is also necessary for WordPress to function properly.
361+
// Otherwise, when viewing the WP admin dashboard at `/wp-admin`,
362+
// links to other admin pages like `edit.php` will incorrectly
363+
// resolve to `/edit.php` rather than `/wp-admin/edit.php`.
364+
if (!fsPath.endsWith('/')) {
365+
return new PHPResponse(
366+
301,
367+
{ Location: [`${requestedUrl.pathname}/`] },
368+
new Uint8Array(0)
369+
);
370+
}
371+
372+
// We can only satisfy requests for directories with a default file
373+
// so let's first resolve to a default path when available.
374+
for (const possibleIndexFile of ['index.php', 'index.html']) {
375+
const possibleIndexPath = joinPaths(fsPath, possibleIndexFile);
376+
if (primaryPhp.isFile(possibleIndexPath)) {
377+
fsPath = possibleIndexPath;
378+
break;
379+
}
380+
}
381+
}
382+
383+
if (!primaryPhp.isFile(fsPath)) {
384+
const fileNotFoundAction = this.getFileNotFoundAction(
385+
normalizedRequestedPath
314386
);
387+
switch (fileNotFoundAction.type) {
388+
case 'response':
389+
return fileNotFoundAction.response;
390+
case 'internal-redirect':
391+
fsPath = joinPaths(this.#DOCROOT, fileNotFoundAction.uri);
392+
break;
393+
case '404':
394+
return PHPResponse.forHttpCode(404);
395+
default:
396+
throw new Error(
397+
'Unsupported file-not-found action type: ' +
398+
// Cast because TS asserts the remaining possibility is `never`
399+
`'${
400+
(fileNotFoundAction as FileNotFoundAction).type
401+
}'`
402+
);
403+
}
404+
}
405+
406+
// We need to confirm that the current target file exists because
407+
// file-not-found fallback actions may redirect to non-existent files.
408+
if (primaryPhp.isFile(fsPath)) {
409+
if (fsPath.endsWith('.php')) {
410+
const effectiveRequest: PHPRequest = {
411+
...request,
412+
// Pass along URL with the #fragment filtered out
413+
url: requestedUrl.toString(),
414+
};
415+
return this.#spawnPHPAndDispatchRequest(
416+
effectiveRequest,
417+
fsPath
418+
);
419+
} else {
420+
return this.#serveStaticFile(primaryPhp, fsPath);
421+
}
422+
} else {
423+
return PHPResponse.forHttpCode(404);
315424
}
316-
return this.#spawnPHPAndDispatchRequest(request, requestedUrl);
317425
}
318426

319427
/**
@@ -323,17 +431,6 @@ export class PHPRequestHandler {
323431
* @returns The response.
324432
*/
325433
#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-
}
337434
const arrayBuffer = php.readFileAsBuffer(fsPath);
338435
return new PHPResponse(
339436
200,
@@ -355,7 +452,7 @@ export class PHPRequestHandler {
355452
*/
356453
async #spawnPHPAndDispatchRequest(
357454
request: PHPRequest,
358-
requestedUrl: URL
455+
scriptPath: string
359456
): Promise<PHPResponse> {
360457
let spawnedPHP: SpawnedPHP | undefined = undefined;
361458
try {
@@ -371,7 +468,7 @@ export class PHPRequestHandler {
371468
return await this.#dispatchToPHP(
372469
spawnedPHP.php,
373470
request,
374-
requestedUrl
471+
scriptPath
375472
);
376473
} finally {
377474
spawnedPHP.reap();
@@ -388,7 +485,7 @@ export class PHPRequestHandler {
388485
async #dispatchToPHP(
389486
php: PHP,
390487
request: PHPRequest,
391-
requestedUrl: URL
488+
scriptPath: string
392489
): Promise<PHPResponse> {
393490
let preferredMethod: PHPRunOptions['method'] = 'GET';
394491

@@ -406,20 +503,10 @@ export class PHPRequestHandler {
406503
headers['content-type'] = contentType;
407504
}
408505

409-
let scriptPath;
410-
try {
411-
scriptPath = this.#resolvePHPFilePath(
412-
php,
413-
decodeURIComponent(requestedUrl.pathname)
414-
);
415-
} catch (error) {
416-
return PHPResponse.forHttpCode(404);
417-
}
418-
419506
try {
420507
const response = await php.run({
421508
relativeUri: ensurePathPrefix(
422-
toRelativeUrl(requestedUrl),
509+
toRelativeUrl(new URL(request.url)),
423510
this.#PATHNAME
424511
),
425512
protocol: this.#PROTOCOL,
@@ -447,45 +534,6 @@ export class PHPRequestHandler {
447534
throw error;
448535
}
449536
}
450-
451-
/**
452-
* Resolve the requested path to the filesystem path of the requested PHP file.
453-
*
454-
* Fall back to index.php as if there was a url rewriting rule in place.
455-
*
456-
* @param requestedPath - The requested pathname.
457-
* @throws {Error} If the requested path doesn't exist.
458-
* @returns The resolved filesystem path.
459-
*/
460-
#resolvePHPFilePath(php: PHP, requestedPath: string): string {
461-
let filePath = removePathPrefix(requestedPath, this.#PATHNAME);
462-
filePath = applyRewriteRules(filePath, this.rewriteRules);
463-
464-
if (filePath.includes('.php')) {
465-
// If the path mentions a .php extension, that's our file's path.
466-
filePath = filePath.split('.php')[0] + '.php';
467-
} else if (php.isDir(`${this.#DOCROOT}${filePath}`)) {
468-
if (!filePath.endsWith('/')) {
469-
filePath = `${filePath}/`;
470-
}
471-
// If the path is a directory, let's assume the file is index.php
472-
filePath = `${filePath}index.php`;
473-
} else {
474-
// Otherwise, let's assume the file is /index.php
475-
filePath = '/index.php';
476-
}
477-
478-
let resolvedFsPath = `${this.#DOCROOT}${filePath}`;
479-
// If the requested PHP file doesn't exist, let's fall back to /index.php
480-
// as the request may need to be rewritten.
481-
if (!php.fileExists(resolvedFsPath)) {
482-
resolvedFsPath = `${this.#DOCROOT}/index.php`;
483-
}
484-
if (php.fileExists(resolvedFsPath)) {
485-
return resolvedFsPath;
486-
}
487-
throw new Error(`File not found: ${resolvedFsPath}`);
488-
}
489537
}
490538

491539
/**
@@ -503,35 +551,6 @@ function inferMimeType(path: string): string {
503551
return mimeTypes[extension] || mimeTypes['_default'];
504552
}
505553

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-
535554
/**
536555
* Applies the given rewrite rules to the given path.
537556
*

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

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

926+
/**
927+
* Checks if a file exists in the PHP filesystem.
928+
*
929+
* @param path – The path to check.
930+
* @returns True if the path is a file, false otherwise.
931+
*/
932+
isFile(path: string) {
933+
return FSHelpers.isFile(this[__private__dont__use].FS, path);
934+
}
935+
926936
/**
927937
* Checks if a file (or a directory) exists in the PHP filesystem.
928938
*

0 commit comments

Comments
 (0)