diff --git a/packages/php-wasm/universal/src/lib/index.ts b/packages/php-wasm/universal/src/lib/index.ts index 34a8c0b47e9..d9204ed68a2 100644 --- a/packages/php-wasm/universal/src/lib/index.ts +++ b/packages/php-wasm/universal/src/lib/index.ts @@ -81,6 +81,12 @@ export type { FileNotFoundAction, CookieStore, } from './php-request-handler'; +export { RequestRouter } from './request-router'; +export type { + RouterFilesystem, + ResolvedRoute, + RequestRouterConfig, +} from './request-router'; 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 83e88c82cd7..b2af025ec94 100644 --- a/packages/php-wasm/universal/src/lib/php-request-handler.ts +++ b/packages/php-wasm/universal/src/lib/php-request-handler.ts @@ -1,4 +1,4 @@ -import { dirname, joinPaths } from '@php-wasm/util'; +import { joinPaths } from '@php-wasm/util'; import { ensurePathPrefix, toRelativeUrl, @@ -16,6 +16,8 @@ import type { PHPInstanceManager, AcquiredPHP } from './php-instance-manager'; import { SinglePHPInstanceManager } from './single-php-instance-manager'; import { HttpCookieStore } from './http-cookie-store'; import mimeTypes from './mime-types.json'; +import { RequestRouter } from './request-router'; +import type { RouterFilesystem } from './request-router'; export type RewriteRule = { match: RegExp; @@ -209,6 +211,8 @@ export class PHPRequestHandler implements AsyncDisposable { #PATHNAME: string; #ABSOLUTE_URL: string; #cookieStore: CookieStore | false; + #router: RequestRouter | undefined; + #routerFs: RouterFilesystem | undefined; #pathAliases: PathAlias[]; rewriteRules: RewriteRule[]; /** @@ -448,152 +452,67 @@ export class PHPRequestHandler implements AsyncDisposable { * @returns A StreamedPHPResponse. */ async requestStreamed(request: PHPRequest): Promise { - const isAbsolute = looksLikeAbsoluteUrl(request.url); - const originalRequestUrl = new URL( - // Remove the hash part of the URL as it's not meant for the server. - request.url.split('#')[0], - isAbsolute ? undefined : DEFAULT_BASE_URL - ); - - const rewrittenRequestUrl = this.#applyRewriteRules(originalRequestUrl); const primaryPhp = await this.getPrimaryPhp(); - /** - * Turn a URL such as `https://playground/scope:my-site/wp-admin/index.php` - * into a site-relative path, such as `/wp-admin/index.php`. - */ - const siteRelativePath = removePathPrefix( - /** - * URL.pathname returns a URL-encoded path. We need to decode it - * before using it as a filesystem path. - */ - decodeURIComponent(rewrittenRequestUrl.pathname), - this.#PATHNAME - ); - let fsPath = this.#resolveToFsPath(siteRelativePath); - 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 (!siteRelativePath.endsWith('/')) { + const router = this.#getRouter(primaryPhp); + const route = router.resolve(request); + + switch (route.type) { + case 'static-file': return StreamedPHPResponse.fromPHPResponse( - new PHPResponse( - 301, - { location: [`${rewrittenRequestUrl.pathname}/`] }, - new Uint8Array(0) - ) + this.#serveStaticFile(primaryPhp, route.fsPath) ); - } - - // 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; - - // Include the resolved index file in the final rewritten request URL. - rewrittenRequestUrl.pathname = joinPaths( - rewrittenRequestUrl.pathname, - possibleIndexFile - ); - break; - } - } - } - - if (!primaryPhp.isFile(fsPath)) { - /** - * Try resolving a partial path. - * - * Example: - * - * – Request URL: /file.php/index.php - * – Document Root: /var/www - * - * If /var/www/file.php/index.php does not exist, but /var/www/file.php does, - * use /var/www/file.php. This is also what Apache and PHP Dev Server do. - */ - let pathToTry = siteRelativePath; - while ( - pathToTry.startsWith('/') && - pathToTry !== dirname(pathToTry) - ) { - pathToTry = dirname(pathToTry); - const resolvedPathToTry = this.#resolveToFsPath(pathToTry); - if ( - primaryPhp.isFile(resolvedPathToTry) && - // Only run partial path resolution for PHP files. - resolvedPathToTry.endsWith('.php') - ) { - fsPath = this.#resolveToFsPath(pathToTry); - break; - } - } - } - - if (!primaryPhp.isFile(fsPath)) { - const fileNotFoundAction = this.getFileNotFoundAction( - rewrittenRequestUrl.pathname - ); - switch (fileNotFoundAction.type) { - case 'response': - return StreamedPHPResponse.fromPHPResponse( - fileNotFoundAction.response - ); - case 'internal-redirect': - fsPath = joinPaths(this.#DOCROOT, fileNotFoundAction.uri); - break; - case '404': - return StreamedPHPResponse.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')) { + case 'php': { + const isAbsolute = looksLikeAbsoluteUrl(request.url); + const originalRequestUrl = new URL( + request.url.split('#')[0], + isAbsolute ? undefined : DEFAULT_BASE_URL + ); + const rewrittenRequestUrl = + this.#applyRewriteRules(originalRequestUrl); return await this.#spawnPHPAndDispatchRequest( request, originalRequestUrl, rewrittenRequestUrl, - fsPath + route.fsPath ); - } else { + } + case 'redirect': return StreamedPHPResponse.fromPHPResponse( - this.#serveStaticFile(primaryPhp, fsPath) + new PHPResponse( + route.statusCode, + route.headers, + new Uint8Array(0) + ) ); - } - } else { - return StreamedPHPResponse.forHttpCode(404); + case 'response': + return StreamedPHPResponse.fromPHPResponse(route.response); + case '404': + return StreamedPHPResponse.forHttpCode(404); } } + /** + * Gets or creates the RequestRouter instance, lazily + * initializing the filesystem wrapper on first use. + */ + #getRouter(php: PHP): RequestRouter { + if (!this.#router) { + this.#routerFs = { + isFile: (path: string) => php.isFile(path), + isDir: (path: string) => php.isDir(path), + }; + this.#router = new RequestRouter({ + documentRoot: this.#DOCROOT, + pathname: this.#PATHNAME, + rewriteRules: this.rewriteRules, + pathAliases: this.#pathAliases, + getFileNotFoundAction: this.getFileNotFoundAction, + fs: this.#routerFs, + }); + } + return this.#router; + } + /** * Apply the rewrite rules to the original request URL. * @@ -620,31 +539,6 @@ export class PHPRequestHandler implements AsyncDisposable { return rewrittenRequestUrl; } - /** - * Resolves a URL path to a filesystem path, checking path aliases first. - * - * If the URL path matches a configured alias prefix, the alias's - * filesystem path is used instead of the document root. - * - * @param urlPath - The URL path to resolve (e.g., '/phpmyadmin/index.php') - * @returns The resolved filesystem path - */ - #resolveToFsPath(urlPath: string): string { - // Check if the URL path matches any alias - for (const alias of this.#pathAliases) { - if ( - urlPath === alias.urlPrefix || - urlPath.startsWith(alias.urlPrefix + '/') - ) { - // Replace the URL prefix with the filesystem path - const relativePath = urlPath.slice(alias.urlPrefix.length); - return joinPaths(alias.fsPath, relativePath); - } - } - // No alias matched, use the document root - return joinPaths(this.#DOCROOT, urlPath); - } - /** * Serves a static file from the PHP filesystem. * @@ -657,9 +551,10 @@ export class PHPRequestHandler implements AsyncDisposable { 200, { 'content-length': [`${arrayBuffer.byteLength}`], - // @TODO: Infer the content-type from the arrayBuffer instead of the - // file path. The code below won't return the correct mime-type if the - // extension was tampered with. + // @TODO: Infer the content-type from the + // arrayBuffer instead of the file path. + // The code below won't return the correct + // mime-type if the extension was tampered with. 'content-type': [inferMimeType(fsPath)], 'accept-ranges': ['bytes'], 'cache-control': ['public, max-age=0'], @@ -869,6 +764,7 @@ export class PHPRequestHandler implements AsyncDisposable { REMOTE_ADDR: '127.0.0.1', DOCUMENT_ROOT: this.#DOCROOT, HTTPS: this.#ABSOLUTE_URL.startsWith('https://') ? 'on' : '', + SERVER_PROTOCOL: 'HTTP/1.1', }; /** diff --git a/packages/php-wasm/universal/src/lib/request-router.spec.ts b/packages/php-wasm/universal/src/lib/request-router.spec.ts new file mode 100644 index 00000000000..f5ca33d4b6e --- /dev/null +++ b/packages/php-wasm/universal/src/lib/request-router.spec.ts @@ -0,0 +1,396 @@ +import { describe, expect, it } from 'vitest'; +import { RequestRouter } from './request-router'; +import type { RouterFilesystem, ResolvedRoute } from './request-router'; +import { PHPResponse } from './php-response'; + +/** + * Creates a mock RouterFilesystem backed by a Map. + */ +function createMockFs(entries: Map): RouterFilesystem { + return { + isFile: (path: string) => entries.get(path) === 'file', + isDir: (path: string) => entries.get(path) === 'dir', + }; +} + +describe('RequestRouter', () => { + describe('static files', () => { + it('resolves a static file', () => { + const fs = createMockFs( + new Map([ + ['/www', 'dir'], + ['/www/style.css', 'file'], + ]) + ); + const router = new RequestRouter({ + documentRoot: '/www', + fs, + }); + + const route = router.resolve({ url: '/style.css' }); + expect(route).toEqual({ + type: 'static-file', + fsPath: '/www/style.css', + }); + }); + + it('resolves nested static files', () => { + const fs = createMockFs( + new Map([ + ['/www', 'dir'], + ['/www/assets/img/logo.png', 'file'], + ]) + ); + const router = new RequestRouter({ + documentRoot: '/www', + fs, + }); + + const route = router.resolve({ + url: '/assets/img/logo.png', + }); + expect(route).toEqual({ + type: 'static-file', + fsPath: '/www/assets/img/logo.png', + }); + }); + }); + + describe('PHP files', () => { + it('resolves a PHP file', () => { + const fs = createMockFs( + new Map([ + ['/www', 'dir'], + ['/www/index.php', 'file'], + ]) + ); + const router = new RequestRouter({ + documentRoot: '/www', + fs, + }); + + const route = router.resolve({ url: '/index.php' }); + expect(route).toEqual({ + type: 'php', + fsPath: '/www/index.php', + }); + }); + + it('resolves partial path (PATH_INFO) to PHP file', () => { + const fs = createMockFs( + new Map([ + ['/www', 'dir'], + ['/www/file.php', 'file'], + ]) + ); + const router = new RequestRouter({ + documentRoot: '/www', + fs, + }); + + const route = router.resolve({ + url: '/file.php/extra/path', + }); + expect(route).toEqual({ + type: 'php', + fsPath: '/www/file.php', + }); + }); + }); + + describe('directories', () => { + it('redirects dir without trailing slash', () => { + const fs = createMockFs( + new Map([ + ['/www', 'dir'], + ['/www/subdir', 'dir'], + ['/www/subdir/index.php', 'file'], + ]) + ); + const router = new RequestRouter({ + documentRoot: '/www', + fs, + }); + + const route = router.resolve({ url: '/subdir' }); + expect(route).toEqual({ + type: 'redirect', + statusCode: 301, + headers: { location: ['/subdir/'] }, + }); + }); + + it('resolves dir with trailing slash to index.php', () => { + const fs = createMockFs( + new Map([ + ['/www', 'dir'], + ['/www/subdir', 'dir'], + ['/www/subdir/', 'dir'], + ['/www/subdir/index.php', 'file'], + ]) + ); + const router = new RequestRouter({ + documentRoot: '/www', + fs, + }); + + const route = router.resolve({ url: '/subdir/' }); + expect(route).toEqual({ + type: 'php', + fsPath: '/www/subdir/index.php', + }); + }); + + it('resolves dir with trailing slash to index.html', () => { + const fs = createMockFs( + new Map([ + ['/www', 'dir'], + ['/www/docs', 'dir'], + ['/www/docs/', 'dir'], + ['/www/docs/index.html', 'file'], + ]) + ); + const router = new RequestRouter({ + documentRoot: '/www', + fs, + }); + + const route = router.resolve({ url: '/docs/' }); + expect(route).toEqual({ + type: 'static-file', + fsPath: '/www/docs/index.html', + }); + }); + }); + + describe('file not found', () => { + it('returns 404 by default', () => { + const fs = createMockFs(new Map([['/www', 'dir']])); + const router = new RequestRouter({ + documentRoot: '/www', + fs, + }); + + const route = router.resolve({ url: '/nonexistent.txt' }); + expect(route).toEqual({ type: '404' }); + }); + + it('handles internal-redirect action (WordPress fallback)', () => { + const fs = createMockFs( + new Map([ + ['/www', 'dir'], + ['/www/index.php', 'file'], + ]) + ); + const router = new RequestRouter({ + documentRoot: '/www', + getFileNotFoundAction: () => ({ + type: 'internal-redirect', + uri: '/index.php', + }), + fs, + }); + + const route = router.resolve({ + url: '/pretty-permalink', + }); + expect(route).toEqual({ + type: 'php', + fsPath: '/www/index.php', + }); + }); + + it('handles response action', () => { + const customResponse = new PHPResponse( + 403, + { 'content-type': ['text/plain'] }, + new Uint8Array(Buffer.from('Forbidden')) + ); + const fs = createMockFs(new Map([['/www', 'dir']])); + const router = new RequestRouter({ + documentRoot: '/www', + getFileNotFoundAction: () => ({ + type: 'response', + response: customResponse, + }), + fs, + }); + + const route = router.resolve({ url: '/secret' }); + expect(route.type).toBe('response'); + expect( + (route as Extract).response + ).toBe(customResponse); + }); + }); + + describe('rewrite rules', () => { + it('applies rewrite rules before routing', () => { + const fs = createMockFs( + new Map([ + ['/www', 'dir'], + ['/www/index.php', 'file'], + ]) + ); + const router = new RequestRouter({ + documentRoot: '/www', + rewriteRules: [ + { + match: /^\/api\/(.*)$/, + replacement: '/index.php?route=$1', + }, + ], + getFileNotFoundAction: () => ({ + type: 'internal-redirect', + uri: '/index.php', + }), + fs, + }); + + const route = router.resolve({ url: '/api/users' }); + expect(route).toEqual({ + type: 'php', + fsPath: '/www/index.php', + }); + }); + + it('WordPress multisite rewrite rule strips site prefix', () => { + const fs = createMockFs( + new Map([ + ['/www', 'dir'], + ['/www/wp-admin/index.php', 'file'], + ]) + ); + const router = new RequestRouter({ + documentRoot: '/www', + rewriteRules: [ + { + match: new RegExp( + `^(/[_0-9a-zA-Z-]+)?(/wp-(content|admin|includes).*)` + ), + replacement: '$2', + }, + ], + fs, + }); + + const route = router.resolve({ + url: '/mysite/wp-admin/index.php', + }); + expect(route).toEqual({ + type: 'php', + fsPath: '/www/wp-admin/index.php', + }); + }); + }); + + describe('path aliases', () => { + it('resolves aliased paths', () => { + const fs = createMockFs( + new Map([ + ['/www', 'dir'], + ['/tools/phpmyadmin', 'dir'], + ['/tools/phpmyadmin/index.php', 'file'], + ]) + ); + const router = new RequestRouter({ + documentRoot: '/www', + pathAliases: [ + { + urlPrefix: '/phpmyadmin', + fsPath: '/tools/phpmyadmin', + }, + ], + fs, + }); + + const route = router.resolve({ + url: '/phpmyadmin/index.php', + }); + expect(route).toEqual({ + type: 'php', + fsPath: '/tools/phpmyadmin/index.php', + }); + }); + + it('falls back to document root for non-aliased paths', () => { + const fs = createMockFs( + new Map([ + ['/www', 'dir'], + ['/www/index.php', 'file'], + ['/tools/phpmyadmin', 'dir'], + ]) + ); + const router = new RequestRouter({ + documentRoot: '/www', + pathAliases: [ + { + urlPrefix: '/phpmyadmin', + fsPath: '/tools/phpmyadmin', + }, + ], + fs, + }); + + const route = router.resolve({ url: '/index.php' }); + expect(route).toEqual({ + type: 'php', + fsPath: '/www/index.php', + }); + }); + + it('matches alias exactly at prefix boundary', () => { + const fs = createMockFs( + new Map([ + ['/www', 'dir'], + ['/www/phpmyadmin-extra', 'dir'], + ['/www/phpmyadmin-extra/index.php', 'file'], + ['/tools/phpmyadmin', 'dir'], + ]) + ); + const router = new RequestRouter({ + documentRoot: '/www', + pathAliases: [ + { + urlPrefix: '/phpmyadmin', + fsPath: '/tools/phpmyadmin', + }, + ], + fs, + }); + + const route = router.resolve({ + url: '/phpmyadmin-extra/index.php', + }); + // Should resolve in document root, not alias + expect(route).toEqual({ + type: 'php', + fsPath: '/www/phpmyadmin-extra/index.php', + }); + }); + }); + + describe('URL pathname prefix', () => { + it('strips pathname prefix before resolving', () => { + const fs = createMockFs( + new Map([ + ['/www', 'dir'], + ['/www/style.css', 'file'], + ]) + ); + const router = new RequestRouter({ + documentRoot: '/www', + pathname: '/scope:mysite', + fs, + }); + + const route = router.resolve({ + url: '/scope:mysite/style.css', + }); + expect(route).toEqual({ + type: 'static-file', + fsPath: '/www/style.css', + }); + }); + }); +}); diff --git a/packages/php-wasm/universal/src/lib/request-router.ts b/packages/php-wasm/universal/src/lib/request-router.ts new file mode 100644 index 00000000000..852d92bdd0f --- /dev/null +++ b/packages/php-wasm/universal/src/lib/request-router.ts @@ -0,0 +1,212 @@ +import { dirname, joinPaths } from '@php-wasm/util'; +import { removePathPrefix } from './urls'; +import type { PHPResponse } from './php-response'; +import type { PHPRequest } from './universal-php'; +import type { + RewriteRule, + PathAlias, + FileNotFoundGetActionCallback, + FileNotFoundAction, +} from './php-request-handler'; +import { applyRewriteRules } from './php-request-handler'; + +/** + * Minimal filesystem interface for routing decisions. + * Implementations can back this with a WASM filesystem, + * host filesystem, or any other source. + */ +export interface RouterFilesystem { + isFile(path: string): boolean; + isDir(path: string): boolean; +} + +export type ResolvedRoute = + | { type: 'static-file'; fsPath: string } + | { type: 'php'; fsPath: string } + | { + type: 'redirect'; + statusCode: number; + headers: Record; + } + | { type: '404' } + | { type: 'response'; response: PHPResponse }; + +export interface RequestRouterConfig { + documentRoot: string; + /** + * URL path prefix (e.g., '/scope:xxx'). Stripped from + * request URLs before filesystem resolution. + */ + pathname?: string; + rewriteRules?: RewriteRule[]; + pathAliases?: PathAlias[]; + getFileNotFoundAction?: FileNotFoundGetActionCallback; + fs: RouterFilesystem; +} + +/** + * A pure routing engine that resolves a request URL to a routing + * decision. Has no PHP dependency — only needs a filesystem + * abstraction for isFile/isDir checks. + * + * This class encapsulates the routing logic previously embedded in + * PHPRequestHandler.requestStreamed(), making it reusable on the + * main thread (e.g., the CLI can create a router backed by the + * host filesystem to serve static files without a worker round-trip). + */ +export class RequestRouter { + #documentRoot: string; + #pathname: string; + #rewriteRules: RewriteRule[]; + #pathAliases: PathAlias[]; + #getFileNotFoundAction: FileNotFoundGetActionCallback; + #fs: RouterFilesystem; + + constructor(config: RequestRouterConfig) { + this.#documentRoot = config.documentRoot; + this.#pathname = config.pathname ?? ''; + this.#rewriteRules = config.rewriteRules ?? []; + this.#pathAliases = config.pathAliases ?? []; + this.#getFileNotFoundAction = + config.getFileNotFoundAction ?? (() => ({ type: '404' })); + this.#fs = config.fs; + } + + resolve(request: PHPRequest): ResolvedRoute { + const isAbsolute = looksLikeAbsoluteUrl(request.url); + const originalRequestUrl = new URL( + request.url.split('#')[0], + isAbsolute ? undefined : 'http://example.com' + ); + + const rewrittenRequestUrl = this.#applyRewriteRules(originalRequestUrl); + + const siteRelativePath = removePathPrefix( + decodeURIComponent(rewrittenRequestUrl.pathname), + this.#pathname + ); + let fsPath = this.#resolveToFsPath(siteRelativePath); + + if (this.#fs.isDir(fsPath)) { + if (!siteRelativePath.endsWith('/')) { + return { + type: 'redirect', + statusCode: 301, + headers: { + location: [`${rewrittenRequestUrl.pathname}/`], + }, + }; + } + + for (const possibleIndexFile of ['index.php', 'index.html']) { + const possibleIndexPath = joinPaths(fsPath, possibleIndexFile); + if (this.#fs.isFile(possibleIndexPath)) { + fsPath = possibleIndexPath; + rewrittenRequestUrl.pathname = joinPaths( + rewrittenRequestUrl.pathname, + possibleIndexFile + ); + break; + } + } + } + + if (!this.#fs.isFile(fsPath)) { + // Try resolving a partial path (e.g., /file.php/path-info) + let pathToTry = siteRelativePath; + while ( + pathToTry.startsWith('/') && + pathToTry !== dirname(pathToTry) + ) { + pathToTry = dirname(pathToTry); + const resolvedPathToTry = this.#resolveToFsPath(pathToTry); + if ( + this.#fs.isFile(resolvedPathToTry) && + resolvedPathToTry.endsWith('.php') + ) { + fsPath = this.#resolveToFsPath(pathToTry); + break; + } + } + } + + if (!this.#fs.isFile(fsPath)) { + const fileNotFoundAction = this.#getFileNotFoundAction( + rewrittenRequestUrl.pathname + ); + switch (fileNotFoundAction.type) { + case 'response': + return { + type: 'response', + response: fileNotFoundAction.response, + }; + case 'internal-redirect': + fsPath = joinPaths( + this.#documentRoot, + fileNotFoundAction.uri + ); + break; + case '404': + return { type: '404' }; + default: + throw new Error( + 'Unsupported file-not-found action type: ' + + `'${ + (fileNotFoundAction as FileNotFoundAction).type + }'` + ); + } + } + + if (this.#fs.isFile(fsPath)) { + if (fsPath.endsWith('.php')) { + return { type: 'php', fsPath }; + } else { + return { type: 'static-file', fsPath }; + } + } + + return { type: '404' }; + } + + #applyRewriteRules(originalRequestUrl: URL): URL { + const siteRelativePath = removePathPrefix( + decodeURIComponent(originalRequestUrl.pathname), + this.#pathname + ); + const rewrittenRequestPath = applyRewriteRules( + siteRelativePath, + this.#rewriteRules + ); + const rewrittenRequestUrl = new URL( + joinPaths(this.#pathname, rewrittenRequestPath), + originalRequestUrl.toString() + ); + for (const [key, value] of originalRequestUrl.searchParams.entries()) { + rewrittenRequestUrl.searchParams.append(key, value); + } + return rewrittenRequestUrl; + } + + #resolveToFsPath(urlPath: string): string { + for (const alias of this.#pathAliases) { + if ( + urlPath === alias.urlPrefix || + urlPath.startsWith(alias.urlPrefix + '/') + ) { + const relativePath = urlPath.slice(alias.urlPrefix.length); + return joinPaths(alias.fsPath, relativePath); + } + } + return joinPaths(this.#documentRoot, urlPath); + } +} + +function looksLikeAbsoluteUrl(url: string): boolean { + try { + new URL(url); + return true; + } catch { + return false; + } +} diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index 21c08621c13..e2c7f35b668 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -15,7 +15,10 @@ import { exposeAPI, exposeSyncAPI, printDebugDetails, + RequestRouter, + inferMimeType, } from '@php-wasm/universal'; +import type { RouterFilesystem } from '@php-wasm/universal'; import type { BlueprintBundle, BlueprintV1Declaration, @@ -26,7 +29,14 @@ import { runBlueprintV1Steps, } from '@wp-playground/blueprints'; import { RecommendedPHPVersion } from '@wp-playground/common'; -import fs, { existsSync, mkdirSync, readdirSync, rmdirSync } from 'fs'; +import fs, { + existsSync, + mkdirSync, + readdirSync, + rmdirSync, + statSync, + createReadStream, +} from 'fs'; import type { Server } from 'http'; import { MessageChannel as NodeMessageChannel, Worker } from 'worker_threads'; // @ts-ignore @@ -63,7 +73,11 @@ import { cleanupStalePlaygroundTempDirs, createPlaygroundCliTempDir, } from './temp-dir'; -import { type WordPressInstallMode } from '@wp-playground/wordpress'; +import { + type WordPressInstallMode, + wordPressRewriteRules, + getFileNotFoundActionForWordPress, +} from '@wp-playground/wordpress'; import { type Mount, addXdebugIDEConfig, @@ -986,6 +1000,8 @@ export async function runCLI(args: RunCLIArgs): Promise { let wordPressReady = false; let isFirstRequest = true; + let mainThreadRouter: RequestRouter | undefined; + let mapVfsToHost: ((vfsPath: string) => string | undefined) | undefined; const server = await startServer({ port: args.port @@ -1292,6 +1308,15 @@ export async function runCLI(args: RunCLIArgs): Promise { } } + // Build the mount table (VFS path → host path) so the + // main-thread router can map VFS paths to host disk. + // Post-install mounts override pre-install mounts for + // the same VFS prefix. + const allMounts = [ + ...(args['mount-before-install'] || []), + ...(args['mount'] || []), + ]; + let handler: BlueprintsV1Handler | BlueprintsV2Handler; if (args['experimental-blueprints-v2-runner']) { handler = new BlueprintsV2Handler(args, { @@ -1452,6 +1477,16 @@ export async function runCLI(args: RunCLIArgs): Promise { wordPressReady = true; + // Create main-thread router backed by the + // host filesystem so static files can be served + // without a worker round-trip. + const routerResult = createMainThreadRouter( + allMounts, + args['pathAliases'] || [] + ); + mainThreadRouter = routerResult.router; + mapVfsToHost = routerResult.mapVfsToHost; + if (!args['experimental-blueprints-v2-runner']) { const compiledBlueprint = await ( handler as BlueprintsV1Handler @@ -1595,6 +1630,39 @@ export async function runCLI(args: RunCLIArgs): Promise { new PHPResponse(302, headers, new Uint8Array()) ); } + // Main-thread routing: resolve the request against the + // host filesystem to serve static files without a worker + // round-trip. + if (mainThreadRouter && mapVfsToHost) { + const route = mainThreadRouter.resolve(request); + switch (route.type) { + case 'static-file': { + const hostPath = mapVfsToHost(route.fsPath); + if (hostPath) { + return serveStaticFileFromHost(hostPath); + } + break; + } + case 'redirect': + return StreamedPHPResponse.fromPHPResponse( + new PHPResponse( + route.statusCode, + route.headers, + new Uint8Array(0) + ) + ); + case 'response': + return StreamedPHPResponse.fromPHPResponse( + route.response + ); + case '404': + return StreamedPHPResponse.forHttpCode(404); + case 'php': + // Fall through to the worker pool below. + break; + } + } + if (cookieStore) { request = { ...request, @@ -1610,8 +1678,6 @@ export async function runCLI(args: RunCLIArgs): Promise { }; } - // TODO: Explore switching to a worker thread method to adopt an entire HTTP connection - // It might be more efficient to let the worker respond directly const response = await playgroundPool.requestStreamed(request); if (cookieStore) { @@ -1636,6 +1702,128 @@ export async function runCLI(args: RunCLIArgs): Promise { return server; } +/** + * Creates a RequestRouter backed by the host filesystem for + * main-thread static file serving. + * + * The router maps VFS paths (e.g. /wordpress/wp-content/style.css) + * to host paths via the CLI's mount table, allowing static files to + * be served directly from disk without a worker round-trip. + */ +function createMainThreadRouter( + mounts: Mount[], + pathAliases: PathAlias[] +): { + router: RequestRouter; + mapVfsToHost: (vfsPath: string) => string | undefined; +} { + // Sort mounts longest-prefix-first so the most specific + // mount wins when multiple mounts overlap. + const sortedMounts = [...mounts].sort( + (a, b) => b.vfsPath.length - a.vfsPath.length + ); + + function mapVfsToHost(vfsPath: string): string | undefined { + for (const mount of sortedMounts) { + if ( + vfsPath === mount.vfsPath || + vfsPath.startsWith(mount.vfsPath + '/') + ) { + const relativePath = vfsPath.slice(mount.vfsPath.length); + return path.join(mount.hostPath, relativePath); + } + } + return undefined; + } + + const hostFs: RouterFilesystem = { + isFile(vfsPath: string): boolean { + const hostPath = mapVfsToHost(vfsPath); + if (!hostPath) return false; + try { + return statSync(hostPath).isFile(); + } catch { + return false; + } + }, + isDir(vfsPath: string): boolean { + const hostPath = mapVfsToHost(vfsPath); + if (!hostPath) return false; + try { + return statSync(hostPath).isDirectory(); + } catch { + return false; + } + }, + }; + + const router = new RequestRouter({ + documentRoot: '/wordpress', + rewriteRules: wordPressRewriteRules, + pathAliases, + getFileNotFoundAction: getFileNotFoundActionForWordPress, + fs: hostFs, + }); + + return { router, mapVfsToHost }; +} + +/** + * Serves a static file from the host filesystem as a + * StreamedPHPResponse, using Node.js streams to avoid + * buffering the entire file in memory. + */ +function serveStaticFileFromHost(hostPath: string): StreamedPHPResponse { + const stat = statSync(hostPath); + const headersJson = JSON.stringify({ + status: 200, + headers: [ + `content-type: ${inferMimeType(hostPath)}`, + `content-length: ${stat.size}`, + 'accept-ranges: bytes', + 'cache-control: public, max-age=0', + ], + }); + + const headersStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(headersJson)); + controller.close(); + }, + }); + + const fileStream = createReadStream(hostPath); + const stdout = new ReadableStream({ + start(controller) { + fileStream.on('data', (chunk: Buffer) => { + controller.enqueue(new Uint8Array(chunk)); + }); + fileStream.on('end', () => { + controller.close(); + }); + fileStream.on('error', (err) => { + controller.error(err); + }); + }, + cancel() { + fileStream.destroy(); + }, + }); + + const stderr = new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + + return new StreamedPHPResponse( + headersStream, + stdout, + stderr, + Promise.resolve(0) + ); +} + /** * Transforms CLI args for the `start` command into the `server` command arguments. *