Skip to content

Commit

Permalink
Route requests more like a normal web server (#1539)
Browse files Browse the repository at this point in the history
## Motivation for the change, related issues

This PR updates PHPRequestHandler to route HTTP requests more like a
regular web server.

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 and #1187

## Implementation details

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

## Testing Instructions (or ideally a Blueprint)

- CI tests
- Test manually with `npm run dev` and observe that Playground loads
normally with no unexpected errors in the console
  • Loading branch information
brandonpayton authored Jul 19, 2024
1 parent ecfd7e6 commit 88accae
Show file tree
Hide file tree
Showing 15 changed files with 591 additions and 160 deletions.
349 changes: 304 additions & 45 deletions packages/php-wasm/node/src/test/php-request-handler.spec.ts

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions packages/php-wasm/node/src/test/php.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -934,6 +934,16 @@ describe.each(SupportedPHPVersions)('PHP %s', (phpVersion) => {
expect(php.isDir(testFilePath)).toEqual(false);
});

it('isFile() should correctly distinguish between a file and a directory', () => {
php.writeFile(testFilePath, 'Hello World!');
expect(php.fileExists(testFilePath)).toEqual(true);
expect(php.isFile(testFilePath)).toEqual(true);

php.mkdir(testDirPath);
expect(php.fileExists(testDirPath)).toEqual(true);
expect(php.isFile(testDirPath)).toEqual(false);
});

it('listFiles() should return a list of files in a directory', () => {
php.mkdir(testDirPath);
php.writeFile(testDirPath + '/test.txt', 'Hello World!');
Expand Down
13 changes: 13 additions & 0 deletions packages/php-wasm/universal/src/lib/fs-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,19 @@ export class FSHelpers {
return FS.isDir(FS.lookupPath(path).node.mode);
}

/**
* Checks if a file exists in the PHP filesystem.
*
* @param path – The path to check.
* @returns True if the path is a file, false otherwise.
*/
static isFile(FS: Emscripten.RootFS, path: string): boolean {
if (!FSHelpers.fileExists(FS, path)) {
return false;
}
return FS.isFile(FS.lookupPath(path).node.mode);
}

/**
* Checks if a file (or a directory) exists in the PHP filesystem.
*
Expand Down
6 changes: 6 additions & 0 deletions packages/php-wasm/universal/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ export type {
RewriteRule,
} from './php-request-handler';
export { PHPRequestHandler, applyRewriteRules } from './php-request-handler';
export type {
FileNotFoundGetActionCallback,
FileNotFoundToInternalRedirect,
FileNotFoundToResponse,
FileNotFoundAction,
} from './php-request-handler';
export { rotatePHPRuntime } from './rotate-php-runtime';
export { writeFiles } from './write-files';
export type { FileTree } from './write-files';
Expand Down
217 changes: 118 additions & 99 deletions packages/php-wasm/universal/src/lib/php-request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,25 @@ export type RewriteRule = {
replacement: string;
};

export type FileNotFoundToResponse = {
type: 'response';
response: PHPResponse;
};
export type FileNotFoundToInternalRedirect = {
type: 'internal-redirect';
uri: string;
};
export type FileNotFoundTo404 = { type: '404' };

export type FileNotFoundAction =
| FileNotFoundToResponse
| FileNotFoundToInternalRedirect
| FileNotFoundTo404;

export type FileNotFoundGetActionCallback = (
relativePath: string
) => FileNotFoundAction;

interface BaseConfiguration {
/**
* The directory in the PHP filesystem where the server will look
Expand All @@ -38,6 +57,12 @@ interface BaseConfiguration {
* Rewrite rules
*/
rewriteRules?: RewriteRule[];

/**
* A callback that decides how to handle a file-not-found condition for a
* given request URI.
*/
getFileNotFoundAction?: FileNotFoundGetActionCallback;
}

export type PHPRequestHandlerFactoryArgs = PHPFactoryOptions & {
Expand Down Expand Up @@ -137,6 +162,7 @@ export class PHPRequestHandler {
#cookieStore: HttpCookieStore;
rewriteRules: RewriteRule[];
processManager: PHPProcessManager;
getFileNotFoundAction: FileNotFoundGetActionCallback;

/**
* The request handler needs to decide whether to serve a static asset or
Expand All @@ -154,6 +180,7 @@ export class PHPRequestHandler {
documentRoot = '/www/',
absoluteUrl = typeof location === 'object' ? location?.href : '',
rewriteRules = [],
getFileNotFoundAction = () => ({ type: '404' }),
} = config;
if ('processManager' in config) {
this.processManager = config.processManager;
Expand Down Expand Up @@ -194,6 +221,7 @@ export class PHPRequestHandler {
this.#PATHNAME,
].join('');
this.rewriteRules = rewriteRules;
this.getFileNotFoundAction = getFileNotFoundAction;
}

async getPrimaryPhp() {
Expand Down Expand Up @@ -306,14 +334,94 @@ export class PHPRequestHandler {
),
this.rewriteRules
);
const fsPath = joinPaths(this.#DOCROOT, normalizedRequestedPath);
if (!seemsLikeAPHPRequestHandlerPath(fsPath)) {
return this.#serveStaticFile(
await this.processManager.getPrimaryPhp(),
fsPath

const primaryPhp = await this.getPrimaryPhp();

let fsPath = joinPaths(this.#DOCROOT, normalizedRequestedPath);

if (primaryPhp.isDir(fsPath)) {
// Ensure directory URIs have a trailing slash. Otherwise,
// relative URIs in index.php or index.html files are relative
// to the next directory up.
//
// Example:
// For an index page served for URI "/settings", we naturally expect
// links to be relative to "/settings", but without the trailing
// slash, a relative link "edit.php" resolves to "/edit.php"
// rather than "/settings/edit.php".
//
// This treatment of relative links is correct behavior for the browser:
// https://www.rfc-editor.org/rfc/rfc3986#section-5.2.3
//
// But user intent for `/settings/index.php` is that its relative
// URIs are relative to `/settings/`. So we redirect to add a
// trailing slash to directory URIs to meet this expecatation.
//
// This behavior is also necessary for WordPress to function properly.
// Otherwise, when viewing the WP admin dashboard at `/wp-admin`,
// links to other admin pages like `edit.php` will incorrectly
// resolve to `/edit.php` rather than `/wp-admin/edit.php`.
if (!fsPath.endsWith('/')) {
return new PHPResponse(
301,
{ Location: [`${requestedUrl.pathname}/`] },
new Uint8Array(0)
);
}

// We can only satisfy requests for directories with a default file
// so let's first resolve to a default path when available.
for (const possibleIndexFile of ['index.php', 'index.html']) {
const possibleIndexPath = joinPaths(fsPath, possibleIndexFile);
if (primaryPhp.isFile(possibleIndexPath)) {
fsPath = possibleIndexPath;
break;
}
}
}

if (!primaryPhp.isFile(fsPath)) {
const fileNotFoundAction = this.getFileNotFoundAction(
normalizedRequestedPath
);
switch (fileNotFoundAction.type) {
case 'response':
return fileNotFoundAction.response;
case 'internal-redirect':
fsPath = joinPaths(this.#DOCROOT, fileNotFoundAction.uri);
break;
case '404':
return PHPResponse.forHttpCode(404);
default:
throw new Error(
'Unsupported file-not-found action type: ' +
// Cast because TS asserts the remaining possibility is `never`
`'${
(fileNotFoundAction as FileNotFoundAction).type
}'`
);
}
}

// We need to confirm that the current target file exists because
// file-not-found fallback actions may redirect to non-existent files.
if (primaryPhp.isFile(fsPath)) {
if (fsPath.endsWith('.php')) {
const effectiveRequest: PHPRequest = {
...request,
// Pass along URL with the #fragment filtered out
url: requestedUrl.toString(),
};
return this.#spawnPHPAndDispatchRequest(
effectiveRequest,
fsPath
);
} else {
return this.#serveStaticFile(primaryPhp, fsPath);
}
} else {
return PHPResponse.forHttpCode(404);
}
return this.#spawnPHPAndDispatchRequest(request, requestedUrl);
}

/**
Expand All @@ -323,17 +431,6 @@ export class PHPRequestHandler {
* @returns The response.
*/
#serveStaticFile(php: PHP, fsPath: string): PHPResponse {
if (!php.fileExists(fsPath)) {
return new PHPResponse(
404,
// Let the service worker know that no static file was found
// and that it's okay to issue a real fetch() to the server.
{
'x-file-type': ['static'],
},
new TextEncoder().encode('404 File not found')
);
}
const arrayBuffer = php.readFileAsBuffer(fsPath);
return new PHPResponse(
200,
Expand All @@ -355,7 +452,7 @@ export class PHPRequestHandler {
*/
async #spawnPHPAndDispatchRequest(
request: PHPRequest,
requestedUrl: URL
scriptPath: string
): Promise<PHPResponse> {
let spawnedPHP: SpawnedPHP | undefined = undefined;
try {
Expand All @@ -371,7 +468,7 @@ export class PHPRequestHandler {
return await this.#dispatchToPHP(
spawnedPHP.php,
request,
requestedUrl
scriptPath
);
} finally {
spawnedPHP.reap();
Expand All @@ -388,7 +485,7 @@ export class PHPRequestHandler {
async #dispatchToPHP(
php: PHP,
request: PHPRequest,
requestedUrl: URL
scriptPath: string
): Promise<PHPResponse> {
let preferredMethod: PHPRunOptions['method'] = 'GET';

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

let scriptPath;
try {
scriptPath = this.#resolvePHPFilePath(
php,
decodeURIComponent(requestedUrl.pathname)
);
} catch (error) {
return PHPResponse.forHttpCode(404);
}

try {
const response = await php.run({
relativeUri: ensurePathPrefix(
toRelativeUrl(requestedUrl),
toRelativeUrl(new URL(request.url)),
this.#PATHNAME
),
protocol: this.#PROTOCOL,
Expand Down Expand Up @@ -447,45 +534,6 @@ export class PHPRequestHandler {
throw error;
}
}

/**
* Resolve the requested path to the filesystem path of the requested PHP file.
*
* Fall back to index.php as if there was a url rewriting rule in place.
*
* @param requestedPath - The requested pathname.
* @throws {Error} If the requested path doesn't exist.
* @returns The resolved filesystem path.
*/
#resolvePHPFilePath(php: PHP, 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.
filePath = filePath.split('.php')[0] + '.php';
} else if (php.isDir(`${this.#DOCROOT}${filePath}`)) {
if (!filePath.endsWith('/')) {
filePath = `${filePath}/`;
}
// If the path is a directory, let's assume the file is index.php
filePath = `${filePath}index.php`;
} else {
// Otherwise, let's assume the file is /index.php
filePath = '/index.php';
}

let resolvedFsPath = `${this.#DOCROOT}${filePath}`;
// If the requested PHP file doesn't exist, let's fall back to /index.php
// as the request may need to be rewritten.
if (!php.fileExists(resolvedFsPath)) {
resolvedFsPath = `${this.#DOCROOT}/index.php`;
}
if (php.fileExists(resolvedFsPath)) {
return resolvedFsPath;
}
throw new Error(`File not found: ${resolvedFsPath}`);
}
}

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

/**
* Guesses whether the given path looks like a PHP file.
*
* @example
* ```js
* seemsLikeAPHPRequestHandlerPath('/index.php') // true
* seemsLikeAPHPRequestHandlerPath('/index.php') // true
* seemsLikeAPHPRequestHandlerPath('/index.php/foo/bar') // true
* seemsLikeAPHPRequestHandlerPath('/index.html') // false
* seemsLikeAPHPRequestHandlerPath('/index.html/foo/bar') // false
* seemsLikeAPHPRequestHandlerPath('/') // true
* ```
*
* @param path The path to check.
* @returns Whether the path seems like a PHP server path.
*/
export function seemsLikeAPHPRequestHandlerPath(path: string): boolean {
return seemsLikeAPHPFile(path) || seemsLikeADirectoryRoot(path);
}

function seemsLikeAPHPFile(path: string) {
return path.endsWith('.php') || path.includes('.php/');
}

function seemsLikeADirectoryRoot(path: string) {
const lastSegment = path.split('/').pop();
return !lastSegment!.includes('.');
}

/**
* Applies the given rewrite rules to the given path.
*
Expand Down
5 changes: 5 additions & 0 deletions packages/php-wasm/universal/src/lib/php-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,11 @@ export class PHPWorker implements LimitedPHPApi {
return _private.get(this)!.php!.isDir(path);
}

/** @inheritDoc @php-wasm/universal!/PHP.isFile */
isFile(path: string): boolean {
return _private.get(this)!.php!.isFile(path);
}

/** @inheritDoc @php-wasm/universal!/PHP.fileExists */
fileExists(path: string): boolean {
return _private.get(this)!.php!.fileExists(path);
Expand Down
10 changes: 10 additions & 0 deletions packages/php-wasm/universal/src/lib/php.ts
Original file line number Diff line number Diff line change
Expand Up @@ -923,6 +923,16 @@ export class PHP implements Disposable {
return FSHelpers.isDir(this[__private__dont__use].FS, path);
}

/**
* Checks if a file exists in the PHP filesystem.
*
* @param path – The path to check.
* @returns True if the path is a file, false otherwise.
*/
isFile(path: string) {
return FSHelpers.isFile(this[__private__dont__use].FS, path);
}

/**
* Checks if a file (or a directory) exists in the PHP filesystem.
*
Expand Down
Loading

0 comments on commit 88accae

Please sign in to comment.