diff --git a/packages/php-wasm/node/src/test/php-request-handler.spec.ts b/packages/php-wasm/node/src/test/php-request-handler.spec.ts index f78949389ef..0427215f06e 100644 --- a/packages/php-wasm/node/src/test/php-request-handler.spec.ts +++ b/packages/php-wasm/node/src/test/php-request-handler.spec.ts @@ -1,29 +1,76 @@ import { RecommendedPHPVersion } from '@wp-playground/common'; +// eslint-disable-next-line @nx/enforce-module-boundaries -- ignore test-related interdependencies so we can test. +import { getFileNotFoundActionForWordPress } from '@wp-playground/wordpress'; import { loadNodeRuntime } from '..'; import { + FileNotFoundGetActionCallback, PHP, PHPRequestHandler, + PHPResponse, SupportedPHPVersions, } from '@php-wasm/universal'; -import { createSpawnHandler } from '@php-wasm/util'; +import { createSpawnHandler, joinPaths } from '@php-wasm/util'; -describe.each(SupportedPHPVersions)( - '[PHP %s] PHPRequestHandler – request', - (phpVersion) => { +interface ConfigForRequestTests { + phpVersion: (typeof SupportedPHPVersions)[number]; + docRoot: string; + absoluteUrl: string | undefined; +} + +const configsForRequestTests: ConfigForRequestTests[] = + SupportedPHPVersions.map((phpVersion) => { + const documentRoots = [ + '/', + // TODO: Re-enable when we can avoid GH workflow cancelation. + // Disable for now because the GH CI unit test workflow is getting + // auto-canceled when this is enabled + //'/wordpress', + ]; + return documentRoots.map((docRoot) => { + const absoluteUrls = [ + undefined, + // TODO: Re-enable when we can avoid GH workflow cancelation. + // Disable for now because the GH CI unit test workflow is + // getting auto-canceled when this is enabled. + //'http://localhost:4321/nested/playground/', + ]; + return absoluteUrls.map((absoluteUrl) => ({ + phpVersion, + docRoot, + absoluteUrl, + })); + }); + }).flat(2); + +describe.each(configsForRequestTests)( + '[PHP $phpVersion, DocRoot $docRoot, AbsUrl $absoluteUrl] PHPRequestHandler – request', + ({ phpVersion, docRoot, absoluteUrl }) => { let php: PHP; let handler: PHPRequestHandler; + let getFileNotFoundActionForTest: FileNotFoundGetActionCallback = + () => ({ + type: '404', + }); beforeEach(async () => { handler = new PHPRequestHandler({ - documentRoot: '/', + documentRoot: docRoot, + absoluteUrl, phpFactory: async () => new PHP(await loadNodeRuntime(phpVersion)), maxPhpInstances: 1, + getFileNotFoundAction: (relativePath: string) => { + return getFileNotFoundActionForTest(relativePath); + }, }); php = await handler.getPrimaryPhp(); + php.mkdir(docRoot); }); it('should execute a PHP file', async () => { - php.writeFile('/index.php', ` { + php.mkdirTree(joinPaths(docRoot, 'folder')); + php.writeFile( + joinPaths(docRoot, 'folder/some.php'), + ` { - php.writeFile('/index.html', `Hello World`); + php.writeFile(joinPaths(docRoot, 'index.html'), `Hello World`); const response = await handler.request({ url: '/index.html', }); @@ -61,7 +129,7 @@ describe.each(SupportedPHPVersions)( it('should serve a static file with urlencoded entities in the path', async () => { php.writeFile( - '/Screenshot 2024-04-05 at 7.13.36 AM.html', + joinPaths(docRoot, 'Screenshot 2024-04-05 at 7.13.36 AM.html'), `Hello World` ); const response = await handler.request({ @@ -84,7 +152,7 @@ describe.each(SupportedPHPVersions)( it('should serve a PHP file with urlencoded entities in the path', async () => { php.writeFile( - '/Screenshot 2024-04-05 at 7.13.36 AM.php', + joinPaths(docRoot, 'Screenshot 2024-04-05 at 7.13.36 AM.php'), `Hello World` ); const response = await handler.request({ @@ -102,14 +170,144 @@ describe.each(SupportedPHPVersions)( }); }); - it('should yield x-file-type=static when a static file is not found', async () => { + const fileNotFoundFallbackTestUris = [ + '/index.php', + '/other.php', + '/index.html', + '/testing.html', + '/', + '/subdir', + ]; + fileNotFoundFallbackTestUris.forEach((nonExistentFileUri) => { + it(`should relay a fallback response for non-existent file: '${nonExistentFileUri}'`, async () => { + getFileNotFoundActionForTest = (uri: string) => { + if (uri === nonExistentFileUri) { + return { + type: 'response', + response: new PHPResponse( + 404, + { 'x-backfill-from': ['remote-host'] }, + new TextEncoder().encode('404 File not found') + ), + }; + } else { + return { type: '404' }; + } + }; + const response = await handler.request({ + url: nonExistentFileUri, + }); + expect(response).toEqual({ + httpStatusCode: 404, + headers: { + 'x-backfill-from': ['remote-host'], + }, + bytes: expect.any(Uint8Array), + errors: '', + exitCode: 0, + }); + }); + it(`should support internal redirection to a PHP file as a fallback for non-existent file: '${nonExistentFileUri}'`, async () => { + const primaryPhp = await handler.getPrimaryPhp(); + const scriptPath = joinPaths(docRoot, 'fallback.php'); + primaryPhp.writeFile( + scriptPath, + ` { + if (uri === nonExistentFileUri) { + return { + type: 'internal-redirect', + uri: '/fallback.php', + }; + } else { + return { type: '404' }; + } + }; + const response = await handler.request({ + url: nonExistentFileUri, + }); + + const expectedRequestUri = + absoluteUrl === undefined + ? nonExistentFileUri + : joinPaths( + new URL(absoluteUrl as string).pathname, + nonExistentFileUri + ); + expect(response).toEqual({ + httpStatusCode: 200, + headers: expect.any(Object), + bytes: new TextEncoder().encode( + 'expected fallback to PHP content:' + + `${expectedRequestUri}:` + + `${scriptPath}` + ), + errors: '', + exitCode: 0, + }); + }); + it(`should support internal redirection to a static file as a fallback for non-existent file: '${nonExistentFileUri}'`, async () => { + const primaryPhp = await handler.getPrimaryPhp(); + primaryPhp.writeFile( + joinPaths(docRoot, 'fallback.txt'), + 'expected fallback to static content' + ); + + getFileNotFoundActionForTest = (uri: string) => { + if (uri === nonExistentFileUri) { + return { + type: 'internal-redirect', + uri: '/fallback.txt', + }; + } else { + return { type: '404' }; + } + }; + const response = await handler.request({ + url: nonExistentFileUri, + }); + expect(response).toEqual({ + httpStatusCode: 200, + headers: expect.any(Object), + bytes: new TextEncoder().encode( + 'expected fallback to static content' + ), + errors: '', + exitCode: 0, + }); + }); + it(`should support responding with a plain 404 for non-existent file: '${nonExistentFileUri}'`, async () => { + getFileNotFoundActionForTest = () => ({ type: '404' }); + const response = await handler.request({ + url: nonExistentFileUri, + }); + expect(response).toEqual({ + httpStatusCode: 404, + headers: expect.any(Object), + bytes: expect.any(Uint8Array), + errors: '', + exitCode: 0, + }); + }); + }); + + it('should redirect to add trailing slash to existing dir', async () => { + php.mkdirTree(joinPaths(docRoot, 'folder')); const response = await handler.request({ - url: '/index.html', + url: '/folder', }); expect(response).toEqual({ - httpStatusCode: 404, + httpStatusCode: 301, headers: { - 'x-file-type': ['static'], + Location: ['/folder/'], }, bytes: expect.any(Uint8Array), errors: '', @@ -117,22 +315,52 @@ describe.each(SupportedPHPVersions)( }); }); - it('should not yield x-file-type=static when a PHP file is not found', async () => { + it('should return 200 and pass query strings when a valid request is made to a folder', async () => { + php.mkdirTree(joinPaths(docRoot, 'folder')); + php.writeFile( + joinPaths(docRoot, 'folder/index.php'), + ` { + php.mkdirTree(joinPaths(docRoot, 'folder')); + php.writeFile( + joinPaths(docRoot, 'folder/index.html'), + `INDEX DOT HTML` + ); + const response = await handler.request({ + url: '/folder/?key=value', }); + expect(response.httpStatusCode).toEqual(200); + expect(response.text).toEqual('INDEX DOT HTML'); + }); + + it('should default a folder request to index.php when when both index.php and index.html exist', async () => { + php.mkdirTree(joinPaths(docRoot, 'folder')); + php.writeFile( + joinPaths(docRoot, 'folder/index.php'), + `INDEX DOT PHP` + ); + php.writeFile( + joinPaths(docRoot, 'folder/index.html'), + `INDEX DOT HTML` + ); + const response = await handler.request({ + url: '/folder/?key=value', + }); + expect(response.httpStatusCode).toEqual(200); + expect(response.text).toEqual('INDEX DOT PHP'); }); it('should return httpStatus 500 if exit code is not 0', async () => { php.writeFile( - '/index.php', + joinPaths(docRoot, 'index.php'), ` { - php.writeFile('/index.php', ` { php.writeFile( - '/index.php', + joinPaths(docRoot, 'index.php'), ` { await php.writeFile( - '/index.php', + joinPaths(docRoot, 'index.php'), ` { - php.writeFile('/test.php', ` { - php.writeFile('/index.php', ` { + beforeEach(() => { + getFileNotFoundActionForTest = + getFileNotFoundActionForWordPress; + }); + it('should delegate request for non-existent PHP file to /index.php with query args', async () => { + php.writeFile( + joinPaths(docRoot, 'index.php'), + ` { - php.mkdirTree('/folder'); - php.writeFile('/folder/index.php', ` { + php.writeFile( + joinPaths(docRoot, 'index.php'), + ` { + php.writeFile( + joinPaths(docRoot, 'index.php'), + ` { 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!'); diff --git a/packages/php-wasm/universal/src/lib/fs-helpers.ts b/packages/php-wasm/universal/src/lib/fs-helpers.ts index 76c3d2b50d2..94163b66fea 100644 --- a/packages/php-wasm/universal/src/lib/fs-helpers.ts +++ b/packages/php-wasm/universal/src/lib/fs-helpers.ts @@ -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. * diff --git a/packages/php-wasm/universal/src/lib/index.ts b/packages/php-wasm/universal/src/lib/index.ts index 64be283640c..983a11b0a39 100644 --- a/packages/php-wasm/universal/src/lib/index.ts +++ b/packages/php-wasm/universal/src/lib/index.ts @@ -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'; diff --git a/packages/php-wasm/universal/src/lib/php-request-handler.ts b/packages/php-wasm/universal/src/lib/php-request-handler.ts index c917f8c6b34..9deb255e2b6 100644 --- a/packages/php-wasm/universal/src/lib/php-request-handler.ts +++ b/packages/php-wasm/universal/src/lib/php-request-handler.ts @@ -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 @@ -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 & { @@ -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 @@ -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; @@ -194,6 +221,7 @@ export class PHPRequestHandler { this.#PATHNAME, ].join(''); this.rewriteRules = rewriteRules; + this.getFileNotFoundAction = getFileNotFoundAction; } async getPrimaryPhp() { @@ -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); } /** @@ -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, @@ -355,7 +452,7 @@ export class PHPRequestHandler { */ async #spawnPHPAndDispatchRequest( request: PHPRequest, - requestedUrl: URL + scriptPath: string ): Promise { let spawnedPHP: SpawnedPHP | undefined = undefined; try { @@ -371,7 +468,7 @@ export class PHPRequestHandler { return await this.#dispatchToPHP( spawnedPHP.php, request, - requestedUrl + scriptPath ); } finally { spawnedPHP.reap(); @@ -388,7 +485,7 @@ export class PHPRequestHandler { async #dispatchToPHP( php: PHP, request: PHPRequest, - requestedUrl: URL + scriptPath: string ): Promise { let preferredMethod: PHPRunOptions['method'] = 'GET'; @@ -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, @@ -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}`); - } } /** @@ -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. * diff --git a/packages/php-wasm/universal/src/lib/php-worker.ts b/packages/php-wasm/universal/src/lib/php-worker.ts index 3e7d1c649ea..5d8c4e83d81 100644 --- a/packages/php-wasm/universal/src/lib/php-worker.ts +++ b/packages/php-wasm/universal/src/lib/php-worker.ts @@ -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); diff --git a/packages/php-wasm/universal/src/lib/php.ts b/packages/php-wasm/universal/src/lib/php.ts index a28e32efaf2..f742e75cbce 100644 --- a/packages/php-wasm/universal/src/lib/php.ts +++ b/packages/php-wasm/universal/src/lib/php.ts @@ -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. * diff --git a/packages/php-wasm/web-service-worker/src/initialize-service-worker.ts b/packages/php-wasm/web-service-worker/src/initialize-service-worker.ts index d74b1402a2c..c71ef35ed81 100644 --- a/packages/php-wasm/web-service-worker/src/initialize-service-worker.ts +++ b/packages/php-wasm/web-service-worker/src/initialize-service-worker.ts @@ -53,7 +53,10 @@ async function defaultRequestHandler(event: FetchEvent) { const workerResponse = await convertFetchEventToPHPRequest(event); if ( workerResponse.status === 404 && - workerResponse.headers.get('x-file-type') === 'static' + (workerResponse.headers.get('x-backfill-from') === 'remote-host' || + // TODO: Remove this once it become clear we aren't reverting + // request routing changes + workerResponse.headers.get('x-file-type') === 'static') ) { const request = await cloneRequest(event.request, { url, diff --git a/packages/playground/blueprints/src/lib/steps/login.spec.ts b/packages/playground/blueprints/src/lib/steps/login.spec.ts index 573cbe140e1..2dd3f88de36 100644 --- a/packages/playground/blueprints/src/lib/steps/login.spec.ts +++ b/packages/playground/blueprints/src/lib/steps/login.spec.ts @@ -27,7 +27,7 @@ describe('Blueprint step installPlugin', () => { it('should log the user in', async () => { await login(php, {}); const response = await handler.request({ - url: '/wp-admin', + url: '/wp-admin/', }); expect(response.text).toContain('Dashboard'); }); diff --git a/packages/playground/remote/service-worker.ts b/packages/playground/remote/service-worker.ts index 387c313e74b..177722a967b 100644 --- a/packages/playground/remote/service-worker.ts +++ b/packages/playground/remote/service-worker.ts @@ -55,14 +55,14 @@ initializeServiceWorker({ const workerResponse = await convertFetchEventToPHPRequest(event); if ( workerResponse.status === 404 && - workerResponse.headers.get('x-file-type') === 'static' + workerResponse.headers.get('x-backfill-from') === 'remote-host' ) { const { staticAssetsDirectory } = await getScopedWpDetails( scope! ); if (!staticAssetsDirectory) { const plain404Response = workerResponse.clone(); - plain404Response.headers.delete('x-file-type'); + plain404Response.headers.delete('x-backfill-from'); return plain404Response; } diff --git a/packages/playground/remote/src/lib/worker-thread.ts b/packages/playground/remote/src/lib/worker-thread.ts index 885b2c229d0..3bcbc245298 100644 --- a/packages/playground/remote/src/lib/worker-thread.ts +++ b/packages/playground/remote/src/lib/worker-thread.ts @@ -37,9 +37,10 @@ import transportFetch from './playground-mu-plugin/playground-includes/wp_http_f import transportDummy from './playground-mu-plugin/playground-includes/wp_http_dummy.php?raw'; /** @ts-ignore */ import playgroundWebMuPlugin from './playground-mu-plugin/0-playground.php?raw'; -import { PHP, PHPWorker } from '@php-wasm/universal'; +import { PHP, PHPResponse, PHPWorker } from '@php-wasm/universal'; import { bootWordPress, + getFileNotFoundActionForWordPress, getLoadedWordPressVersion, } from '@wp-playground/wordpress'; import { wpVersionToStaticAssetsDirectory } from '@wp-playground/wordpress-builds'; @@ -336,6 +337,7 @@ try { 'sqlite.zip' ); + const knownRemoteAssetPaths = new Set(); const requestHandler = await bootWordPress({ siteUrl: setURLScope(wordPressSiteUrl, scope).toString(), createPhpRuntime, @@ -367,6 +369,28 @@ try { }, }, }, + getFileNotFoundAction(relativeUri: string) { + if (!knownRemoteAssetPaths.has(relativeUri)) { + return getFileNotFoundActionForWordPress(relativeUri); + } + + // This path is listed as a remote asset. Mark it as a static file + // so the service worker knows it can issue a real fetch() to the server. + return { + type: 'response', + response: new PHPResponse( + 404, + { + 'x-backfill-from': ['remote-host'], + // Include x-file-type header so remote asset + // retrieval continues to work for clients + // running a prior service worker version. + 'x-file-type': ['static'], + }, + new TextEncoder().encode('404 File not found') + ), + }; + }, }); apiEndpoint.__internal_setRequestHandler(requestHandler); @@ -417,6 +441,15 @@ try { } } + if (primaryPhp.isFile(remoteAssetListPath)) { + const remoteAssetPaths = primaryPhp + .readFileAsText(remoteAssetListPath) + .split('\n'); + remoteAssetPaths.forEach((wpRelativePath) => + knownRemoteAssetPaths.add(joinPaths('/', wpRelativePath)) + ); + } + setApiReady(); } catch (e) { setAPIError(e as Error); diff --git a/packages/playground/website/cypress/e2e/remote-assets.cy.ts b/packages/playground/website/cypress/e2e/remote-assets.cy.ts new file mode 100644 index 00000000000..43be96d3311 --- /dev/null +++ b/packages/playground/website/cypress/e2e/remote-assets.cy.ts @@ -0,0 +1,57 @@ +describe('Remote Assets', () => { + const testedStorageOptions = [ + 'none', + // TODO: Re-enable this option once the tests are more stable + //'browser' + ]; + + testedStorageOptions.forEach((storage) => { + it(`should load remote assets for storage=${storage}`, () => { + const expectedRemoteAssetPath = + 'wp-includes/blocks/navigation/style.min.css'; + const blueprint = + '{"siteOptions":{"blogname":"remote asset test"}}'; + + cy.visit(`/?storage=${storage}#${blueprint}`); + runAssertions(); + + if (storage === 'browser') { + // Reload and re-assert to test when loading from browser storage + cy.reload(); + runAssertions(); + } + + function runAssertions() { + // NOTE: This appears to be necessary for Cypress + // to wait until a WordPress page is actually loaded. + // Otherwise, the document.styleSheets assertions hang. + cy.wordPressDocument() + .its('body') + .should('contain', 'remote asset test'); + + cy.window() + .then((win: any) => { + return win.playground + .readFileAsText( + '/wordpress/wordpress-remote-asset-paths' + ) + .then((remoteAssetPaths: string) => { + return cy.wrap(remoteAssetPaths.split('\n')); + }); + }) + .should('include', expectedRemoteAssetPath); + + cy.wordPressDocument() + .its('styleSheets') + .then((styleSheets: StyleSheetList) => + cy.wrap( + Array.from(styleSheets).find((sheet) => + sheet.href?.includes(expectedRemoteAssetPath) + ) + ) + ) + .should('exist'); + } + }); + }); +}); diff --git a/packages/playground/wordpress/src/boot.ts b/packages/playground/wordpress/src/boot.ts index 089ba326161..b9bd52245e2 100644 --- a/packages/playground/wordpress/src/boot.ts +++ b/packages/playground/wordpress/src/boot.ts @@ -1,4 +1,6 @@ import { + FileNotFoundAction, + FileNotFoundGetActionCallback, FileTree, PHP, PHPProcessManager, @@ -83,6 +85,12 @@ export interface BootOptions { * ``` */ createFiles?: FileTree; + + /** + * A callback that decides how to handle a file-not-found condition for a + * given request URI. + */ + getFileNotFoundAction?: FileNotFoundGetActionCallback; } /** @@ -169,6 +177,8 @@ export async function bootWordPress(options: BootOptions) { documentRoot: options.documentRoot || '/wordpress', absoluteUrl: options.siteUrl, rewriteRules: wordPressRewriteRules, + getFileNotFoundAction: + options.getFileNotFoundAction ?? getFileNotFoundActionForWordPress, }); const php = await requestHandler.getPrimaryPhp(); @@ -190,13 +200,13 @@ export async function bootWordPress(options: BootOptions) { php.defineConstant('WP_HOME', options.siteUrl); php.defineConstant('WP_SITEURL', options.siteUrl); - // @TODO Assert WordPress core files are in place - // Run "before database" hooks to mount/copy more files in if (options.hooks?.beforeDatabaseSetup) { await options.hooks.beforeDatabaseSetup(php); } + // @TODO Assert WordPress core files are in place + if (options.sqliteIntegrationPluginZip) { await preloadSqliteIntegration( php, @@ -256,3 +266,15 @@ async function installWordPress(php: PHP) { }) ); } + +export function getFileNotFoundActionForWordPress( + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- maintain consistent FileNotFoundGetActionCallback signature + relativeUri: string +): FileNotFoundAction { + // Delegate unresolved requests to WordPress. This makes WP magic possible, + // like pretty permalinks and dynamically generated sitemaps. + return { + type: 'internal-redirect', + uri: '/index.php', + }; +} diff --git a/packages/playground/wordpress/src/index.ts b/packages/playground/wordpress/src/index.ts index 47bfa9ec5a9..051b5044fb5 100644 --- a/packages/playground/wordpress/src/index.ts +++ b/packages/playground/wordpress/src/index.ts @@ -1,7 +1,8 @@ import { PHP, UniversalPHP } from '@php-wasm/universal'; import { joinPaths, phpVar } from '@php-wasm/util'; import { unzipFile } from '@wp-playground/common'; -export { bootWordPress } from './boot'; +export { bootWordPress, getFileNotFoundActionForWordPress } from './boot'; +export { getLoadedWordPressVersion } from './version-detect'; export * from './version-detect'; export * from './rewrite-rules'; @@ -50,14 +51,6 @@ export async function setupPlatformLevelMuPlugins(php: UniversalPHP) { await php.writeFile( '/internal/shared/mu-plugins/0-playground.php', `