diff --git a/README.md b/README.md index 8d167f53..d2e24b80 100644 --- a/README.md +++ b/README.md @@ -123,13 +123,30 @@ Do you have more questions (❓)? Let's move to [FAQ](#%EF%B8%8F-faq). ## 👨‍💻 `vercel dev` -For running `vercel dev` properly, you need to have PHP installed on your computer, [learn more](errors/now-dev-no-local-php.md). -But it's PHP and as you know PHP has built-in development server. It works out of box. +`vercel dev` runs your PHP functions using a local PHP CLI installation. Install PHP and the extensions your application needs, then run from your project root: +```sh +# For Composer projects, install dependencies locally first: +composer install +vercel dev ``` + +PHP must be available on `PATH`. Alternatively, set `VERCEL_PHP_EXECUTABLE` to the full path of your PHP executable. See [local PHP setup](errors/now-dev-no-local-php.md). + +Vercel handles `vercel.json` routing and supplies its development environment variables. Each matched PHP request starts a loopback-only PHP built-in server on an available port, with that function's entrypoint as its router. Vercel shuts down the server after the response. The working directory and default document root are the project root; `VERCEL_PHP_DOCROOT` can select a different document root. + +Local development uses your installed PHP version, extensions and configuration, not the bundled Linux deployment runtime. If `api/php.ini` exists, the runtime appends the `api` directory to PHP's configuration scan path, so `api/*.ini` overrides load after your host's primary configuration. Existing `PHPRC` and `PHP_INI_SCAN_DIR` settings are preserved. Use portable directives and configure local extension paths separately. Composer installation and the `composer run vercel` build script are not run automatically by the dev hook. Run any setup or generation your application needs before starting development. + +This supports local HTTP development, not Lambda resource limits or production isolation. Servers are request-scoped, so do not rely on PHP process state between requests. The runtime disables PHP's extra CLI server workers to keep process cleanup bounded. + +You can still bypass Vercel and use [PHP's built-in server](https://www.php.net/manual/en/features.commandline.webserver.php): + +```sh php -S localhost:8000 api/index.php ``` +That command does not apply Vercel routes or load Vercel environment variables. `api/index.php` runs as a router on every request; return `false` from the router to serve a requested static file directly. Use your framework's development server when appropriate. PHP's built-in server is for development, not public production hosting. + ## 👀 Demo - official - https://php.vercel.app/ @@ -366,11 +383,7 @@ All files in root folder are uploaded to Vercel, use `.vercelignore` to exclude
9. How to develop locally? -I think the best way at this moment is use [PHP Development Server](https://www.php.net/manual/en/features.commandline.webserver.php). - -``` -php -S localhost:8000 api/index.php -``` +Install local PHP and any Composer dependencies, then use `vercel dev`. See the [`vercel dev` section](#-vercel-dev) for configuration and the standalone PHP-server alternative.
diff --git a/errors/now-dev-no-local-php.md b/errors/now-dev-no-local-php.md index 08ce2cbc..1b548af3 100644 --- a/errors/now-dev-no-local-php.md +++ b/errors/now-dev-no-local-php.md @@ -1,47 +1,25 @@ -# It looks like you don't have PHP on your machine. +# Local PHP is required for `vercel dev` -**Why This Error Occurred** +The runtime uses your installed PHP CLI for local development. It does not run the bundled Linux deployment binary on your computer. PHP-CGI is not required. -You ran `now dev` on a machine where PHP is not installed. -For the time being, this runtime requires a local PHP installation to run the runtime locally. +Install a supported PHP CLI version using your operating system's package manager or the [PHP installation instructions](https://www.php.net/manual/en/install.php). On Windows, install a native PHP build and its required Visual C++ runtime. -**Possible Ways to Fix It** +Check the executable and configuration in the terminal where you run Vercel: -1. Install PHP to your computer - -**OSX** - -``` -brew install php@7.4 -``` - -**Ubuntu** - -``` -apt-get -y install apt-transport-https lsb-release ca-certificates -wget -O /etc/apt/trusted.gpg.d/php.gpg https://packages.sury.org/php/apt.gpg -sh -c 'echo "deb https://packages.sury.org/php/ $(lsb_release -sc) main" > /etc/apt/sources.list.d/php.list' -apt-get update -apt-get install php7.4-cli php7.4-cgi php7.4-json php7.4-curl php7.4-mbstring +```sh +php -v +php --ini ``` -**Fedora** +Add PHP to `PATH`, or set `VERCEL_PHP_EXECUTABLE` to the full executable path in that terminal's environment. For example, in PowerShell: -``` -yum install https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm -yum install https://rpms.remirepo.net/enterprise/remi-release-7.rpm -yum install yum-utils -yum-config-manager --enable remi-php74 -yum update -yum install php74-cli php74-cgi php74-json php74-curl php74-mbstring +```powershell +$env:VERCEL_PHP_EXECUTABLE = 'C:\php\php.exe' +vercel dev ``` -2. Start PHP built-in Development Server - -```sh -php -S localhost:8000 api/index.php -``` +Install the extensions your application requires. Startup warnings about missing DLLs or shared libraries usually mean your local `php.ini` references another installation. Correct those paths or use `PHPRC` to select a local configuration. The runtime preserves your host's configuration and appends `api/*.ini` project overrides when `api/php.ini` exists; it does not merge the Linux runtime's defaults. -**Check that php is in the path** +For Composer projects, run `composer install` locally before starting `vercel dev`. The dev hook does not install dependencies or run Composer build scripts automatically. -If you do have installed PHP but still get this error, check that PHP executable is added to the PATH environment variable. +See the [README local development section](../README.md#-vercel-dev) for routing, environment variables, document roots and the standalone PHP-server alternative. diff --git a/src/dev-server.ts b/src/dev-server.ts new file mode 100644 index 00000000..8cee4e95 --- /dev/null +++ b/src/dev-server.ts @@ -0,0 +1,146 @@ +import { spawn, ChildProcess } from 'child_process'; +import { promises as fs } from 'fs'; +import net from 'net'; +import path from 'path'; +import type { StartDevServer } from '@vercel/build-utils'; + +// Windows treats environment names case-insensitively, but Node's child_process +// only passes the first spelling. Merge overrides without retaining duplicates. +function localEnvironment(overrides: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const source of [process.env, overrides]) { + for (const [key, value] of Object.entries(source)) { + env[process.platform === 'win32' ? key.toUpperCase() : key] = value; + } + } + // One PHP process per invocation. Worker children would escape pid cleanup. + for (const key of Object.keys(env)) { + if (key.toUpperCase() === 'PHP_CLI_SERVER_WORKERS') delete env[key]; + } + return env; +} + +async function availablePort(): Promise { + const server = net.createServer(); + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + reject(new Error('Unable to allocate a local PHP server port.')); + return; + } + server.close(error => error ? reject(error) : resolve(address.port)); + }); + }); +} + +function stop(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null || !child.pid) { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + child.kill('SIGKILL'); + }, 1000); + const deadline = setTimeout(() => { + cleanup(); + reject(new Error(`Unable to stop local PHP process ${child.pid}.`)); + }, 5000); + const cleanup = () => { + clearTimeout(timer); + clearTimeout(deadline); + child.removeListener('close', closed); + }; + const closed = () => { cleanup(); resolve(); }; + child.once('close', closed); + child.kill(); + }); +} + +function ready(child: ChildProcess, port: number): Promise { + return new Promise((resolve, reject) => { + let stderr = ''; + let socket: net.Socket | undefined; + const timeout = setTimeout(() => finish(new Error('Local PHP server startup timed out.')), 10000); + const finish = (error?: Error) => { + clearTimeout(timeout); + socket?.destroy(); + child.removeListener('error', failed); + child.removeListener('close', closed); + child.stderr?.removeListener('data', output); + error ? reject(error) : resolve(); + }; + const failed = (error: Error) => finish(new Error( + `Unable to start local PHP. Install PHP CLI and add it to PATH, or set VERCEL_PHP_EXECUTABLE. ${error.message}` + )); + const closed = (code: number | null) => finish(new Error( + `Local PHP server exited before becoming ready (code ${code}). ${stderr.trim()}` + )); + const output = (data: Buffer) => { + stderr = (stderr + data.toString()).slice(-8192); + // Wait for PHP's own startup confirmation before probing. A different + // process could have claimed the allocated port between close and spawn. + if (!socket && /Development Server .*started/.test(stderr)) { + socket = net.connect(port, '127.0.0.1'); + socket.once('connect', () => finish()); + socket.once('error', error => finish(error)); + } + }; + child.once('error', failed); + child.once('close', closed); + child.stderr?.on('data', output); + }); +} + +export const startDevServer: StartDevServer = async ({ + entrypoint, workPath, meta = {}, publicDir, onStdout, onStderr, +}) => { + const env = localEnvironment(meta.env || {}); + const executable = env.VERCEL_PHP_EXECUTABLE || 'php'; + const router = path.resolve(workPath, entrypoint); + const docroot = path.resolve(workPath, env.VERCEL_PHP_DOCROOT || publicDir || '.'); + if (!(await fs.stat(router)).isFile()) throw new Error(`PHP entrypoint is not a file: ${router}`); + if (!(await fs.stat(docroot)).isDirectory()) throw new Error(`PHP document root is not a directory: ${docroot}`); + + const ini = path.join(workPath, 'api', 'php.ini'); + try { + if ((await fs.stat(ini)).isFile()) { + // Keep the host's primary php.ini/extensions and scan project overrides + // afterward, rather than loading Linux runtime configuration locally. + env.PHP_INI_SCAN_DIR = `${env.PHP_INI_SCAN_DIR || ''}${path.delimiter}${path.dirname(ini)}`; + } + } catch (error) { + if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') throw error; + } + + for (let attempt = 0; attempt < 3; attempt++) { + const port = await availablePort(); + const child = spawn(executable, ['-S', `127.0.0.1:${port}`, '-t', docroot, router], { + cwd: workPath, env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, + }); + let shutdownPromise: Promise | undefined; + const shutdown = () => { + process.removeListener('exit', onExit); + if (!shutdownPromise) shutdownPromise = stop(child); + return shutdownPromise; + }; + // Do not accumulate per-request exit handlers after CLI shutdown. + const onExit = () => { child.kill(); }; + process.once('exit', onExit); + child.once('close', () => process.removeListener('exit', onExit)); + child.stdout?.on('data', onStdout || (data => process.stdout.write(data))); + child.stderr?.on('data', onStderr || (data => process.stderr.write(data))); + try { + await ready(child, port); + if (!child.pid) throw new Error('Local PHP server did not provide a process ID.'); + return { port, pid: child.pid, shutdown }; + } catch (error) { + await shutdown(); + if (attempt < 2 && error instanceof Error && /Failed to listen/.test(error.message)) continue; + throw error; + } + } + throw new Error('Unable to allocate a local PHP server port.'); +}; diff --git a/src/index.ts b/src/index.ts index 1eb51f25..ed9303c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,15 +33,9 @@ export const build: BuildV3 = async ({ config = {}, meta = {}, }) => { - // Check if now dev mode is used + // Local HTTP invocations use startDevServer, never the Linux Lambda bundle. if (meta.isDev) { - console.log(` - 🐘 vercel dev is not supported right now. - Please use PHP built-in development server. - - php -S localhost:8000 api/index.php - `); - process.exit(255); + throw new Error('Local PHP requests must use the startDevServer hook. Update your Vercel CLI.'); } console.log('🐘 Downloading user files'); @@ -153,3 +147,4 @@ export const prepareCache: PrepareCache = async ({ workPath }) => { }; export { shouldServe }; +export { startDevServer } from './dev-server'; diff --git a/test/integration/README.md b/test/integration/README.md new file mode 100644 index 00000000..6156c8fc --- /dev/null +++ b/test/integration/README.md @@ -0,0 +1,33 @@ +# Local development tests + +Build the runtime before running tests: + +```sh +npm run build +npx jest --runInBand test/spec/index.dev.js +``` + +The PHP tests use `php` on `PATH`, or `TEST_PHP_EXECUTABLE` if set. Tests that need PHP are explicitly skipped with a warning if the executable cannot run `-n -v`. Missing-executable and BuildV3 export/fallback tests always run. Set `PHPRC` when your host installation needs an isolated configuration. Tests create and remove fixtures under the ignored `.local` directory. + +## Real Vercel CLI dispatch + +Install Vercel CLI separately in an ignored tool directory, or use an existing installation. For example: + +```sh +npm install --prefix .local/cli --ignore-scripts vercel@50.4.5 +TEST_VERCEL_CLI="$PWD/.local/cli/node_modules/vercel/dist/vc.js" node test/integration/vercel-dev.js +``` + +In PowerShell: + +```powershell +$env:TEST_VERCEL_CLI = "$PWD/.local/cli/node_modules/vercel/dist/vc.js" +$env:TEST_PHP_EXECUTABLE = 'C:\php\php.exe' +node test/integration/vercel-dev.js +``` + +Without `TEST_VERCEL_CLI` or runnable PHP, this script prints an explicit skip message. It invokes the unmodified CLI against generated projects using both legacy `builds` and current `functions` configurations, with this checkout's compiled runtime in the project's local builder cache. It checks direct and nested entrypoints, rewrites and query merging, form/binary bodies, concurrent endpoints, static files, `.env`, project ini overrides and the CLI's per-response PHP shutdown. + +No account or deployment is used. Global config is isolated inside each fixture; the command uses a dummy token and the CLI's `__VERCEL_SKIP_DEV_CMD` test escape hatch to avoid project linking. The API URL is a loopback trap, and the test asserts that no API calls occurred. It never changes real credentials. Tool installs and generated fixtures are not part of the package or PR. + +The hook follows the [Vercel CLI 50.4.5 dispatch contract](https://github.com/vercel/vercel/blob/vercel%4050.4.5/packages/cli/src/util/dev/server.ts): a hook call per matched request, original URL plus routing query parameters proxied to the returned port, and `shutdown` on response close. It does not cache a persistent PHP process across invocations. diff --git a/test/integration/vercel-dev.js b/test/integration/vercel-dev.js new file mode 100644 index 00000000..95efa637 --- /dev/null +++ b/test/integration/vercel-dev.js @@ -0,0 +1,139 @@ +// Real CLI dispatch test, with local builder cache and no Vercel account/API. +// TEST_VERCEL_CLI=/path/to/vercel/dist/vc.js TEST_PHP_EXECUTABLE=/path/to/php node test/integration/vercel-dev.js +const assert = require('assert/strict'); +const fs = require('fs/promises'); +const path = require('path'); +const http = require('http'); +const net = require('net'); +const { spawn, spawnSync } = require('child_process'); + +const root = path.resolve(__dirname, '../..'); +const cli = process.env.TEST_VERCEL_CLI; +const php = process.env.TEST_PHP_EXECUTABLE || 'php'; +if (!cli || spawnSync(php, ['-n', '-v']).status !== 0) { + console.error('CLI integration skipped: set TEST_VERCEL_CLI and install PHP or set TEST_PHP_EXECUTABLE.'); + process.exit(0); +} + +function request(port, url, method = 'GET', body, headers = {}) { + return new Promise((resolve, reject) => { + const req = http.request({ hostname: '127.0.0.1', port, path: url, method, headers }, res => { + const chunks = []; + res.on('data', chunk => chunks.push(chunk)); + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) })); + }); + req.on('error', reject); + req.setTimeout(5000, () => req.destroy(new Error('Request timed out'))); + req.end(body); + }); +} +async function freePort() { + const server = net.createServer(); + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); + const port = server.address().port; + await new Promise(resolve => server.close(resolve)); + return port; +} +async function run(mode) { + const fixtures = path.join(root, '.local/cli-tests'); + await fs.mkdir(fixtures, { recursive: true }); + const cwd = await fs.mkdtemp(path.join(fixtures, `${mode}-`)); + let child; + let output = ''; + try { + await fs.mkdir(path.join(cwd, 'api')); + await fs.writeFile(path.join(cwd, 'api/php.ini'), 'precision=12\n'); + const phpSource = `'index', 'uri'=>$_SERVER['REQUEST_URI'], +'method'=>$_SERVER['REQUEST_METHOD'], 'query'=>$_GET, 'form'=>$_POST, +'body'=>base64_encode(file_get_contents('php://input')), +'env'=>getenv('CLI_TEST_VALUE'), 'precision'=>ini_get('precision')]);`; + await fs.writeFile(path.join(cwd, 'api/index.php'), phpSource); + await fs.writeFile(path.join(cwd, 'api/other.php'), ' { res.writeHead(500); res.end('Unexpected API call'); }); + let apiCalls = 0; + api.on('request', () => apiCalls++); + await new Promise(resolve => api.listen(0, '127.0.0.1', resolve)); + const port = await freePort(); + const env = { ...process.env, VERCEL_PHP_EXECUTABLE: php, __VERCEL_SKIP_DEV_CMD: '1', VERCEL_TELEMETRY_DISABLED: '1', NO_UPDATE_NOTIFIER: '1' }; + try { + child = spawn(process.execPath, [path.resolve(cli), 'dev', '--listen', `127.0.0.1:${port}`, '--debug', '--global-config', globalConfig, '--token', 'offlineTestNotARealToken', '--api', `http://127.0.0.1:${api.address().port}`], { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }); + child.stdout.on('data', data => { output += data; }); + child.stderr.on('data', data => { output += data; }); + child.on('error', error => { output += error.message; }); + const deadline = Date.now() + 60000; + while (!/Ready!/.test(output)) { + if (child.exitCode !== null || Date.now() > deadline) throw new Error(`CLI did not become ready:\n${output}`); + await new Promise(resolve => setTimeout(resolve, 100)); + } + const direct = await request(port, '/api/index.php?name=two%20words'); + assert.equal(direct.status, 200, direct.body.toString()); + assert.equal(direct.headers['x-endpoint'], 'index'); + assert.equal(direct.headers.server, 'Vercel'); + assert.deepEqual(JSON.parse(direct.body).query, { name: 'two words' }); + assert.equal(JSON.parse(direct.body).env, 'from-dotenv'); + assert.equal(JSON.parse(direct.body).precision, '12'); + const rewritten = JSON.parse((await request(port, '/rewrite/hello?original=yes')).body); + assert.equal(rewritten.endpoint, 'index'); + assert.equal(rewritten.query.captured, 'hello'); + assert.equal(rewritten.query.original, 'yes'); + assert.match(rewritten.uri, /^\/rewrite\/hello\?/); + const form = JSON.parse((await request(port, '/front/form', 'POST', 'name=two+words', { 'Content-Type': 'application/x-www-form-urlencoded' })).body); + assert.deepEqual(form.form, { name: 'two words' }); + assert.equal(form.method, 'POST'); + const bytes = Buffer.from([0, 255, 1, 128, 13, 10]); + const binary = JSON.parse((await request(port, '/front/binary', 'PUT', bytes, { 'Content-Type': 'application/octet-stream' })).body); + assert.equal(binary.body, bytes.toString('base64')); + const others = await Promise.all(Array.from({ length: 4 }, () => request(port, '/api/other.php'))); + others.forEach(other => assert.equal(other.body.toString(), 'other endpoint')); + assert.equal((await request(port, '/api/nested/deep.php')).body.toString(), 'nested endpoint'); + assert.equal((await request(port, '/asset.txt')).body.toString(), 'CLI static asset'); + assert.equal((await request(port, '/missing')).status, 404); + assert.equal(apiCalls, 0, 'CLI must not use a remote API'); + assert.match(output, /Proxying to "vercel-php" dev server/); + const phpPids = [...output.matchAll(/Proxying to "vercel-php" dev server \(port=(\d+), pid=(\d+)\)/g)].map(match => ({ port: Number(match[1]), pid: Number(match[2]) })); + assert.equal(phpPids.length, 9, 'CLI must dispatch each PHP request through the hook'); + await new Promise(resolve => setTimeout(resolve, 200)); + for (const server of phpPids) { + await assert.rejects(request(server.port, '/'), 'CLI must shut down each PHP server after its response'); + } + console.log(`${mode}: real Vercel CLI dispatch, rewrites, GET/query/form/binary, concurrent endpoints, static files, env/ini and request-scoped shutdown passed`); + } catch (error) { + console.error(`CLI output:\n${output}`); + throw error; + } finally { + await new Promise(resolve => api.close(resolve)); + } + } finally { + if (child && child.exitCode === null) { + const closed = new Promise(resolve => child.once('close', resolve)); + child.kill(); + await closed; + } + await fs.rm(cwd, { recursive: true, force: true }); + } +} +(async () => { await run('builds'); await run('functions'); })().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/test/spec/index.dev.js b/test/spec/index.dev.js index 09ead033..9b808f96 100644 --- a/test/spec/index.dev.js +++ b/test/spec/index.dev.js @@ -1,18 +1,158 @@ -const builder = require('./../../dist/index'); +const fs = require('fs/promises'); +const path = require('path'); +const net = require('net'); +const http = require('http'); +const { spawnSync } = require('child_process'); +const builder = require('../../dist/index'); -test('it should failed using now dev', async () => { - const mockLog = console.log = jest.fn(); +const executable = process.env.TEST_PHP_EXECUTABLE || 'php'; +const hasPhp = spawnSync(executable, ['-n', '-v']).status === 0; +const phpTest = hasPhp ? test : test.skip; +if (!hasPhp) console.warn('PHP integration tests skipped: set TEST_PHP_EXECUTABLE or install PHP on PATH.'); +let workPath; +let servers; - jest.spyOn(process, 'exit').mockImplementation((code) => { - expect(code).toBe(255); - expect(mockLog).toHaveBeenCalledTimes(1); +beforeEach(async () => { + const root = path.resolve(__dirname, '../../.local/tests'); + await fs.mkdir(root, { recursive: true }); + workPath = await fs.mkdtemp(path.join(root, 'dev-')); + servers = []; + await fs.mkdir(path.join(workPath, 'api')); + await fs.writeFile(path.join(workPath, 'api/php.ini'), 'precision=12\n'); + await fs.writeFile(path.join(workPath, 'api/index.php'), ` 'index', 'method' => $_SERVER['REQUEST_METHOD'], + 'uri' => $_SERVER['REQUEST_URI'], 'query' => $_GET, 'form' => $_POST, + 'body' => base64_encode(file_get_contents('php://input')), + 'env' => getenv('PHP_TEST_VALUE'), 'precision' => ini_get('precision'), + 'cwd' => getcwd(), 'workers' => getenv('PHP_CLI_SERVER_WORKERS') +]);`); + await fs.writeFile(path.join(workPath, 'api/other.php'), ' { + await Promise.all(servers.map(server => server.shutdown())); + await fs.rm(workPath, { recursive: true, force: true }); +}); + +function options(extra = {}) { + return { + files: {}, entrypoint: 'api/index.php', workPath, repoRootPath: workPath, + config: {}, meta: { isDev: true, env: { VERCEL_PHP_EXECUTABLE: executable, PHP_TEST_VALUE: 'local', PHP_CLI_SERVER_WORKERS: '4' } }, + onStdout: () => {}, onStderr: () => {}, ...extra, + }; +} +async function start(extra) { + const server = await builder.startDevServer(options(extra)); + servers.push(server); + return server; +} +function request(port, url, method = 'GET', body, headers = {}) { + return new Promise((resolve, reject) => { + const req = http.request({ hostname: '127.0.0.1', port, path: url, method, headers }, res => { + const chunks = []; + res.on('data', chunk => chunks.push(chunk)); + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) })); + }); + req.on('error', reject); + req.setTimeout(5000, () => req.destroy(new Error('Request timed out'))); + req.end(body); }); +} + +test('exports BuildV3 hook and prevents local Linux packaging without exiting', async () => { + expect(builder.version).toBe(3); + expect(typeof builder.startDevServer).toBe('function'); + await expect(builder.build(options())).rejects.toThrow('startDevServer'); +}); + +test('reports missing local PHP clearly and cleans up exit handlers', async () => { + const count = process.listenerCount('exit'); + await expect(builder.startDevServer(options({ meta: { env: { VERCEL_PHP_EXECUTABLE: path.join(workPath, 'missing-php') } } }))) + .rejects.toThrow('Install PHP CLI'); + expect(process.listenerCount('exit')).toBe(count); +}); + +phpTest('serves GET/query, form and binary bodies, headers, local ini and environment', async () => { + const original = { ...process.env }; + const stderr = []; + const stdout = []; + const server = await start({ onStderr: data => stderr.push(data), onStdout: data => stdout.push(data) }); + const get = await request(server.port, '/hello?name=two%20words'); + expect(get.status).toBe(200); + expect(get.headers['x-php-test']).toBe('yes'); + const data = JSON.parse(get.body); + expect(data).toMatchObject({ endpoint: 'index', method: 'GET', uri: '/hello?name=two%20words', query: { name: 'two words' }, env: 'local', precision: '12', workers: false }); + expect(path.resolve(data.cwd)).toBe(workPath); + const form = JSON.parse((await request(server.port, '/form', 'POST', 'name=two+words', { 'Content-Type': 'application/x-www-form-urlencoded' })).body); + expect(form.form).toEqual({ name: 'two words' }); + const body = Buffer.from([0, 255, 1, 128, 13, 10]); + const binary = JSON.parse((await request(server.port, '/binary', 'PUT', body, { 'Content-Type': 'application/octet-stream' })).body); + expect(binary.method).toBe('PUT'); + expect(binary.body).toBe(body.toString('base64')); + expect(stderr.length).toBeGreaterThan(0); + expect(Buffer.concat(stdout).toString()).toContain('request log'); + expect(process.env).toEqual(original); +}); - await builder.build({ - files: [], - entrypoint: 'test.php', - workPath: __dirname, - config: {}, - meta: { isDev: true }, +phpTest('uses independent available ports and shuts down only its own processes', async () => { + const occupied = net.createServer(); + await new Promise((resolve, reject) => { + occupied.once('error', reject); + occupied.listen(0, '127.0.0.1', resolve); }); + try { + const [first, second] = await Promise.all([start(), start({ entrypoint: 'api/other.php' })]); + expect(first.port).not.toBe(occupied.address().port); + expect(first.port).not.toBe(second.port); + expect((await request(second.port, '/api/other.php')).body.toString()).toBe('other'); + await first.shutdown(); + await first.shutdown(); + await expect(request(first.port, '/')).rejects.toThrow(); + expect((await request(second.port, '/')).body.toString()).toBe('other'); + expect(occupied.listening).toBe(true); + } finally { + await new Promise(resolve => occupied.close(resolve)); + } +}); + +phpTest('allows router-controlled static passthrough and explicit document root', async () => { + await fs.mkdir(path.join(workPath, 'public')); + await fs.writeFile(path.join(workPath, 'public/asset.txt'), 'public asset'); + const server = await start({ meta: { env: { VERCEL_PHP_EXECUTABLE: executable, VERCEL_PHP_DOCROOT: 'public' } } }); + expect((await request(server.port, '/asset.txt')).body.toString()).toBe('public asset'); +}); + +phpTest('uses publicDir when there is no document root override', async () => { + await fs.mkdir(path.join(workPath, 'public')); + await fs.writeFile(path.join(workPath, 'public/asset.txt'), 'public asset'); + const server = await start({ publicDir: path.join(workPath, 'public') }); + expect((await request(server.port, '/asset.txt')).body.toString()).toBe('public asset'); +}); + +phpTest('reports startup failure without leaking a PHP child', async () => { + const count = process.listenerCount('exit'); + await fs.writeFile(path.join(workPath, 'api/php.ini'), 'auto_prepend_file="missing.php"\n'); + // A non-PHP executable exits without PHP's readiness confirmation. + await expect(builder.startDevServer(options({ meta: { env: { VERCEL_PHP_EXECUTABLE: process.execPath } } }))) + .rejects.toThrow('exited before becoming ready'); + expect(process.listenerCount('exit')).toBe(count); +}); + +phpTest('normalizes Windows PATH casing overrides without changing global environment', async () => { + if (process.platform !== 'win32') return; + const original = { ...process.env }; + const binary = spawnSync(executable, ['-n', '-r', 'echo PHP_BINARY;'], { encoding: 'utf8' }); + expect(binary.status).toBe(0); + const resolvedExecutable = binary.stdout.trim(); + expect(path.isAbsolute(resolvedExecutable)).toBe(true); + const env = { VERCEL_PHP_EXECUTABLE: 'php', PATH: workPath, pAtH: path.dirname(resolvedExecutable) }; + const server = await start({ meta: { env } }); + expect((await request(server.port, '/')).status).toBe(200); + expect(process.env).toEqual(original); });