feat(compiler): add PHP builder and SAPI support with proxy configuration

- Implement configureProxyEnvironment function to export CLI proxy for child processes
- Add --sapi option supporting embed, cli, fpm targets with comma-separated lists
- Introduce --php-builder option for building PHP from source with extensions and ZTS
- Update documentation for new SAPI and PHP builder configuration options
- Add PeclExtensionSource class for managing PECL extension downloads
- Modify build configuration to support PHP builder and multiple SAPI targets
- Update compiler base with new build mode validation and SAPI handling
- Enhance native build configuration with PHP builder specific library paths
- Add comprehensive test coverage for new SAPI and PHP builder features
master
韩天峰 2 weeks ago
parent e16c787f44
commit ceeb1da3f9
  1. 17
      README-CN.md
  2. 20
      README.md
  3. 2
      completions/tpc.bash
  4. 26
      docs/en/COMPILER_CLI.md
  5. 88
      docs/en/LIBPHP_INSTALLER.md
  6. 23
      docs/zh-cn/COMPILER_CLI.md
  7. 82
      docs/zh-cn/LIBPHP_INSTALLER.md
  8. 5
      phpunit/code/compiler_api/extension_clean_maps.php
  9. 59
      phpunit/src/Build/PhpBuilderConfigurationTest.php
  10. 10
      phpunit/src/Build/SapiBuildConfigurationTest.php
  11. 129
      phpunit/src/CompilerBaseApiTest.php
  12. 14
      phpunit/src/Installer/PhpBuildConfigurationTest.php
  13. 3
      src/Backend/GccLikeBackend.php
  14. 16
      src/Build/NativeBuildConfigurationTrait.php
  15. 14
      src/Build/NativeCommandOptionsTrait.php
  16. 112
      src/Build/PeclExtensionSource.php
  17. 143
      src/Build/PhpBuilderConfiguration.php
  18. 94
      src/Build/PhpBuilderSource.php
  19. 7
      src/Build/SapiApplicationLinker.php
  20. 10
      src/Build/SapiBuildConfiguration.php
  21. 97
      src/Build/SapiPhpBuilder.php
  22. 80
      src/Build/SourcePipelineTrait.php
  23. 27
      src/CompilerBase.php
  24. 11
      src/Installer/PhpBuildConfiguration.php
  25. 11
      src/Metadata/Constants.php
  26. 205
      src/Translator.php
  27. 39
      src/compiler.php

@ -190,11 +190,11 @@ php bin/tpc.php --help
embed 安装前缀;在类 Unix 系统中,该目录应包含 `bin/php-config`、PHP 头文件和 embed 安装前缀;在类 Unix 系统中,该目录应包含 `bin/php-config`、PHP 头文件和
`lib/libphp.so`。 `lib/libphp.so`。
### 构建 `libphp.so` ### PHP 运行时选择
二进制和共享库构建需要 PHP 的 `embed` SAPI。如果 Linux 上缺少 `libphp.so`, 二进制模式默认使用 Embed SAPI 和宿主机 `libphp`。找不到该库时,交互式构建会询问
`tpc.php` 可以交互式下载 PHP 源码并自动构建。PHP 扩展构建从宿主 SAPI 解析 Zend 是否启用 `php-builder`,从 php-src 构建私有静态运行时。CLI 和 FPM 目标始终要求
符号,不能再加载第二份 `libphp`。详见[自动构建 libphp.so](docs/zh-cn/LIBPHP_INSTALLER.md)。 启用 `php-builder`。详见 [PHP builder](docs/zh-cn/LIBPHP_INSTALLER.md)。
## 快速开始 ## 快速开始
@ -687,13 +687,15 @@ bin/tpc.php --wasm=browser app.php
| `-d`, `--debug` | 调试构建,带符号和源码跟踪 | | `-d`, `--debug` | 调试构建,带符号和源码跟踪 |
| `-o`, `--output <file>` | 输出文件名 | | `-o`, `--output <file>` | 输出文件名 |
| `-m`, `--mode <bin\|lib\|ext>` | 构建模式(默认 `bin`) | | `-m`, `--mode <bin\|lib\|ext>` | 构建模式(默认 `bin`) |
| `--sapi <embed\|cli\|fpm>` | 二进制使用的 SAPI(默认 `embed`,支持逗号分隔多个值) |
| `--php-builder <配置>` | 从 php-src 构建私有 PHP 运行时 |
| `-r`, `--run` | 构建成功后运行 | | `-r`, `--run` | 构建成功后运行 |
| `-j`, `--job <num>` | 并行编译任务数(默认 `4`) | | `-j`, `--job <num>` | 并行编译任务数(默认 `4`) |
| `-f`, `--force` | 不使用缓存,重新编译可复用 PHPX 对象 | | `-f`, `--force` | 不使用缓存,重新编译可复用 PHPX 对象 |
| `--build-dir <dir>` | 生成 C++ 与中间产物的目录 | | `--build-dir <dir>` | 生成 C++ 与中间产物的目录 |
| `--dry` | 只生成 C++,跳过编译与链接 | | `--dry` | 只生成 C++,跳过编译与链接 |
| `--php-version <8.4\|8.5>` | 接受的 PHP 语法版本 | | `--php-version <8.4\|8.5>` | 接受的 PHP 语法版本 |
| `--proxy <url>` | 下载 PHP 元数据和源码归档时使用的代理 | | `--proxy <url>` | 所有网络传输使用的代理 |
| `--cxx-std <ver>` | C++ 标准(如 `c++17`、`c++20`) | | `--cxx-std <ver>` | C++ 标准(如 `c++17`、`c++20`) |
| `--march <arch>` | 目标指令集(如 `native`) | | `--march <arch>` | 目标指令集(如 `native`) |
| `--target-platform <triple>` | 交叉编译目标 triple | | `--target-platform <triple>` | 交叉编译目标 triple |
@ -714,8 +716,9 @@ source <(./tpc --generate-completion=bash)
## 常见问题 ## 常见问题
- **缺少 `libphp.so` / `libphp.dylib`:** 安装或编译与当前 PHP 匹配的 embed SAPI,设置 - **缺少 `libphp.so` / `libphp.dylib`:** 安装匹配的 Embed SAPI、设置 `PHP_HOME`、
`PHP_HOME`,或使用 `bin/tpc.php` 在 Linux 上提供的交互式安装流程。 接受交互式 `php-builder` 提示,或在非交互构建中传入
`--php-builder='extensions: []; zts: off'`。
- **找不到 PHPX:** 将 `PHPX_HOME` 指向包含 `include/` 和 - **找不到 PHPX:** 将 `PHPX_HOME` 指向包含 `include/` 和
`lib/libphpx.so`(或对应平台文件)的 PHPX 安装目录,并在编译项目前先构建 PHPX。 `lib/libphpx.so`(或对应平台文件)的 PHPX 安装目录,并在编译项目前先构建 PHPX。
- **启动崩溃或出现 ABI 错误:** PHP 头文件、`php-config`、`libphp` 和扩展 ABI - **启动崩溃或出现 ABI 错误:** PHP 头文件、`php-config`、`libphp` 和扩展 ABI

@ -214,13 +214,12 @@ php bin/tpc.php --help
may point to the PHP embed prefix; it must contain `bin/php-config`, PHP headers, may point to the PHP embed prefix; it must contain `bin/php-config`, PHP headers,
and `lib/libphp.so` on Unix-like systems. and `lib/libphp.so` on Unix-like systems.
### Building `libphp.so` ### PHP runtime selection
Binary and shared-library builds require PHP's `embed` SAPI. If `libphp.so` is Binary mode uses the Embed SAPI and the host `libphp` by default. If that library
missing on Linux, `tpc.php` can interactively download the PHP source and build is missing, an interactive build offers to enable `php-builder`, which builds a
it for you. A PHP extension build resolves Zend symbols from the host SAPI and private static runtime from php-src. CLI and FPM targets always require
must not load a second `libphp`. See `php-builder`. See [PHP builder](docs/en/LIBPHP_INSTALLER.md).
[Automatic libphp.so build](docs/en/LIBPHP_INSTALLER.md).
## Quick Start ## Quick Start
@ -755,13 +754,15 @@ Key options:
| `-d`, `--debug` | Debug build with symbols and source tracking | | `-d`, `--debug` | Debug build with symbols and source tracking |
| `-o`, `--output <file>` | Output file name | | `-o`, `--output <file>` | Output file name |
| `-m`, `--mode <bin\|lib\|ext>` | Build mode (default `bin`) | | `-m`, `--mode <bin\|lib\|ext>` | Build mode (default `bin`) |
| `--sapi <embed\|cli\|fpm>` | Binary SAPI target (default `embed`; comma-separated lists accepted) |
| `--php-builder <config>` | Build a private PHP runtime from php-src |
| `-r`, `--run` | Run after a successful build | | `-r`, `--run` | Run after a successful build |
| `-j`, `--job <num>` | Parallel compile jobs (default `4`) | | `-j`, `--job <num>` | Parallel compile jobs (default `4`) |
| `-f`, `--force` | Rebuild reusable PHPX objects instead of using the cache | | `-f`, `--force` | Rebuild reusable PHPX objects instead of using the cache |
| `--build-dir <dir>` | Directory for generated C++ and intermediates | | `--build-dir <dir>` | Directory for generated C++ and intermediates |
| `--dry` | Generate C++ only, skip compile and link | | `--dry` | Generate C++ only, skip compile and link |
| `--php-version <8.4\|8.5>` | PHP syntax version to accept | | `--php-version <8.4\|8.5>` | PHP syntax version to accept |
| `--proxy <url>` | Proxy used for PHP metadata and source archive downloads | | `--proxy <url>` | Proxy used for network transfers |
| `--cxx-std <ver>` | C++ standard (e.g. `c++17`, `c++20`) | | `--cxx-std <ver>` | C++ standard (e.g. `c++17`, `c++20`) |
| `--march <arch>` | Target instruction set (e.g. `native`) | | `--march <arch>` | Target instruction set (e.g. `native`) |
| `--target-platform <triple>` | Cross-compilation target triple | | `--target-platform <triple>` | Cross-compilation target triple |
@ -782,8 +783,9 @@ source <(./tpc --generate-completion=bash)
## Troubleshooting ## Troubleshooting
- **`libphp.so` / `libphp.dylib` is missing:** install/build the matching PHP embed SAPI, set - **`libphp.so` / `libphp.dylib` is missing:** install the matching Embed SAPI,
`PHP_HOME`, or let `bin/tpc.php` offer the interactive Linux installer. set `PHP_HOME`, accept the interactive `php-builder` prompt, or pass
`--php-builder='extensions: []; zts: off'` in non-interactive builds.
- **PHPX cannot be found:** set `PHPX_HOME` to a PHPX installation containing - **PHPX cannot be found:** set `PHPX_HOME` to a PHPX installation containing
`include/` and `lib/libphpx.so` (or the platform equivalent), then build PHPX `include/` and `lib/libphpx.so` (or the platform equivalent), then build PHPX
before compiling the project. before compiling the project.

@ -144,7 +144,7 @@ _typephp_tpc()
return return
;; ;;
-* ) -* )
COMPREPLY=( $(compgen -W '--nano -O --optimize -o --output -h --help -v --version --profile --no-literal-strings --php-version --proxy -f --force -m --mode --sapi -r --run --debug -j --job --no-console --sanitize --cxx-std --march --compiler --target-platform --no-color --build-dir --dry -I --include-path -D --define --no-progress --lto --format -l --link-lib -L --link-path --full-static --wasm --wasm= --gen-python-helper --convert-python-to-php --output-dir --output-dir= --build-dir= --generate-completion=' -- "$current") ) COMPREPLY=( $(compgen -W '--nano -O --optimize -o --output -h --help -v --version --profile --no-literal-strings --php-version --proxy -f --force -m --mode --php-builder --sapi -r --run --debug -j --job --no-console --sanitize --cxx-std --march --compiler --target-platform --no-color --build-dir --dry -I --include-path -D --define --no-progress --lto --format -l --link-lib -L --link-path --full-static --wasm --wasm= --gen-python-helper --convert-python-to-php --output-dir --output-dir= --build-dir= --generate-completion=' -- "$current") )
return return
;; ;;
esac esac

@ -67,6 +67,8 @@ bin/tpc.php app.php --dry --build-dir /tmp/typephp-build
| `-d`, `--debug` | Debug build; disables optimization and adds debug symbols and TypePHP source tracking. | | `-d`, `--debug` | Debug build; disables optimization and adds debug symbols and TypePHP source tracking. |
| `-o`, `--output <file>` | Output file name. | | `-o`, `--output <file>` | Output file name. |
| `-m`, `--mode <bin|lib|ext>` | Build mode, default `bin`. | | `-m`, `--mode <bin|lib|ext>` | Build mode, default `bin`. |
| `--sapi <embed|cli|fpm>` | SAPI used by a `bin` target; accepts a comma-separated list. Default: `embed`. |
| `--php-builder <config>` | Build PHP from php-src, for example `--php-builder='extensions: [swoole, mongodb]; zts: on'`. |
| `-r`, `--run` | Run after a successful build. | | `-r`, `--run` | Run after a successful build. |
| `-j`, `--job <num>` | Number of parallel compilation jobs, default `4`. | | `-j`, `--job <num>` | Number of parallel compilation jobs, default `4`. |
| `-f`, `--force` | Ignore the phpx misc object cache and force recompilation. | | `-f`, `--force` | Ignore the phpx misc object cache and force recompilation. |
@ -75,10 +77,32 @@ bin/tpc.php app.php --dry --build-dir /tmp/typephp-build
| `--format` | Run clang-format on the generated code. | | `--format` | Run clang-format on the generated code. |
| `--no-progress` | Do not show the progress bar; output progress per file. | | `--no-progress` | Do not show the progress bar; output progress per file. |
| `--no-color` | Disable colored output. | | `--no-color` | Disable colored output. |
| `--proxy <url>` | Use an HTTP(S) or SOCKS proxy for PHP metadata and source archive downloads. | | `--proxy <url>` | Use an HTTP(S) or SOCKS proxy for network transfers. |
`-v` / `--version` only displays the version; it is not a verbose option. `-v` / `--version` only displays the version; it is not a verbose option.
`mode` describes the artifact type, while `sapi` describes the PHP process
interface. `cli` and `fpm` are not build modes. They require `php-builder`;
`embed` can use either the host `libphp` (the default) or a private static PHP
runtime. When the default Embed build cannot find the host library, an
interactive invocation offers to enable `php-builder`. In CI, pass the option
explicitly.
The equivalent YAML is:
```yaml
mode: bin
sapi: [cli, fpm, embed]
php-builder:
extensions: [swoole, mongodb]
zts: on
```
`php-builder` does not depend on the host PHP runtime or modify the downloaded
php-src tree. It collects extension requirements from project sources, YAML,
and Composer metadata, then configures a private static runtime using libraries
provided by the operating system.
## Target and Toolchain ## Target and Toolchain
| Option | Description | | Option | Description |

@ -1,67 +1,61 @@
# Automatically building libphp.so # PHP builder
TypePHP's executable and shared-library modes require the `libphp.so` provided by the PHP Embed SAPI. Many Linux distributions' PHP packages only include CLI or FPM, so the Composer-installed `tpc.php` asks whether to automatically build a private PHP when `libphp.so` is not found. For `mode: bin`, TypePHP separates the artifact type from the PHP SAPI:
The installer targets `tpc.php` launched by the PHP interpreter. The bootstrap binary `tpc` must have `libphp.so` and `libphpx.so` loaded by the system dynamic linker before entering `main()`, so it cannot install missing libraries by itself. ```yaml
mode: bin
sapi: [embed, cli, fpm]
php-builder:
extensions: [swoole, mongodb]
zts: on
```
This feature is only enabled on Linux and in interactive terminals. Extension mode (`-m ext`) does not need `libphp.so` and does not trigger the installer; non-interactive environments such as CI also do not automatically download or install packages, or execute `sudo`. `sapi` selects one or more process interfaces. `embed` is the default. `cli`
and `fpm` are not modes and always require `php-builder` because a host PHP
installation does not provide the static build inputs needed by these targets.
## Usage flow `php-builder` means that TypePHP builds a private PHP runtime from an official
php-src archive. The result does not depend on the host PHP runtime, but it does
use development libraries supplied by the operating system. The original
php-src cache is left unchanged; external PECL extensions are injected into a
derived source tree. Extension requirements are merged from the explicit
`extensions` list, project sources, YAML dependencies, and embedded Composer
metadata, and then translated to PHP configure options.
Simply run the normal compile command: The command-line equivalent is:
```bash ```bash
vendor/bin/tpc.php project.yml bin/tpc.php project.yml \
--sapi=embed,cli,fpm \
--php-builder='extensions: [swoole, mongodb]; zts: on'
``` ```
When `libphp.so` is missing, the installer asks in sequence: `zts` accepts `on` or `off`. `php-builder.sapi` is invalid; SAPI selection is
always the independent top-level `sapi` option.
1. whether to automatically build the PHP Embed library;
2. the PHP version, defaulting to exactly the `PHP_VERSION` of the currently running `tpc.php`, with the option to manually enter another PHP 8.4.x/8.5.x stable version;
3. the install directory, defaulting to `~/.typephp`;
4. whether to install missing development packages via the detected `apt-get`, `dnf`, or `yum`.
The installer reads the current `php-config --configure-options`, keeps the current PHP's supported extension configuration, replaces the install path, and adds `--enable-embed=shared`. It always uses `--without-pear` because PEAR is deprecated. PHP source is downloaded only from PHP.net, and verified using the SHA-256 from the official release information.
After compilation, the main files are as follows:
```text
~/.typephp/bin/php
~/.typephp/bin/php-config
~/.typephp/lib/libphp.so
~/.typephp/lib/php.ini
~/.typephp/lib/loaded-extensions.txt
```
The current main ini file and the configuration in the scan directory are merged. When the same PHP major/minor version is used, the shared extensions loaded in the current ini are copied to the new extension directory; across major/minor versions, binary extensions are not copied, and unusable extension configuration is commented out to avoid a generated PHP that cannot start. ## Default Embed behavior
After a successful installation, the current `tpc.php` process automatically uses the new directory as `PHP_HOME` and continues the original compile task. It can also be specified explicitly later: Without `php-builder`, `mode: bin` plus `sapi: embed` preserves the traditional
behavior and links the host Embed library (`libphp.so` or `libphp.dylib`). If it
is unavailable on Linux or macOS, an interactive invocation asks whether to
enable `php-builder`. In a non-interactive environment, choose explicitly:
```bash ```bash
export PHP_HOME="$HOME/.typephp" bin/tpc.php project.yml \
vendor/bin/tpc.php project.yml --php-builder='extensions: []; zts: off'
``` ```
When the same directory and version are chosen again, the installer asks whether to directly reuse the existing `libphp.so` and does not repeat the full build. Declining the prompt leaves no usable Embed runtime and stops the build.
## Non-interactive environments ## Cache and proxy
The installer does not automatically confirm privileged operations in CI. Prepare `libphp.so` in advance, then set: Downloaded sources and private runtimes are cached under `~/.typephp`. Compatible
runtimes are reused across application builds. `--proxy` is a global network
setting rather than a `php-builder` field; PHP and PECL metadata and archives,
and any other TypePHP network transfers, use the configured HTTP(S) or SOCKS
proxy.
```bash ```bash
PHP_HOME=/path/to/php vendor/bin/tpc.php project.yml bin/tpc.php project.yml --proxy=socks5h://127.0.0.1:1080 \
--php-builder='extensions: []; zts: off'
``` ```
## Automatically building libphpx.so
After the PHP Embed check completes on Linux, the build pipeline also checks `lib/libphpx.so` in the PHPX root directory. The PHPX root directory is resolved in the following order:
1. `PHPX_HOME`;
2. the `swoole/phpx` install path in Composer `InstalledVersions`;
3. `vendor/swoole/phpx` within the TypePHP source repository.
When the shared library is missing, an interactive terminal asks whether to build. PHPX itself does not depend on `libphp.so`; it depends on PHP headers and `php-config`. `LibPhpxInstaller` uses the currently selected PHP prefix, sets `PHP_HOME`, puts that prefix's `bin` at the front of `PATH`, and passes `-Dphp_dir=<PHP prefix>` to the PHPX CMake, ensuring PHPX matches the PHP ABI used by the project runtime.
Toolchain detection runs after the local library checks. After the user confirms the automatic build, the installer checks and can install GCC/G++, make, CMake, pkg-config, and the dependencies required by PHP configure via `apt-get`, `dnf`, or `yum`; therefore a Composer environment does not need a full pre-installed C/C++ toolchain.
The build always uses Release, disables PHPX tests, and builds only the `phpx` target; parallelism is capped at 8. The output must be `<PHPX>/lib/libphpx.so`, otherwise the installer reports an error. Non-interactive environments only report the missing library and do not run CMake.

@ -67,6 +67,8 @@ bin/tpc.php app.php --dry --build-dir /tmp/typephp-build
| `-d`, `--debug` | 调试构建;关闭优化、增加调试符号和 TypePHP 源码跟踪。 | | `-d`, `--debug` | 调试构建;关闭优化、增加调试符号和 TypePHP 源码跟踪。 |
| `-o`, `--output <file>` | 输出文件名。 | | `-o`, `--output <file>` | 输出文件名。 |
| `-m`, `--mode <bin|lib|ext>` | 构建模式,默认 `bin`。 | | `-m`, `--mode <bin|lib|ext>` | 构建模式,默认 `bin`。 |
| `--sapi <embed|cli|fpm>` | `bin` 目标使用的 SAPI,支持逗号分隔的多个值;默认 `embed`。 |
| `--php-builder <配置>` | 从 php-src 构建 PHP,例如 `--php-builder='extensions: [swoole, mongodb]; zts: on'`。 |
| `-r`, `--run` | 构建成功后运行。 | | `-r`, `--run` | 构建成功后运行。 |
| `-j`, `--job <num>` | 并行编译任务数,默认 `4`。 | | `-j`, `--job <num>` | 并行编译任务数,默认 `4`。 |
| `-f`, `--force` | 忽略 phpx misc 对象缓存,强制重新编译。 | | `-f`, `--force` | 忽略 phpx misc 对象缓存,强制重新编译。 |
@ -75,10 +77,29 @@ bin/tpc.php app.php --dry --build-dir /tmp/typephp-build
| `--format` | 对生成代码运行 clang-format。 | | `--format` | 对生成代码运行 clang-format。 |
| `--no-progress` | 不显示进度条,逐文件输出进度。 | | `--no-progress` | 不显示进度条,逐文件输出进度。 |
| `--no-color` | 禁用彩色输出。 | | `--no-color` | 禁用彩色输出。 |
| `--proxy <url>` | 下载 PHP 元数据和源码归档时使用 HTTP(S) 或 SOCKS 代理。 | | `--proxy <url>` | 所有网络传输使用 HTTP(S) 或 SOCKS 代理。 |
`-v` / `--version` 只显示版本,不是 verbose 选项。 `-v` / `--version` 只显示版本,不是 verbose 选项。
`mode` 表达产物类型,`sapi` 表达 PHP 进程接口;`cli` 和 `fpm` 不是构建模式。
两者都强依赖 `php-builder`。`embed` 既可使用默认的宿主机 `libphp`,也可使用
私有的静态 PHP 运行时。当默认 Embed 构建找不到宿主库时,交互式运行会询问是否启用
`php-builder`;CI 中应显式传入该选项。
等价的 YAML 配置为:
```yaml
mode: bin
sapi: [cli, fpm, embed]
php-builder:
extensions: [swoole, mongodb]
zts: on
```
`php-builder` 不依赖宿主机 PHP 运行时,也不会修改下载的 php-src 原始目录。它会从
项目源码、YAML 和 Composer 元数据收集扩展依赖,自动生成 configure 参数,并使用
操作系统提供的底层库构建私有静态运行时。
## 目标和工具链 ## 目标和工具链
| 选项 | 说明 | | 选项 | 说明 |

@ -1,67 +1,55 @@
# 自动构建 libphp.so # PHP builder
TypePHP 的可执行文件和共享库模式需要 PHP Embed SAPI 提供的 `libphp.so`。许多 Linux 发行版的 PHP 包只包含 CLI 或 FPM,因此 Composer 安装的 `tpc.php` 在找不到 `libphp.so` 时会询问是否自动构建一份私有 PHP。 在 `mode: bin` 下,TypePHP 将产物类型与 PHP SAPI 分开配置:
该安装器面向由 PHP 解释器启动的 `tpc.php`。自举二进制 `tpc` 在进入 `main()` 前就必须由系统动态链接器加载 `libphp.so` 和 `libphpx.so`,不能依靠自身补装缺失库。 ```yaml
mode: bin
sapi: [embed, cli, fpm]
php-builder:
extensions: [swoole, mongodb]
zts: on
```
该功能只在 Linux 和交互式终端中启用。扩展模式(`-m ext`)不需要 `libphp.so`,不会触发安装器;CI 等非交互环境也不会自动下载、安装软件包或执行 `sudo`。 `sapi` 用于选择一个或多个进程接口,默认值是 `embed`。`cli` 和 `fpm` 不是 mode;
由于宿主 PHP 安装不提供这些目标所需的静态构建输入,它们始终强依赖
`php-builder`。
## 使用流程 `php-builder` 表示 TypePHP 从官方 php-src 归档构建私有 PHP 运行时。产物不依赖
宿主机 PHP 运行时,但会使用操作系统提供的开发库。官方 php-src 缓存不会被修改;
外部 PECL 扩展会注入派生的源码目录。显式 `extensions`、项目源码、YAML 依赖和
内嵌 Composer 元数据中的扩展需求会合并,并自动转换为 PHP configure 参数。
正常执行编译命令即可: 等价的命令行为:
```bash ```bash
vendor/bin/tpc.php project.yml bin/tpc.php project.yml \
--sapi=embed,cli,fpm \
--php-builder='extensions: [swoole, mongodb]; zts: on'
``` ```
缺少 `libphp.so` 时,安装器会依次询问: `zts` 接受 `on` 或 `off`。`php-builder.sapi` 是非法配置;SAPI 始终通过独立的
顶层 `sapi` 选项指定。
1. 是否自动构建 PHP Embed 库;
2. PHP 版本,默认与当前运行 `tpc.php` 的 `PHP_VERSION` 完全一致,也可手动输入其他 PHP 8.4.x/8.5.x 稳定版本;
3. 安装目录,默认为 `~/.typephp`;
4. 是否通过检测到的 `apt-get`、`dnf` 或 `yum` 安装缺失的开发包。
安装器读取当前 `php-config --configure-options`,保留当前 PHP 支持的扩展配置,替换安装路径并加入 `--enable-embed=shared`。由于 PEAR 已废弃,构建始终使用 `--without-pear`。PHP 源码只从 PHP.net 下载,并使用官方发布信息中的 SHA-256 校验。
编译完成后主要文件如下:
```text
~/.typephp/bin/php
~/.typephp/bin/php-config
~/.typephp/lib/libphp.so
~/.typephp/lib/php.ini
~/.typephp/lib/loaded-extensions.txt
```
当前 ini 主文件和扫描目录中的配置会被合并。使用相同 PHP 主次版本时,当前 ini 中加载的共享扩展会复制到新扩展目录;跨主次版本时不会复制二进制扩展,不可用的扩展配置会被注释,避免生成的 PHP 无法启动。 ## 默认 Embed 行为
安装成功后,当前 `tpc.php` 进程会自动将新目录作为 `PHP_HOME` 并继续原来的编译任务。后续也可以显式指定: 未配置 `php-builder` 时,`mode: bin` 加 `sapi: embed` 保持原有行为,链接宿主机
Embed 库(`libphp.so` 或 `libphp.dylib`)。Linux 或 macOS 上缺少该库时,交互式
运行会询问是否启用 `php-builder`。非交互环境必须显式选择:
```bash ```bash
export PHP_HOME="$HOME/.typephp" bin/tpc.php project.yml \
vendor/bin/tpc.php project.yml --php-builder='extensions: []; zts: off'
``` ```
再次选择同一目录和版本时,安装器会询问是否直接复用已有的 `libphp.so`,不会重复执行完整构建。 若拒绝提示,则没有可用的 Embed 运行时,构建会停止。
## 非交互环境 ## 缓存与代理
安装器不会在 CI 中自动确认权限操作。请提前准备 `libphp.so`,然后设置: 下载的源码和私有运行时缓存在 `~/.typephp`,兼容的运行时会在不同应用构建之间复用。
`--proxy` 是全局网络设置,不属于 `php-builder`;PHP、PECL 元数据与归档,以及
TypePHP 的其他网络传输,都会使用指定的 HTTP(S) 或 SOCKS 代理。
```bash ```bash
PHP_HOME=/path/to/php vendor/bin/tpc.php project.yml bin/tpc.php project.yml --proxy=socks5h://127.0.0.1:1080 \
--php-builder='extensions: []; zts: off'
``` ```
## 自动构建 libphpx.so
Linux 下完成 PHP Embed 检查后,构建流水线还会检查 PHPX 根目录中的 `lib/libphpx.so`。PHPX 根目录按以下顺序解析:
1. `PHPX_HOME`;
2. Composer `InstalledVersions` 中的 `swoole/phpx` 安装路径;
3. TypePHP 源码仓库内的 `vendor/swoole/phpx`。
缺少共享库时,交互式终端会询问是否构建。PHPX 本身不依赖 `libphp.so`;它依赖 PHP 头文件和 `php-config`。`LibPhpxInstaller` 使用当前选定的 PHP 前缀,同时设置 `PHP_HOME`、将该前缀的 `bin` 放到 `PATH` 首位,并向 PHPX CMake 传递 `-Dphp_dir=<PHP prefix>`,确保 PHPX 与项目运行时使用的 PHP ABI 一致。
工具链检测在本地库检查之后执行。用户确认自动构建后,安装器通过 `apt-get`、`dnf` 或 `yum` 检查并可安装 GCC/G++、make、CMake、pkg-config 以及 PHP configure 所需依赖;因此 Composer 环境无需预先安装完整 C/C++ 工具链。
构建固定使用 Release、关闭 PHPX tests,并只构建 `phpx` target;并行度最多为 8。输出必须为 `<PHPX>/lib/libphpx.so`,否则安装器报错。非交互环境只提示缺失,不执行 CMake。

@ -4,3 +4,8 @@ function main(): void
{ {
echo "ok\n"; echo "ok\n";
} }
final class RequestStaticCache
{
private static array $values = [];
}

@ -0,0 +1,59 @@
<?php
/**
* This file is part of TypePHP(AOT).
*
* @link https://www.swoole.com/aot/
* @contact service@swoole.com
*/
namespace TypePhp\Tests\Build;
use PHPUnit\Framework\TestCase;
use TypePhp\Build\PhpBuilderConfiguration;
/**
* @internal
* @coversNothing
*/
final class PhpBuilderConfigurationTest extends TestCase
{
public function testParsesYamlMapping(): void
{
$configuration = PhpBuilderConfiguration::fromYaml([
'zts' => 'on',
'extensions' => ['swoole', 'ext-mongodb', 'swoole'],
]);
self::assertTrue($configuration->zts);
self::assertSame(['mongodb', 'swoole'], $configuration->extensions);
}
public function testParsesCommandLineSyntax(): void
{
$configuration = PhpBuilderConfiguration::fromCommandLine(
'extensions: [curl, mbstring]; zts: off;',
);
self::assertFalse($configuration->zts);
self::assertSame(['curl', 'mbstring'], $configuration->extensions);
}
public function testRejectsLegacyListOfMappingsYamlShape(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('`php-builder` must be a mapping');
PhpBuilderConfiguration::fromYaml([
['zts' => true],
['extensions' => []],
]);
}
public function testRejectsUnknownOption(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Unknown `php-builder` option');
PhpBuilderConfiguration::fromYaml(['sapi' => ['embed']]);
}
}

@ -1,24 +1,24 @@
<?php <?php
namespace TypePhpTest\Build; namespace TypePhp\Tests\Build;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use TypePhp\Build\SapiBuildConfiguration; use TypePhp\Build\SapiBuildConfiguration;
final class SapiBuildConfigurationTest extends TestCase final class SapiBuildConfigurationTest extends TestCase
{ {
public function testTargetsAcceptSingleBothAndLists(): void public function testTargetsAcceptEmbedCliFpmAndLists(): void
{ {
self::assertSame(['embed'], SapiBuildConfiguration::parseTargets('embed'));
self::assertSame(['cli'], SapiBuildConfiguration::parseTargets('cli')); self::assertSame(['cli'], SapiBuildConfiguration::parseTargets('cli'));
self::assertSame(['cli', 'fpm'], SapiBuildConfiguration::parseTargets('both'));
self::assertSame(['fpm', 'cli'], SapiBuildConfiguration::parseTargets(['fpm', 'cli', 'fpm'])); self::assertSame(['fpm', 'cli'], SapiBuildConfiguration::parseTargets(['fpm', 'cli', 'fpm']));
self::assertSame(['cli', 'fpm'], SapiBuildConfiguration::parseTargets('cli, fpm')); self::assertSame(['embed', 'cli', 'fpm'], SapiBuildConfiguration::parseTargets('embed, cli, fpm'));
} }
public function testInvalidTargetIsRejected(): void public function testInvalidTargetIsRejected(): void
{ {
$this->expectException(\InvalidArgumentException::class); $this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Expected cli, fpm, or both'); $this->expectExceptionMessage('Expected embed, cli, or fpm');
SapiBuildConfiguration::parseTargets('apache'); SapiBuildConfiguration::parseTargets('apache');
} }
} }

@ -9,6 +9,7 @@
namespace TypePhp\Tests; namespace TypePhp\Tests;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use TypePhp\Build\PhpBuilderConfiguration;
use TypePhp\CompilerBase; use TypePhp\CompilerBase;
use TypePhp\CompilerTest; use TypePhp\CompilerTest;
use TypePhp\Exception\TestError; use TypePhp\Exception\TestError;
@ -250,10 +251,17 @@ PHP);
$this->compiler->setBuildMode('extension'); $this->compiler->setBuildMode('extension');
$this->assertSame(CompilerBase::BUILD_MODE_EXT, $this->compiler->getBuildMode()); $this->assertSame(CompilerBase::BUILD_MODE_EXT, $this->compiler->getBuildMode());
$this->compiler->setBuildMode('cli'); $this->compiler->setBuildMode('binary');
$this->assertSame(CompilerBase::BUILD_MODE_BIN, $this->compiler->getBuildMode()); $this->assertSame(CompilerBase::BUILD_MODE_BIN, $this->compiler->getBuildMode());
} }
public function testCliIsNotAcceptedAsABuildMode(): void
{
$this->expectException(TestError::class);
$this->expectExceptionMessage('Invalid build mode `cli`. Expected bin, lib, or ext.');
$this->compiler->setBuildMode('cli');
}
public function testPhpLanguageVersionControlsParser(): void public function testPhpLanguageVersionControlsParser(): void
{ {
$this->assertSame('8.5.0', $this->compiler->getPhpVersion()); $this->assertSame('8.5.0', $this->compiler->getPhpVersion());
@ -908,13 +916,14 @@ YAML, 'myproject.yml', 'examples/tetris-sdl');
); );
} }
public function testBothSapiTargetsHaveExplicitTargetSuffixes(): void public function testMultipleSapiTargetsHaveExplicitTargetSuffixes(): void
{ {
$this->compiler->setOutputPath($this->testDir . '/build/app'); $this->compiler->setOutputPath($this->testDir . '/build/app');
$this->invokeMethod('configureSapiTargets', 'both'); $this->invokeMethod('configureSapiTargets', ['embed', 'cli', 'fpm']);
$this->assertSame( $this->assertSame(
[ [
'embed' => $this->testDir . '/build/app-embed',
'cli' => $this->testDir . '/build/app-cli', 'cli' => $this->testDir . '/build/app-cli',
'fpm' => $this->testDir . '/build/app-fpm', 'fpm' => $this->testDir . '/build/app-fpm',
], ],
@ -922,17 +931,72 @@ YAML, 'myproject.yml', 'examples/tetris-sdl');
); );
} }
public function testSapiBuildModeDefaultsToCliTarget(): void public function testCombinedSapiModuleKeepsEmbedOnlyFunctionsOutOfCliAndFpm(): void
{
global $translator;
$compiler = CompilerTest::create(TYPEPHP_ROOT_PATH);
$translator = $compiler;
$compiler->setTargetName('combined_sapi');
$configureSapi = new \ReflectionMethod($compiler, 'configureSapiTargets');
$configureSapi->invoke($compiler, ['embed', 'cli', 'fpm']);
$testFile = TYPEPHP_ROOT_PATH . '/phpunit/code/compiler_api/extension_clean_maps.php';
$compiler->addFiles([$testFile]);
$compiler->prepareFile($testFile);
$compiler->convertFile($testFile);
$extension = (string) file_get_contents($compiler->genExtension());
self::assertStringContainsString('static const zend_function_entry ext_functions[]', $extension);
self::assertStringContainsString('static const zend_function_entry sapi_ext_functions[]', $extension);
self::assertSame(1, substr_count($extension, 'PHP_FE(cli_set_process_title'));
self::assertStringContainsString('_sapi_module_entry;', $extension);
self::assertStringContainsString('if (strcmp(sapi_module.name, "embed") == 0)', $extension);
}
public function testPhpBuilderUsesDefaultEmbedSapi(): void
{
$this->invokeMethod('configurePhpBuilder', PhpBuilderConfiguration::fromYaml([]));
$this->assertSame(['embed'], $this->compiler->getSapiTargets());
$this->assertTrue($this->compiler->isPhpBuilderBuild());
$this->assertFalse($this->compiler->isSapiBuild());
$this->assertTrue($this->compiler->isBuildModeBin());
}
public function testCliSapiRequiresPhpBuilder(): void
{
$this->invokeMethod('configureSapiTargets', 'fpm');
$this->expectException(TestError::class);
$this->expectExceptionMessage('The cli and fpm SAPIs require `php-builder`');
$this->invokeMethod('validateLoadedProjectConfiguration');
}
public function testSapiOptionIsOnlyValidForBinaryMode(): void
{
$this->compiler->setBuildMode('lib');
$this->invokeMethod('configureSapiTargets', 'embed');
$this->expectException(TestError::class);
$this->expectExceptionMessage('`sapi` is only supported with `mode: bin`');
$this->invokeMethod('validateLoadedProjectConfiguration');
}
public function testPhpBuilderIsOnlyValidForBinaryMode(): void
{ {
$this->compiler->setBuildMode(CompilerBase::BUILD_MODE_SAPI); $this->compiler->setBuildMode('ext');
$this->invokeMethod('configurePhpBuilder', PhpBuilderConfiguration::fromYaml([]));
$this->assertSame(['cli'], $this->compiler->getSapiTargets()); $this->expectException(TestError::class);
$this->assertTrue($this->compiler->isSapiBuild()); $this->expectExceptionMessage('`php-builder` is only supported with `mode: bin`');
$this->invokeMethod('validateLoadedProjectConfiguration');
} }
public function testSapiCliEntryIsEmbeddedAndExcludedFromAotSources(): void public function testPhpBuilderCliEntryIsEmbeddedAndExcludedFromAotSources(): void
{ {
$projectFile = $this->createProjectFile(<<<'YAML' $projectFile = $this->createProjectFile(<<<'YAML'
php-builder:
zts: on
extensions: []
sapi: cli sapi: cli
entry: main.php entry: main.php
sources: sources:
@ -949,9 +1013,10 @@ YAML);
$this->invokeMethod('validateLoadedProjectConfiguration'); $this->invokeMethod('validateLoadedProjectConfiguration');
} }
public function testSapiCliRequiresEntry(): void public function testPhpBuilderCliRequiresEntry(): void
{ {
$projectFile = $this->createProjectFile(<<<'YAML' $projectFile = $this->createProjectFile(<<<'YAML'
php-builder: {}
sapi: cli sapi: cli
sources: sources:
- main.php - main.php
@ -959,13 +1024,14 @@ YAML);
$this->invokeMethod('parseProjectYaml', $projectFile); $this->invokeMethod('parseProjectYaml', $projectFile);
$this->expectException(TestError::class); $this->expectException(TestError::class);
$this->expectExceptionMessage('CLI SAPI builds require an `entry` PHP file'); $this->expectExceptionMessage('`sapi` containing cli requires an `entry` PHP file');
$this->invokeMethod('validateLoadedProjectConfiguration'); $this->invokeMethod('validateLoadedProjectConfiguration');
} }
public function testSapiFpmDoesNotRequireCliEntry(): void public function testPhpBuilderFpmDoesNotRequireCliEntry(): void
{ {
$projectFile = $this->createProjectFile(<<<'YAML' $projectFile = $this->createProjectFile(<<<'YAML'
php-builder: {}
sapi: fpm sapi: fpm
sources: sources:
- main.php - main.php
@ -976,6 +1042,25 @@ YAML);
$this->assertSame(['fpm'], $this->compiler->getSapiTargets()); $this->assertSame(['fpm'], $this->compiler->getSapiTargets());
} }
public function testPhpBuilderFpmDoesNotRequireCompiledMainFunction(): void
{
global $translator;
$compiler = CompilerTest::create($this->testDir);
$translator = $compiler;
$compiler->setTargetName('fpm_without_main');
$configureSapi = new \ReflectionMethod($compiler, 'configureSapiTargets');
$configureSapi->invoke($compiler, 'fpm');
$source = $this->testDir . '/fpm-source.php';
file_put_contents($source, "<?php\nfunction request_handler(): string { return 'ok'; }\n");
$compiler->addFiles([$source]);
$compiler->prepareFile($source);
$compiler->convertFile($source);
$extension = (string) file_get_contents($compiler->genExtension());
self::assertStringContainsString('sapi_ext_functions', $extension);
self::assertStringNotContainsString('php::eval(', $extension);
}
public function testProjectWithoutAotSourcesUsesRuntimeDeclarationHeader(): void public function testProjectWithoutAotSourcesUsesRuntimeDeclarationHeader(): void
{ {
$this->compiler->setTargetName('opcode_only'); $this->compiler->setTargetName('opcode_only');
@ -1590,7 +1675,26 @@ YAML);
$this->assertIsInt($moduleInitStart, $mode); $this->assertIsInt($moduleInitStart, $mode);
$this->assertIsInt($moduleCleanStart, $mode); $this->assertIsInt($moduleCleanStart, $mode);
$moduleInit = substr($extension, $moduleInitStart, $moduleCleanStart - $moduleInitStart); $moduleInit = substr($extension, $moduleInitStart, $moduleCleanStart - $moduleInitStart);
$moduleClean = substr($extension, $moduleCleanStart);
$this->assertStringNotContainsString('slot.reset()', $moduleInit, $mode); $this->assertStringNotContainsString('slot.reset()', $moduleInit, $mode);
if ($mode === CompilerBase::BUILD_MODE_BIN) {
$this->assertStringContainsString(
'if (strcmp(sapi_module.name, "embed") == 0)',
$moduleClean,
$mode,
);
$this->assertStringContainsString(
'php::setStaticProperty("RequestStaticCache", "values", php::Array{});',
$moduleClean,
$mode,
);
} else {
$this->assertStringNotContainsString(
'if (strcmp(sapi_module.name, "embed") == 0)',
$moduleClean,
$mode,
);
}
$this->assertMatchesRegularExpression( $this->assertMatchesRegularExpression(
'/PHP_RSHUTDOWN_FUNCTION\([^)]*\)\s*\{\s*' '/PHP_RSHUTDOWN_FUNCTION\([^)]*\)\s*\{\s*'
. 'php::request_shutdown\(\);\s*' . 'php::request_shutdown\(\);\s*'
@ -1751,6 +1855,9 @@ YAML);
$extension = file_get_contents($extensionFile); $extension = file_get_contents($extensionFile);
$this->assertStringContainsString('php::Str php_exported_defaults_arg_0_default_value() {', $extension); $this->assertStringContainsString('php::Str php_exported_defaults_arg_0_default_value() {', $extension);
$this->assertStringContainsString('static php::Str _literal_strings[]', $extension); $this->assertStringContainsString('static php::Str _literal_strings[]', $extension);
$this->assertStringNotContainsString('_literal_string_values', $extension);
$this->assertStringNotContainsString('ZVAL_STRINGL(_literal_strings[', $extension);
$this->assertStringNotContainsString('ZVAL_NULL(_literal_strings[', $extension);
$this->assertStringContainsString('php::Str &get_str(uint32_t index) noexcept {', $extension); $this->assertStringContainsString('php::Str &get_str(uint32_t index) noexcept {', $extension);
$this->assertStringContainsString('return get_str(', $extension); $this->assertStringContainsString('return get_str(', $extension);
$this->assertStringContainsString('php::Array php_exported_variadic_arg_0_default_value() {', $extension); $this->assertStringContainsString('php::Array php_exported_variadic_arg_0_default_value() {', $extension);

@ -140,7 +140,7 @@ final class PhpBuildConfigurationTest extends TestCase
public function testSapiDerivationBuildsStaticCliAndSelectedFpm(): void public function testSapiDerivationBuildsStaticCliAndSelectedFpm(): void
{ {
$options = PhpBuildConfiguration::deriveSapi( $options = PhpBuildConfiguration::derivePhpBuilder(
[ [
'--prefix=/usr', '--prefix=/usr',
'--enable-embed=shared', '--enable-embed=shared',
@ -151,11 +151,13 @@ final class PhpBuildConfigurationTest extends TestCase
], ],
'/tmp/typephp-sapi', '/tmp/typephp-sapi',
['cli', 'fpm'], ['cli', 'fpm'],
true,
); );
self::assertContains('--prefix=/tmp/typephp-sapi', $options); self::assertContains('--prefix=/tmp/typephp-sapi', $options);
self::assertContains('--enable-cli', $options); self::assertContains('--enable-cli', $options);
self::assertContains('--enable-fpm', $options); self::assertContains('--enable-fpm', $options);
self::assertContains('--enable-zts', $options);
self::assertContains('--enable-opcache', $options); self::assertContains('--enable-opcache', $options);
self::assertContains('--with-curl', $options); self::assertContains('--with-curl', $options);
self::assertContains('--without-pear', $options); self::assertContains('--without-pear', $options);
@ -164,6 +166,16 @@ final class PhpBuildConfigurationTest extends TestCase
self::assertNotContains('--with-curl=shared', $options); self::assertNotContains('--with-curl=shared', $options);
} }
public function testPhpBuilderDerivationBuildsStaticEmbedWithoutZts(): void
{
$options = PhpBuildConfiguration::derivePhpBuilder([], '/tmp/typephp-embed', ['embed'], false);
self::assertContains('--enable-embed=static', $options);
self::assertContains('--enable-cli', $options);
self::assertContains('--disable-fpm', $options);
self::assertContains('--disable-zts', $options);
}
public function testParseShellWordsRejectsIncompleteInput(): void public function testParseShellWordsRejectsIncompleteInput(): void
{ {
$this->expectException(\InvalidArgumentException::class); $this->expectException(\InvalidArgumentException::class);

@ -292,6 +292,9 @@ abstract class GccLikeBackend extends CompilerBackend
if (!empty($options['libraries'])) { if (!empty($options['libraries'])) {
$cmd .= ' ' . $this->formatLibraries($options['libraries']); $cmd .= ' ' . $this->formatLibraries($options['libraries']);
} }
if (!empty($options['post_ldflags'])) {
$cmd .= ' ' . $options['post_ldflags'];
}
return $cmd; return $cmd;
} }

@ -177,9 +177,9 @@ trait NativeBuildConfigurationTrait
$includePaths = array_merge($includePaths, $phpSdkPaths); $includePaths = array_merge($includePaths, $phpSdkPaths);
} else { } else {
// Linux/macOS // Linux/macOS
$phpPaths = $platform->buildPhpIncludePaths($this->getPhpDir(), $this->isSapiBuild()); $phpPaths = $platform->buildPhpIncludePaths($this->getPhpDir(), $this->isPhpBuilderBuild());
$includePaths = array_merge($includePaths, $phpPaths); $includePaths = array_merge($includePaths, $phpPaths);
if ($this->isSapiBuild() && $this->sapiPhpBuildDirectory !== null) { if ($this->isPhpBuilderBuild() && $this->sapiPhpBuildDirectory !== null) {
$makefile = $this->sapiPhpBuildDirectory . '/Makefile'; $makefile = $this->sapiPhpBuildDirectory . '/Makefile';
$contents = is_file($makefile) ? (string) file_get_contents($makefile) : ''; $contents = is_file($makefile) ? (string) file_get_contents($makefile) : '';
if (preg_match('/^INCLUDES[ \t]*=[ \t]*(.*)$/m', $contents, $match) === 1) { if (preg_match('/^INCLUDES[ \t]*=[ \t]*(.*)$/m', $contents, $match) === 1) {
@ -211,9 +211,11 @@ trait NativeBuildConfigurationTrait
} }
$platform = $this->getPlatform(); $platform = $this->getPlatform();
$libraryPaths = [ $libraryPaths = [];
$this->getPhpxDir() . '/lib', if ($this->isPhpBuilderBuild() && $this->sapiPhpxArchive !== null) {
]; $libraryPaths[] = dirname($this->sapiPhpxArchive);
}
$libraryPaths[] = $this->getPhpxDir() . '/lib';
// Add the platform-specific PHP library paths // Add the platform-specific PHP library paths
if ($platform instanceof Windows) { if ($platform instanceof Windows) {
@ -335,6 +337,10 @@ trait NativeBuildConfigurationTrait
$platform = $this->getPlatform(); $platform = $this->getPlatform();
if ($this->isPhpBuilderBuild() && $this->sapiPhpxArchive !== null) {
return is_file($this->sapiPhpxArchive) ? $this->sapiPhpxArchive : null;
}
if ($platform instanceof Windows) { if ($platform instanceof Windows) {
$phpxLibPath = $this->getPhpxDir() . '\\lib\\phpx.lib'; $phpxLibPath = $this->getPhpxDir() . '\\lib\\phpx.lib';
return is_file($phpxLibPath) ? $phpxLibPath : null; return is_file($phpxLibPath) ? $phpxLibPath : null;

@ -156,6 +156,19 @@ trait NativeCommandOptionsTrait
$libraries = array_merge($libraries, $this->linkLibs); $libraries = array_merge($libraries, $this->linkLibs);
$ldflags = $this->ldflags; $ldflags = $this->ldflags;
$postLdflags = '';
if ($this->isPhpBuilderBuild()
&& $this->hasSapi('embed')
&& $this->sapiPhpBuildDirectory !== null
) {
$makefile = $this->sapiPhpBuildDirectory . '/Makefile';
$contents = is_file($makefile) ? (string) file_get_contents($makefile) : '';
if (preg_match('/^EXTRA_LIBS[ \t]*=[ \t]*(.*)$/m', $contents, $match) === 1) {
// Static libphp references these system libraries, so they must
// appear after -lphp on linkers that resolve archives left-to-right.
$postLdflags = trim($match[1]);
}
}
$targetPlatform = $this->targetPlatform; $targetPlatform = $this->targetPlatform;
if ($this->fullStatic) { if ($this->fullStatic) {
// The bundled libphp.a carries musl libc, so the link must use musl's // The bundled libphp.a carries musl libc, so the link must use musl's
@ -181,6 +194,7 @@ trait NativeCommandOptionsTrait
'library_paths' => $libraryPaths, 'library_paths' => $libraryPaths,
'libraries' => $libraries, 'libraries' => $libraries,
'ldflags' => $ldflags, 'ldflags' => $ldflags,
'post_ldflags' => $postLdflags,
'debug' => $this->debug, 'debug' => $this->debug,
'no_console' => $this->noConsole, 'no_console' => $this->noConsole,
'build_mode' => $this->buildMode, 'build_mode' => $this->buildMode,

@ -0,0 +1,112 @@
<?php
/**
* This file is part of TypePHP(AOT).
*
* @link https://www.swoole.com/aot/
* @contact service@swoole.com
*/
namespace TypePhp\Build;
use TypePhp\Http\Downloader;
final class PeclExtensionSource
{
private readonly \Closure $output;
private readonly Downloader $downloader;
/** @param callable(string):void $output */
public function __construct(
private readonly string $cacheDirectory,
callable $output,
?string $proxy = null,
) {
$this->output = \Closure::fromCallable($output);
$this->downloader = new Downloader($proxy);
}
/** @return array{directory:string,version:string} */
public function prepare(string $extension): array
{
$extension = SapiExtensionRequirements::normalize($extension);
if (preg_match('/^[a-z][a-z0-9_-]*$/D', $extension) !== 1) {
throw new \InvalidArgumentException("Invalid PECL extension name `{$extension}`");
}
$version = trim($this->downloader->downloadText(
'https://pecl.php.net/rest/r/' . rawurlencode($extension) . '/stable.txt',
));
if (preg_match('/^[0-9][A-Za-z0-9._-]*$/D', $version) !== 1) {
throw new \RuntimeException("PECL extension `{$extension}` has no stable source release");
}
$root = $this->cacheDirectory . '/pecl/' . $extension . '-' . $version;
if ($this->isComplete($root)) {
return ['directory' => $root, 'version' => $version];
}
$this->mkdir($this->cacheDirectory . '/pecl');
$lock = fopen($this->cacheDirectory . '/pecl/download.lock', 'c+');
if ($lock === false || !flock($lock, LOCK_EX)) {
throw new \RuntimeException('Unable to lock the PECL source cache');
}
try {
if ($this->isComplete($root)) {
return ['directory' => $root, 'version' => $version];
}
if (is_dir($root)) {
throw new \RuntimeException("Incomplete PECL source cache must be removed: {$root}");
}
$archives = $this->cacheDirectory . '/archives';
$this->mkdir($archives);
$archive = $archives . '/' . $extension . '-' . $version . '.tgz';
if (!is_file($archive)) {
($this->output)("Downloading PECL extension {$extension}-{$version}");
$temporary = $archive . '.part-' . bin2hex(random_bytes(6));
try {
$this->downloader->downloadFile(
'https://pecl.php.net/get/' . rawurlencode($extension . '-' . $version) . '.tgz',
$temporary,
);
if (!rename($temporary, $archive)) {
throw new \RuntimeException("Unable to cache PECL archive: {$archive}");
}
} finally {
if (is_file($temporary)) {
@unlink($temporary);
}
}
}
($this->output)("Extracting PECL extension {$extension}-{$version}");
$this->run(['tar', '-xzf', $archive, '-C', $this->cacheDirectory . '/pecl']);
if (!$this->isComplete($root)) {
throw new \RuntimeException("PECL archive did not produce extension source: {$root}");
}
return ['directory' => $root, 'version' => $version];
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
private function isComplete(string $directory): bool
{
return is_dir($directory)
&& (is_file($directory . '/config.m4') || is_file($directory . '/config0.m4'));
}
/** @param list<string> $command */
private function run(array $command): void
{
$process = proc_open($command, [STDIN, STDOUT, STDERR], $pipes);
if (!is_resource($process) || proc_close($process) !== 0) {
throw new \RuntimeException('Command failed: ' . implode(' ', $command));
}
}
private function mkdir(string $directory): void
{
if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) {
throw new \RuntimeException("Cannot create PECL source cache directory: {$directory}");
}
}
}

@ -0,0 +1,143 @@
<?php
/**
* This file is part of TypePHP(AOT).
*
* @link https://www.swoole.com/aot/
* @contact service@swoole.com
*/
namespace TypePhp\Build;
use Symfony\Component\Yaml\Yaml;
final readonly class PhpBuilderConfiguration
{
/**
* @param list<string> $extensions
*/
public function __construct(
public bool $zts,
public array $extensions,
) {
}
public static function fromYaml(mixed $value): self
{
if (!is_array($value) || ($value !== [] && array_is_list($value))) {
throw new \InvalidArgumentException('`php-builder` must be a mapping');
}
return self::fromArray($value);
}
public static function fromCommandLine(string $value): self
{
$value = trim($value);
if ($value === '') {
throw new \InvalidArgumentException('Option --php-builder requires a non-empty configuration');
}
try {
$parsed = Yaml::parse(self::semicolonSeparatedYaml($value));
} catch (\Throwable $exception) {
throw new \InvalidArgumentException(
'Invalid --php-builder configuration: ' . $exception->getMessage(),
previous: $exception,
);
}
return self::fromYaml($parsed);
}
/** @param array<string, mixed> $config */
private static function fromArray(array $config): self
{
$unknown = array_diff(array_keys($config), ['zts', 'extensions']);
if ($unknown !== []) {
throw new \InvalidArgumentException(
'Unknown `php-builder` option' . (count($unknown) > 1 ? 's' : '')
. ': ' . implode(', ', array_map(static fn (string $key): string => "`{$key}`", $unknown)),
);
}
$extensions = self::stringList($config['extensions'] ?? [], 'php-builder.extensions', true);
$extensions = SapiExtensionRequirements::merge($extensions);
return new self(
self::boolean($config['zts'] ?? false),
$extensions,
);
}
/** @return list<string> */
private static function stringList(mixed $value, string $name, bool $allowEmpty = false): array
{
$values = is_string($value) ? preg_split('/\s*,\s*/', trim($value)) : $value;
if (!is_array($values)) {
throw new \InvalidArgumentException("`{$name}` must be a string or list");
}
$result = [];
foreach ($values as $entry) {
if (!is_string($entry) || trim($entry) === '') {
throw new \InvalidArgumentException("Each `{$name}` entry must be a non-empty string");
}
$result[] = strtolower(trim($entry));
}
if (!$allowEmpty && $result === []) {
throw new \InvalidArgumentException("`{$name}` must not be empty");
}
return $result;
}
private static function boolean(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if ($value === 1 || $value === 0) {
return (bool) $value;
}
if (is_string($value)) {
return match (strtolower(trim($value))) {
'on', 'true', 'yes', '1' => true,
'off', 'false', 'no', '0' => false,
default => throw new \InvalidArgumentException('`php-builder.zts` must be on or off'),
};
}
throw new \InvalidArgumentException('`php-builder.zts` must be on or off');
}
private static function semicolonSeparatedYaml(string $value): string
{
$result = '';
$quote = null;
$depth = 0;
$escaped = false;
foreach (str_split($value) as $character) {
if ($escaped) {
$result .= $character;
$escaped = false;
continue;
}
if ($character === '\\' && $quote === '"') {
$result .= $character;
$escaped = true;
continue;
}
if (($character === '"' || $character === "'") && ($quote === null || $quote === $character)) {
$quote = $quote === null ? $character : null;
$result .= $character;
continue;
}
if ($quote === null) {
if ($character === '[' || $character === '{') {
$depth++;
} elseif ($character === ']' || $character === '}') {
$depth--;
} elseif ($character === ';' && $depth === 0) {
$result .= "\n";
continue;
}
}
$result .= $character;
}
return preg_replace('/\n[ \t]+/', "\n", trim($result)) ?? trim($result);
}
}

@ -0,0 +1,94 @@
<?php
/**
* This file is part of TypePHP(AOT).
*
* @link https://www.swoole.com/aot/
* @contact service@swoole.com
*/
namespace TypePhp\Build;
final class PhpBuilderSource
{
private readonly \Closure $output;
/** @param callable(string):void $output */
public function __construct(
private readonly string $cacheDirectory,
callable $output,
private readonly ?string $proxy = null,
) {
$this->output = \Closure::fromCallable($output);
}
/**
* @param list<string> $extensions
* @return array{source:string,external:array<string,string>}
*/
public function prepare(string $officialSource, array $extensions): array
{
$external = [];
$sources = [];
$pecl = new PeclExtensionSource($this->cacheDirectory, $this->output, $this->proxy);
foreach (SapiExtensionRequirements::merge($extensions) as $extension) {
$directoryName = str_replace('-', '_', $extension);
if (is_dir($officialSource . '/ext/' . $directoryName)) {
continue;
}
$release = $pecl->prepare($extension);
$external[$extension] = $release['version'];
$sources[$extension] = $release['directory'];
}
if ($external === []) {
return ['source' => $officialSource, 'external' => []];
}
ksort($external, SORT_STRING);
$version = OfficialPhpSource::version($officialSource);
$fingerprint = substr(hash('sha256', json_encode($external, JSON_THROW_ON_ERROR)), 0, 16);
$source = $this->cacheDirectory . '/php-builder-src/php-' . $version . '-' . $fingerprint;
$manifest = $source . '/.typephp-extensions.json';
$metadata = json_encode($external, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)
. PHP_EOL;
if (is_file($source . '/configure') && @file_get_contents($manifest) === $metadata) {
return ['source' => $source, 'external' => $external];
}
if (is_dir($source)) {
throw new \RuntimeException("Incomplete PHP builder source cache must be removed: {$source}");
}
$parent = dirname($source);
$this->mkdir($parent);
$temporary = $source . '.part-' . bin2hex(random_bytes(6));
$this->mkdir($temporary);
($this->output)('Preparing php-src with external extensions: ' . implode(', ', array_keys($external)));
$this->run(['cp', '-a', rtrim($officialSource, '/\\') . '/.', $temporary]);
foreach ($sources as $extension => $extensionSource) {
$target = $temporary . '/ext/' . str_replace('-', '_', $extension);
$this->mkdir($target);
$this->run(['cp', '-a', rtrim($extensionSource, '/\\') . '/.', $target]);
}
$this->run(['sh', $temporary . '/buildconf', '--force'], $temporary);
AtomicFile::write($temporary . '/.typephp-extensions.json', $metadata);
if (!rename($temporary, $source)) {
throw new \RuntimeException("Unable to publish PHP builder source tree: {$source}");
}
return ['source' => $source, 'external' => $external];
}
/** @param list<string> $command */
private function run(array $command, ?string $directory = null): void
{
$process = proc_open($command, [STDIN, STDOUT, STDERR], $pipes, $directory);
if (!is_resource($process) || proc_close($process) !== 0) {
throw new \RuntimeException('Command failed: ' . implode(' ', $command));
}
}
private function mkdir(string $directory): void
{
if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) {
throw new \RuntimeException("Cannot create PHP builder source directory: {$directory}");
}
}
}

@ -96,6 +96,13 @@ final class SapiApplicationLinker
$arguments[] = 'cli'; $arguments[] = 'cli';
} else { } else {
$arguments[] = 'PHP_FPM_OBJS=' . $this->onlyEntryObjects($fpmObjects, ['fpm_main.lo']) $arguments[] = 'PHP_FPM_OBJS=' . $this->onlyEntryObjects($fpmObjects, ['fpm_main.lo'])
// The shared generated object also contains the Embed-only
// process-title function table. FPM does not register that
// table, but the linker must still resolve its callbacks.
. ' ' . $this->onlyEntryObjects(
$cliObjects,
['ps_title.lo', 'php_cli_process_title.lo'],
)
. ' ' . $runtimeArchive; . ' ' . $runtimeArchive;
$arguments[] = 'SAPI_FPM_PATH=' . $linkOutput; $arguments[] = 'SAPI_FPM_PATH=' . $linkOutput;
$arguments[] = 'fpm'; $arguments[] = 'fpm';

@ -17,20 +17,16 @@ final class SapiBuildConfiguration
throw new \InvalidArgumentException('`sapi` entries must be non-empty strings'); throw new \InvalidArgumentException('`sapi` entries must be non-empty strings');
} }
$target = strtolower(trim($target)); $target = strtolower(trim($target));
if ($target === 'both') { if (!in_array($target, ['embed', 'cli', 'fpm'], true)) {
array_push($targets, 'cli', 'fpm');
continue;
}
if (!in_array($target, ['cli', 'fpm'], true)) {
throw new \InvalidArgumentException( throw new \InvalidArgumentException(
"Invalid SAPI target `{$target}`. Expected cli, fpm, or both.", "Invalid SAPI target `{$target}`. Expected embed, cli, or fpm.",
); );
} }
$targets[] = $target; $targets[] = $target;
} }
$targets = array_values(array_unique($targets)); $targets = array_values(array_unique($targets));
if ($targets === []) { if ($targets === []) {
throw new \InvalidArgumentException('`sapi` must select cli, fpm, or both'); throw new \InvalidArgumentException('`sapi` must select embed, cli, or fpm');
} }
return $targets; return $targets;
} }

@ -32,29 +32,38 @@ final class SapiPhpBuilder
array $targets, array $targets,
int $jobs, int $jobs,
array $requiredExtensions = [], array $requiredExtensions = [],
bool $zts = false,
): SapiPhpBuild { ): SapiPhpBuild {
if (PHP_OS_FAMILY !== 'Linux' && PHP_OS_FAMILY !== 'Darwin') { if (PHP_OS_FAMILY !== 'Linux' && PHP_OS_FAMILY !== 'Darwin') {
throw new \RuntimeException('Self-contained SAPI builds currently require Linux or macOS'); throw new \RuntimeException('Self-contained SAPI builds currently require Linux or macOS');
} }
if ($targets === [] || array_diff($targets, ['cli', 'fpm']) !== []) { if ($targets === [] || array_diff($targets, ['embed', 'cli', 'fpm']) !== []) {
throw new \InvalidArgumentException('SAPI targets must contain cli, fpm, or both'); throw new \InvalidArgumentException('PHP builder SAPI targets must contain embed, cli, or fpm');
} }
$source = (new OfficialPhpSource( $officialSource = (new OfficialPhpSource(
OfficialPhpSource::defaultCacheDirectory(), OfficialPhpSource::defaultCacheDirectory(),
$this->output, $this->output,
$this->proxy, $this->proxy,
))->prepare($phpVersion); ))->prepare($phpVersion);
$preparedSource = (new PhpBuilderSource(
OfficialPhpSource::defaultCacheDirectory(),
$this->output,
$this->proxy,
))->prepare($officialSource, $requiredExtensions);
$source = $preparedSource['source'];
$externalExtensions = $preparedSource['external'];
$sourceVersion = OfficialPhpSource::version($source); $sourceVersion = OfficialPhpSource::version($source);
$baseOptions = $this->sourceConfigureOptions($source); $baseOptions = $this->sourceConfigureOptions($source);
$requiredExtensions = SapiExtensionRequirements::merge($requiredExtensions); $requiredExtensions = SapiExtensionRequirements::merge($requiredExtensions);
$extensionOptions = SapiExtensionConfiguration::configureOptions($source, $requiredExtensions); $extensionOptions = SapiExtensionConfiguration::configureOptions($source, $requiredExtensions);
// Build both official executable SAPIs once. Individual projects can $runtimeTargets = array_values(array_unique($targets));
// then select either archive without configuring or compiling php-src. sort($runtimeTargets, SORT_STRING);
$runtimeTargets = ['cli', 'fpm'];
$identity = [ $identity = [
$sourceVersion, $sourceVersion,
$baseOptions, $baseOptions,
$runtimeTargets, $runtimeTargets,
$zts,
$externalExtensions,
PHP_OS_FAMILY, PHP_OS_FAMILY,
php_uname('m'), php_uname('m'),
getenv('CC') ?: '', getenv('CC') ?: '',
@ -62,7 +71,7 @@ final class SapiPhpBuilder
filemtime($source . '/configure'), filemtime($source . '/configure'),
]; ];
$compatibility = hash('sha256', json_encode($identity, JSON_THROW_ON_ERROR)); $compatibility = hash('sha256', json_encode($identity, JSON_THROW_ON_ERROR));
$cacheDirectory = OfficialPhpSource::defaultCacheDirectory() . '/sapi'; $cacheDirectory = OfficialPhpSource::defaultCacheDirectory() . '/php-builder';
$cached = $this->findCompatibleRuntime( $cached = $this->findCompatibleRuntime(
$cacheDirectory, $cacheDirectory,
$sourceVersion, $sourceVersion,
@ -84,7 +93,7 @@ final class SapiPhpBuilder
$this->mkdir($root); $this->mkdir($root);
$lock = fopen($root . '/build.lock', 'c+'); $lock = fopen($root . '/build.lock', 'c+');
if ($lock === false || !flock($lock, LOCK_EX)) { if ($lock === false || !flock($lock, LOCK_EX)) {
throw new \RuntimeException("Unable to lock SAPI runtime cache: {$root}"); throw new \RuntimeException("Unable to lock PHP builder runtime cache: {$root}");
} }
try { try {
$build = $root . '/build'; $build = $root . '/build';
@ -92,29 +101,33 @@ final class SapiPhpBuilder
$this->mkdir($build); $this->mkdir($build);
$this->mkdir($prefix . '/lib/conf.d'); $this->mkdir($prefix . '/lib/conf.d');
$options = PhpBuildConfiguration::deriveSapi( $options = PhpBuildConfiguration::derivePhpBuilder(
[...$baseOptions, ...$extensionOptions], [...$baseOptions, ...$extensionOptions],
$prefix, $prefix,
$runtimeTargets, $runtimeTargets,
$zts,
); );
$configured = $build . '/.typephp-configure.json'; $configured = $build . '/.typephp-configure.json';
$configuration = json_encode($options, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); $configuration = json_encode($options, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
if (!is_file($build . '/Makefile') || @file_get_contents($configured) !== $configuration) { if (!is_file($build . '/Makefile') || @file_get_contents($configured) !== $configuration) {
($this->output)('Configuring private PHP ' . $sourceVersion . ' SAPI runtime'); ($this->output)('Configuring PHP ' . $sourceVersion . ' source runtime');
$this->run([$source . '/configure', ...$options], $build); $this->run([$source . '/configure', ...$options], $build);
AtomicFile::write($configured, $configuration); AtomicFile::write($configured, $configuration);
} }
$php = $prefix . '/bin/php'; $php = $prefix . '/bin/php';
$fpm = $prefix . '/sbin/php-fpm'; $fpm = $prefix . '/sbin/php-fpm';
$needsInstall = !is_executable($php) || !is_executable($fpm); $embed = $prefix . '/lib/libphp.a';
$needsInstall = !is_executable($php)
|| (in_array('fpm', $runtimeTargets, true) && !is_executable($fpm))
|| (in_array('embed', $runtimeTargets, true) && !is_file($embed));
if ($needsInstall) { if ($needsInstall) {
($this->output)('Building private PHP runtime (cached across application builds)'); ($this->output)('Building private PHP runtime (cached across application builds)');
$this->run(['make', '-j' . max(1, $jobs)], $build); $this->run(['make', '-j' . max(1, $jobs)], $build);
$this->run(['make', 'install'], $build); $this->run(['make', 'install'], $build);
} }
$sapiArchives = $this->buildSapiArchives($build, $root, $jobs); $sapiArchives = $this->buildSapiArchives($build, $root, $jobs, $runtimeTargets, $embed);
$phpxBuild = $root . '/phpx-build'; $phpxBuild = $root . '/phpx-build';
$phpxArchive = $phpxBuild . '/lib/libphpx.a'; $phpxArchive = $phpxBuild . '/lib/libphpx.a';
@ -142,10 +155,13 @@ final class SapiPhpBuilder
} }
AtomicFile::write($root . '/runtime.json', json_encode([ AtomicFile::write($root . '/runtime.json', json_encode([
'version' => $sourceVersion, 'version' => $sourceVersion,
'source' => $source,
'compatibility' => $compatibility, 'compatibility' => $compatibility,
'requested_extensions' => $requiredExtensions, 'requested_extensions' => $requiredExtensions,
'enabled_extensions' => $enabledExtensions, 'enabled_extensions' => $enabledExtensions,
'configure_options' => $options, 'configure_options' => $options,
'sapis' => $runtimeTargets,
'zts' => $zts,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL); ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL);
return new SapiPhpBuild( return new SapiPhpBuild(
$source, $source,
@ -185,7 +201,7 @@ final class SapiPhpBuilder
if (array_diff($requiredExtensions, $enabled) !== []) { if (array_diff($requiredExtensions, $enabled) !== []) {
continue; continue;
} }
$runtime = $this->runtimeFromRoot($root, $sourceVersion, $enabled); $runtime = $this->runtimeFromRoot($root, $sourceVersion, $enabled, $requiredExtensions);
if ($runtime !== null) { if ($runtime !== null) {
$matches[] = [$runtime, count($enabled), filemtime($manifest) ?: 0]; $matches[] = [$runtime, count($enabled), filemtime($manifest) ?: 0];
} }
@ -199,23 +215,37 @@ final class SapiPhpBuilder
string $root, string $root,
string $version, string $version,
array $enabledExtensions, array $enabledExtensions,
array $requiredExtensions,
): ?SapiPhpBuild { ): ?SapiPhpBuild {
$build = $root . '/build'; $build = $root . '/build';
$prefix = $root . '/install'; $prefix = $root . '/install';
$phpxArchive = $root . '/phpx-build/lib/libphpx.a'; $phpxArchive = $root . '/phpx-build/lib/libphpx.a';
$archives = [ $metadata = json_decode((string) @file_get_contents($root . '/runtime.json'), true);
'cli' => $root . '/lib/libphp-cli.a', $targets = is_array($metadata) && is_array($metadata['sapis'] ?? null)
'fpm' => $root . '/lib/libphp-fpm.a', ? $metadata['sapis']
]; : ['cli', 'fpm'];
$archives = [];
foreach ($targets as $target) {
if ($target === 'embed') {
$archives['embed'] = $prefix . '/lib/libphp.a';
} elseif ($target === 'cli' || $target === 'fpm') {
$archives[$target] = $root . '/lib/libphp-' . $target . '.a';
}
}
if (!is_executable($prefix . '/bin/php') if (!is_executable($prefix . '/bin/php')
|| !is_file($phpxArchive) || !is_file($phpxArchive)
|| !is_file($archives['cli']) || array_diff($requiredExtensions, $enabledExtensions) !== []
|| !is_file($archives['fpm']) || array_filter($archives, static fn (string $archive): bool => !is_file($archive)) !== []
|| !is_file($build . '/Makefile') || !is_file($build . '/Makefile')
) { ) {
return null; return null;
} }
$source = OfficialPhpSource::defaultCacheDirectory() . '/src/php-' . $version; $source = is_array($metadata) && is_string($metadata['source'] ?? null)
? $metadata['source']
: OfficialPhpSource::defaultCacheDirectory() . '/src/php-' . $version;
if (!is_dir($source)) {
return null;
}
return new SapiPhpBuild( return new SapiPhpBuild(
$source, $source,
$build, $build,
@ -251,15 +281,24 @@ final class SapiPhpBuilder
} }
/** @return array<string, string> */ /** @return array<string, string> */
private function buildSapiArchives(string $build, string $root, int $jobs): array private function buildSapiArchives(
string $build,
string $root,
int $jobs,
array $targets,
string $embedArchive,
): array
{ {
$libraryDirectory = $root . '/lib'; $libraryDirectory = $root . '/lib';
$this->mkdir($libraryDirectory); $this->mkdir($libraryDirectory);
$archives = [ $archives = [];
'cli' => $libraryDirectory . '/libphp-cli.a', foreach (array_intersect($targets, ['cli', 'fpm']) as $target) {
'fpm' => $libraryDirectory . '/libphp-fpm.a', $archives[$target] = $libraryDirectory . '/libphp-' . $target . '.a';
]; }
if (is_file($archives['cli']) && is_file($archives['fpm'])) { if (in_array('embed', $targets, true)) {
$archives['embed'] = $embedArchive;
}
if (array_filter($archives, static fn (string $archive): bool => !is_file($archive)) === []) {
return $archives; return $archives;
} }
// The normal PHP build creates every core and SAPI object. Archive // The normal PHP build creates every core and SAPI object. Archive
@ -273,6 +312,9 @@ final class SapiPhpBuilder
'cli' => ['PHP_CLI_OBJS', 'sapi/cli/php_cli.lo'], 'cli' => ['PHP_CLI_OBJS', 'sapi/cli/php_cli.lo'],
'fpm' => ['PHP_FPM_OBJS', 'sapi/fpm/fpm/fpm_main.lo'], 'fpm' => ['PHP_FPM_OBJS', 'sapi/fpm/fpm/fpm_main.lo'],
] as $target => [$variable, $entryObject]) { ] as $target => [$variable, $entryObject]) {
if (!isset($archives[$target])) {
continue;
}
if (is_file($archives[$target])) { if (is_file($archives[$target])) {
continue; continue;
} }
@ -289,6 +331,9 @@ final class SapiPhpBuilder
throw new \RuntimeException('Unable to store PHP SAPI archive: ' . $archives[$target]); throw new \RuntimeException('Unable to store PHP SAPI archive: ' . $archives[$target]);
} }
} }
if (isset($archives['embed']) && !is_file($archives['embed'])) {
throw new \RuntimeException('PHP Embed static archive was not installed: ' . $archives['embed']);
}
return $archives; return $archives;
} }

@ -13,7 +13,7 @@ use Ajaxray\AnsiKit\Components\Progressbar;
use TypePhp\Backend\CompilerFactory; use TypePhp\Backend\CompilerFactory;
use TypePhp\Exception\SyntaxError; use TypePhp\Exception\SyntaxError;
use TypePhp\Exception\Unsupported; use TypePhp\Exception\Unsupported;
use TypePhp\Installer\LibPhpInstaller; use TypePhp\Installer\InteractiveConsole;
use TypePhp\Installer\LibPhpxInstaller; use TypePhp\Installer\LibPhpxInstaller;
use TypePhp\Platform\Linux; use TypePhp\Platform\Linux;
use TypePhp\Platform\Wasi; use TypePhp\Platform\Wasi;
@ -509,11 +509,20 @@ trait SourcePipelineTrait
private function validateLoadedProjectConfiguration(): void private function validateLoadedProjectConfiguration(): void
{ {
if (in_array('cli', $this->sapiTargets, true) && $this->sapiEntryFile === null) { if ($this->sapiConfigured && !$this->isBuildModeBin()) {
$this->error('CLI SAPI builds require an `entry` PHP file'); $this->error('`sapi` is only supported with `mode: bin`');
} }
if ($this->sapiEntryFile !== null && !in_array('cli', $this->sapiTargets, true)) { if ($this->isPhpBuilderBuild() && !$this->isBuildModeBin()) {
$this->error('`entry` requires the CLI SAPI target'); $this->error('`php-builder` is only supported with `mode: bin`');
}
if ($this->isSapiBuild() && !$this->isPhpBuilderBuild()) {
$this->error('The cli and fpm SAPIs require `php-builder`');
}
if ($this->hasSapi('cli') && $this->sapiEntryFile === null) {
$this->error('`sapi` containing cli requires an `entry` PHP file');
}
if ($this->sapiEntryFile !== null && !$this->hasSapi('cli')) {
$this->error('`entry` requires `sapi` to contain cli');
} }
if ($this->embeddedFiles !== [] && !$this->isBuildModeBin() && !$this->isSapiBuild()) { if ($this->embeddedFiles !== [] && !$this->isBuildModeBin() && !$this->isSapiBuild()) {
$this->error('`embedded-files` requires `mode: bin`'); $this->error('`embedded-files` requires `mode: bin`');
@ -573,21 +582,16 @@ trait SourcePipelineTrait
return; return;
} }
if ($this->isSapiBuild()) { $this->offerPhpBuilderForMissingEmbedRuntime();
$this->prepareSapiBuildEnvironment();
if ($this->isPhpBuilderBuild()) {
$this->preparePhpBuilderEnvironment();
$this->getOpcodeBuildExtensionArgs(); $this->getOpcodeBuildExtensionArgs();
return; return;
} }
$platform = $this->getPlatform(); $platform = $this->getPlatform();
$phpDir = $this->getPhpDir(); $phpDir = $this->getPhpDir();
if ($this->isBuildModeEmbed() && $platform instanceof Linux) {
try {
$phpDir = (new LibPhpInstaller(proxy: $this->downloadProxy))->ensure($phpDir) ?? $phpDir;
} catch (\Throwable $e) {
$this->error('Unable to install libphp.so: ' . $e->getMessage());
}
}
if (!$platform instanceof Wasi) { if (!$platform instanceof Wasi) {
$this->validatePhpRuntimeMinimum($phpDir); $this->validatePhpRuntimeMinimum($phpDir);
@ -611,6 +615,43 @@ trait SourcePipelineTrait
} }
} }
private function offerPhpBuilderForMissingEmbedRuntime(): void
{
if ($this->isPhpBuilderBuild()
|| !$this->isBuildModeBin()
|| !$this->hasSapi('embed')
|| (PHP_OS_FAMILY !== 'Linux' && PHP_OS_FAMILY !== 'Darwin')
) {
return;
}
try {
$this->getPlatform()->detectPhpLibs($this->getPhpDir());
return;
} catch (\RuntimeException $exception) {
$detail = $exception->getMessage();
}
$console = new InteractiveConsole();
if (!$console->isInteractive()) {
$this->error(
"The host PHP embed library is missing: {$detail}. "
. 'Run tpc in an interactive terminal to enable php-builder, or pass '
. "--php-builder='extensions: []; zts: off'",
);
}
$console->write('The host PHP installation does not provide an Embed SAPI library.');
if (!$console->confirm('Build a private PHP runtime from php-src with php-builder?', true)) {
$this->error('The embed SAPI requires a host libphp library or `php-builder`');
}
$this->phpBuilderEnabled = true;
$this->phpBuilderZts = false;
$this->phpBuilderExtensions = [];
$console->write('php-builder enabled; required extensions will be collected automatically.');
}
private function reportBuildLibraryWarnings(): void private function reportBuildLibraryWarnings(): void
{ {
// shell_exec and define are already called directly via php::fn::, so no // shell_exec and define are already called directly via php::fn::, so no
@ -618,7 +659,7 @@ trait SourcePipelineTrait
// All Windows build modes depend on the PHPX import library and runtime. // All Windows build modes depend on the PHPX import library and runtime.
// Other platforms only run the existing checks in embedded build mode. // Other platforms only run the existing checks in embedded build mode.
if (!$this->isNanoMode() && !$this->isSapiBuild() if (!$this->isNanoMode() && !$this->isPhpBuilderBuild()
&& ($this->isBuildModeEmbed() || $this->getPlatform() instanceof Windows)) { && ($this->isBuildModeEmbed() || $this->getPlatform() instanceof Windows)) {
foreach ($this->getPlatform()->getBuildLibraryWarnings( foreach ($this->getPlatform()->getBuildLibraryWarnings(
$this->getPhpDir(), $this->getPhpDir(),
@ -642,13 +683,14 @@ trait SourcePipelineTrait
} }
/** @param list<string>|null $sourceDependencies */ /** @param list<string>|null $sourceDependencies */
private function prepareSapiBuildEnvironment(?array $sourceDependencies = null): void private function preparePhpBuilderEnvironment(?array $sourceDependencies = null): void
{ {
try { try {
$composerDependencies = SapiExtensionRequirements::fromEmbeddedVendorFiles( $composerDependencies = SapiExtensionRequirements::fromEmbeddedVendorFiles(
$this->embeddedFiles, $this->embeddedFiles,
); );
$requiredExtensions = SapiExtensionRequirements::merge( $requiredExtensions = SapiExtensionRequirements::merge(
$this->phpBuilderExtensions,
$this->extensionDependencies, $this->extensionDependencies,
$composerDependencies, $composerDependencies,
$sourceDependencies ?? [], $sourceDependencies ?? [],
@ -671,6 +713,7 @@ trait SourcePipelineTrait
$this->sapiTargets, $this->sapiTargets,
min(8, max(1, $this->maxJob)), min(8, max(1, $this->maxJob)),
$requiredExtensions, $requiredExtensions,
$this->phpBuilderZts,
); );
} catch (\Throwable $exception) { } catch (\Throwable $exception) {
$this->error('Unable to prepare self-contained PHP SAPI runtime: ' . $exception->getMessage()); $this->error('Unable to prepare self-contained PHP SAPI runtime: ' . $exception->getMessage());
@ -682,6 +725,7 @@ trait SourcePipelineTrait
$this->sapiPhpxArchive = $runtime->phpxArchive; $this->sapiPhpxArchive = $runtime->phpxArchive;
$this->sapiRuntimeArchives = $runtime->sapiArchives; $this->sapiRuntimeArchives = $runtime->sapiArchives;
$this->sapiEnabledExtensions = $runtime->enabledExtensions; $this->sapiEnabledExtensions = $runtime->enabledExtensions;
$this->isPhpZts = $this->phpBuilderZts;
putenv('PHP_HOME=' . $runtime->prefix); putenv('PHP_HOME=' . $runtime->prefix);
$_ENV['PHP_HOME'] = $runtime->prefix; $_ENV['PHP_HOME'] = $runtime->prefix;
$this->validatePhpRuntimeMinimum($runtime->prefix); $this->validatePhpRuntimeMinimum($runtime->prefix);
@ -877,11 +921,11 @@ trait SourcePipelineTrait
$previousPhase = $this->enterCompilerPhase(self::PHASE_CONVERT); $previousPhase = $this->enterCompilerPhase(self::PHASE_CONVERT);
$this->initializeProjectConversion($files); $this->initializeProjectConversion($files);
$conversion = $this->convertProjectFiles($files); $conversion = $this->convertProjectFiles($files);
if ($this->isSapiBuild()) { if ($this->isPhpBuilderBuild()) {
// Function and class ownership is known only after lowering all // Function and class ownership is known only after lowering all
// project bodies. Upgrade the cached PHP runtime before code is // project bodies. Upgrade the cached PHP runtime before code is
// compiled if those bodies introduced another extension. // compiled if those bodies introduced another extension.
$this->prepareSapiBuildEnvironment($this->resolveExtensionDependencies()); $this->preparePhpBuilderEnvironment($this->resolveExtensionDependencies());
} }
return $this->finalizeProjectConversion($conversion); return $this->finalizeProjectConversion($conversion);
} finally { } finally {

@ -374,7 +374,6 @@ class CompilerBase implements PropertyAccessContext
public const string BUILD_MODE_BIN = 'bin'; public const string BUILD_MODE_BIN = 'bin';
public const string BUILD_MODE_EXT = 'ext'; public const string BUILD_MODE_EXT = 'ext';
public const string BUILD_MODE_LIB = 'lib'; public const string BUILD_MODE_LIB = 'lib';
public const string BUILD_MODE_SAPI = 'sapi';
public const string ENTRY_FUNCTION = 'main'; public const string ENTRY_FUNCTION = 'main';
protected const string PHASE_IDLE = 'idle'; protected const string PHASE_IDLE = 'idle';
protected const string PHASE_PREPARE = 'prepare'; protected const string PHASE_PREPARE = 'prepare';
@ -552,8 +551,17 @@ class CompilerBase implements PropertyAccessContext
/** Generate a VM-less program entry for the Composer php-nano runtime. */ /** Generate a VM-less program entry for the Composer php-nano runtime. */
protected bool $nanoMode = false; protected bool $nanoMode = false;
protected bool $phpBuilderEnabled = false;
/** @var list<string> */
protected array $sapiTargets = ['embed'];
protected bool $sapiConfigured = false;
protected bool $phpBuilderZts = false;
/** @var list<string> */ /** @var list<string> */
protected array $sapiTargets = []; protected array $phpBuilderExtensions = [];
/** Absolute path of the embedded primary script used by the CLI SAPI. */ /** Absolute path of the embedded primary script used by the CLI SAPI. */
protected ?string $sapiEntryFile = null; protected ?string $sapiEntryFile = null;
@ -970,19 +978,24 @@ class CompilerBase implements PropertyAccessContext
return $this->buildMode === self::BUILD_MODE_LIB; return $this->buildMode === self::BUILD_MODE_LIB;
} }
public function isBuildModeSapi(): bool public function isBuildModeEmbed(): bool
{ {
return $this->buildMode === self::BUILD_MODE_SAPI; return $this->isBuildModeBin() || $this->isBuildModeLib();
} }
public function isBuildModeEmbed(): bool public function isPhpBuilderBuild(): bool
{
return $this->phpBuilderEnabled;
}
public function hasSapi(string $sapi): bool
{ {
return $this->isBuildModeBin() || $this->isBuildModeLib() || $this->isBuildModeSapi(); return in_array($sapi, $this->sapiTargets, true);
} }
public function isSapiBuild(): bool public function isSapiBuild(): bool
{ {
return $this->sapiTargets !== []; return $this->hasSapi('cli') || $this->hasSapi('fpm');
} }
/** @return list<string> */ /** @return list<string> */

@ -159,13 +159,19 @@ final class PhpBuildConfiguration
* @param list<string> $targets * @param list<string> $targets
* @return list<string> * @return list<string>
*/ */
public static function deriveSapi(string|array $configureOptions, string $prefix, array $targets): array public static function derivePhpBuilder(
string|array $configureOptions,
string $prefix,
array $targets,
bool $zts,
): array
{ {
$replace = [ $replace = [
'--prefix', '--with-config-file-path', '--with-config-file-scan-dir', '--prefix', '--with-config-file-path', '--with-config-file-scan-dir',
'--enable-cli', '--disable-cli', '--enable-fpm', '--disable-fpm', '--enable-cli', '--disable-cli', '--enable-fpm', '--disable-fpm',
'--enable-cgi', '--disable-cgi', '--enable-phpdbg', '--disable-phpdbg', '--enable-cgi', '--disable-cgi', '--enable-phpdbg', '--disable-phpdbg',
'--enable-embed', '--disable-embed', '--enable-opcache', '--disable-opcache', '--enable-embed', '--disable-embed', '--enable-opcache', '--disable-opcache',
'--enable-zts', '--disable-zts',
'--with-pear', '--without-pear', '--with-pear', '--without-pear',
]; ];
$drop = ['--with-apxs', '--with-apxs2', '--with-fpm-systemd', ...self::PREFIX_DERIVED]; $drop = ['--with-apxs', '--with-apxs2', '--with-fpm-systemd', ...self::PREFIX_DERIVED];
@ -194,7 +200,8 @@ final class PhpBuildConfiguration
in_array('fpm', $targets, true) ? '--enable-fpm' : '--disable-fpm', in_array('fpm', $targets, true) ? '--enable-fpm' : '--disable-fpm',
'--disable-cgi', '--disable-cgi',
'--disable-phpdbg', '--disable-phpdbg',
'--disable-embed', in_array('embed', $targets, true) ? '--enable-embed=static' : '--disable-embed',
$zts ? '--enable-zts' : '--disable-zts',
'--enable-opcache', '--enable-opcache',
'--without-pear', '--without-pear',
...$result, ...$result,

@ -179,7 +179,7 @@ class Constants
], ],
'proxy' => [ 'proxy' => [
'longPrefix' => 'proxy', 'longPrefix' => 'proxy',
'description' => 'Proxy URL used for file downloads (HTTP(S) or SOCKS)', 'description' => 'Proxy URL used for network transfers (HTTP(S) or SOCKS)',
'required' => false, 'required' => false,
], ],
'force' => [ 'force' => [
@ -192,13 +192,18 @@ class Constants
'mode' => [ 'mode' => [
'longPrefix' => 'mode', 'longPrefix' => 'mode',
'prefix' => 'm', 'prefix' => 'm',
'description' => 'Build mode: bin, lib, ext, or sapi; default: bin', 'description' => 'Build mode: bin, lib, or ext; default: bin',
'required' => false, 'required' => false,
'defaultValue' => CompilerBase::BUILD_MODE_BIN, 'defaultValue' => CompilerBase::BUILD_MODE_BIN,
], ],
'php-builder' => [
'longPrefix' => 'php-builder',
'description' => 'Build PHP from source, e.g. "extensions: [curl]; zts: on"',
'required' => false,
],
'sapi' => [ 'sapi' => [
'longPrefix' => 'sapi', 'longPrefix' => 'sapi',
'description' => 'Build a self-contained PHP application SAPI: cli, fpm, or both', 'description' => 'PHP SAPI target: embed, cli, fpm, or a comma-separated list',
'required' => false, 'required' => false,
], ],
'run' => [ 'run' => [

@ -32,6 +32,7 @@ use TypePhp\Build\NanoSourceComposer;
use TypePhp\Build\NativeBuilder; use TypePhp\Build\NativeBuilder;
use TypePhp\Build\NativeCommandOptionsTrait; use TypePhp\Build\NativeCommandOptionsTrait;
use TypePhp\Build\NativeDependencyAuditor; use TypePhp\Build\NativeDependencyAuditor;
use TypePhp\Build\PhpBuilderConfiguration;
use TypePhp\Build\PhpSourceExtensionIndex; use TypePhp\Build\PhpSourceExtensionIndex;
use TypePhp\Build\PrecompiledHeaderManager; use TypePhp\Build\PrecompiledHeaderManager;
use TypePhp\Build\ResourceCompilationTrait; use TypePhp\Build\ResourceCompilationTrait;
@ -437,7 +438,8 @@ class Translator extends Preprocessor
['-o, --output <file>', 'Output name or path (default: input basename)'], ['-o, --output <file>', 'Output name or path (default: input basename)'],
['-f, --force', 'Clear incremental caches and force a full rebuild'], ['-f, --force', 'Clear incremental caches and force a full rebuild'],
['-m, --mode <mode>', 'Build mode: bin, lib, or ext (default: bin)'], ['-m, --mode <mode>', 'Build mode: bin, lib, or ext (default: bin)'],
['--sapi <target>', 'Build a self-contained PHP CLI, FPM, or both'], ['--sapi <target>', 'PHP SAPI target: embed (default), CLI, and/or FPM'],
['--php-builder <config>', 'Build a private PHP runtime from source'],
['-r, --run', 'Run the compiled binary after a successful build'], ['-r, --run', 'Run the compiled binary after a successful build'],
['-j, --job <num>', 'Number of parallel compilation jobs (default: 4)'], ['-j, --job <num>', 'Number of parallel compilation jobs (default: 4)'],
['--cxx-std <ver>', 'C++ standard version (default: c++17)'], ['--cxx-std <ver>', 'C++ standard version (default: c++17)'],
@ -450,7 +452,7 @@ class Translator extends Preprocessor
['--lto', 'Enable Link Time Optimization (-flto)'], ['--lto', 'Enable Link Time Optimization (-flto)'],
['--no-literal-strings', 'Disable literal string optimization'], ['--no-literal-strings', 'Disable literal string optimization'],
['--php-version <ver>', 'Accepted PHP language version (8.4-8.5, default: 8.5)'], ['--php-version <ver>', 'Accepted PHP language version (8.4-8.5, default: 8.5)'],
['--proxy <url>', 'Proxy URL used for file downloads'], ['--proxy <url>', 'Proxy URL used for network transfers'],
['--no-progress', 'Print one compilation line per file instead of a progress bar'], ['--no-progress', 'Print one compilation line per file instead of a progress bar'],
['--no-console', 'Hide the console window (Windows GUI applications only)'], ['--no-console', 'Hide the console window (Windows GUI applications only)'],
['--sanitize <type>', 'Enable a sanitizer such as address or undefined'], ['--sanitize <type>', 'Enable a sanitizer such as address or undefined'],
@ -512,6 +514,15 @@ class Translator extends Preprocessor
if ($this->climate->arguments->defined('sapi')) { if ($this->climate->arguments->defined('sapi')) {
$this->configureSapiTargets((string) $this->climate->arguments->get('sapi')); $this->configureSapiTargets((string) $this->climate->arguments->get('sapi'));
} }
if ($this->climate->arguments->defined('php-builder')) {
try {
$this->configurePhpBuilder(PhpBuilderConfiguration::fromCommandLine(
(string) $this->climate->arguments->get('php-builder'),
));
} catch (\InvalidArgumentException $exception) {
$this->error($exception->getMessage());
}
}
// --full-static links the whole musl C runtime into the artifact, so it // --full-static links the whole musl C runtime into the artifact, so it
// only makes sense for a self-contained executable. A shared object // only makes sense for a self-contained executable. A shared object
@ -894,21 +905,17 @@ class Translator extends Preprocessor
{ {
$mode = strtolower(trim($mode)); $mode = strtolower(trim($mode));
$mode = match ($mode) { $mode = match ($mode) {
'binary', 'cli' => self::BUILD_MODE_BIN, 'binary' => self::BUILD_MODE_BIN,
'extension' => self::BUILD_MODE_EXT, 'extension' => self::BUILD_MODE_EXT,
'library', 'shared', 'dll', 'dylib', 'so' => self::BUILD_MODE_LIB, 'library', 'shared', 'dll', 'dylib', 'so' => self::BUILD_MODE_LIB,
'sapi' => self::BUILD_MODE_SAPI,
default => $mode, default => $mode,
}; };
if (!in_array($mode, [self::BUILD_MODE_BIN, self::BUILD_MODE_EXT, self::BUILD_MODE_LIB, self::BUILD_MODE_SAPI], true)) { if (!in_array($mode, [self::BUILD_MODE_BIN, self::BUILD_MODE_EXT, self::BUILD_MODE_LIB], true)) {
$this->error("Invalid build mode `{$mode}`. Expected bin, lib, ext, or sapi."); $this->error("Invalid build mode `{$mode}`. Expected bin, lib, or ext.");
} }
$this->buildMode = $mode; $this->buildMode = $mode;
if ($mode === self::BUILD_MODE_SAPI && $this->sapiTargets === []) {
$this->sapiTargets = ['cli'];
}
} }
public function setTargetName(string $name): void public function setTargetName(string $name): void
@ -1285,9 +1292,9 @@ class Translator extends Preprocessor
private function doGenExtension(): string private function doGenExtension(): string
{ {
if ($this->isBuildModeBin()) { if ($this->isBuildModeBin() && $this->hasSapi('embed')) {
if (!$this->hasFunction(self::ENTRY_FUNCTION)) { if (!$this->hasFunction(self::ENTRY_FUNCTION)) {
$this->climate->red('When the build mode is a binary executable file, the `main()` function must be defined'); $this->climate->red('The Embed SAPI requires a global `main()` function');
exit(1); exit(1);
} }
} }
@ -1320,7 +1327,9 @@ class Translator extends Preprocessor
if ($this->isSapiBuild()) { if ($this->isSapiBuild()) {
$code .= '#include <typephp_opcode_table.h>' . PHP_EOL; $code .= '#include <typephp_opcode_table.h>' . PHP_EOL;
} elseif ($this->isBuildModeEmbed() && !$this->isNanoMode()) { }
if ($this->isBuildModeEmbed() && !$this->isNanoMode()
&& (!$this->isSapiBuild() || $this->hasSapi('embed'))) {
$code .= '#include <typephp_runtime.h>' . PHP_EOL; $code .= '#include <typephp_runtime.h>' . PHP_EOL;
if ($this->embeddedFiles === [] && $this->embeddedOpcodeFiles === []) { if ($this->embeddedFiles === [] && $this->embeddedOpcodeFiles === []) {
// The runtime still calls these hooks; keep empty builds in this translation unit. // The runtime still calls these hooks; keep empty builds in this translation unit.
@ -1553,16 +1562,18 @@ CODE;
$traitMetadata = $this->genTraitMetadataCode(); $traitMetadata = $this->genTraitMetadataCode();
$code .= $traitMetadata['declarations']; $code .= $traitMetadata['declarations'];
$code .= "// clang-format off\n"; $processTitleFunctions = '';
$code .= "static const zend_function_entry ext_functions[] = {\n";
if (!$this->isNanoMode() if (!$this->isNanoMode()
&& $this->isBuildModeBin() && $this->isBuildModeBin()
&& !$this->isWasiTarget() && !$this->isWasiTarget()
&& !$this->isIosTarget()) { && !$this->isIosTarget()) {
$code .= $this->getIndent() . "PHP_FE(cli_set_process_title, arginfo_cli_set_process_title)\n"; $processTitleFunctions .= $this->getIndent()
$code .= $this->getIndent() . "PHP_FE(cli_get_process_title, arginfo_cli_get_process_title)\n"; . "PHP_FE(cli_set_process_title, arginfo_cli_set_process_title)\n";
$processTitleFunctions .= $this->getIndent()
. "PHP_FE(cli_get_process_title, arginfo_cli_get_process_title)\n";
} }
$projectFunctions = '';
foreach ($this->symbols->functions() as $functionDef) { foreach ($this->symbols->functions() as $functionDef) {
if ($functionDef->attributeFactory) { if ($functionDef->attributeFactory) {
continue; continue;
@ -1580,11 +1591,25 @@ CODE;
$zifName = $this->escapeZendFnName($fullName); $zifName = $this->escapeZendFnName($fullName);
// TypePHP is always strict. Store the flag in the registered // TypePHP is always strict. Store the flag in the registered
// zend_function instead of rewriting shared metadata on every call. // zend_function instead of rewriting shared metadata on every call.
$code .= $this->getIndent() . 'ZEND_RAW_FENTRY("' . $this->escapeString($fullName) $projectFunctions .= $this->getIndent() . 'ZEND_RAW_FENTRY("' . $this->escapeString($fullName)
. '", ZEND_FN(' . $zifName . '), arginfo_' . $zifName . '", ZEND_FN(' . $zifName . '), arginfo_' . $zifName
. ', ZEND_ACC_STRICT_TYPES, NULL, NULL)' . PHP_EOL; . ', ZEND_ACC_STRICT_TYPES, NULL, NULL)' . PHP_EOL;
} }
$code .= "// clang-format off\n";
$code .= "static const zend_function_entry ext_functions[] = {\n";
$code .= $processTitleFunctions;
$code .= $projectFunctions;
$code .= $this->getIndent() . "ZEND_FE_END\n};\n// clang-format on" . PHP_EOL . PHP_EOL; $code .= $this->getIndent() . "ZEND_FE_END\n};\n// clang-format on" . PHP_EOL . PHP_EOL;
if ($this->isSapiBuild() && $processTitleFunctions !== '') {
// PHP CLI already registers these functions, while FPM does not
// provide their implementation. The SAPI module therefore uses a
// table containing project functions only. Embed keeps the
// original process-title API through ext_functions.
$code .= "// clang-format off\n";
$code .= "static const zend_function_entry sapi_ext_functions[] = {\n";
$code .= $projectFunctions;
$code .= $this->getIndent() . "ZEND_FE_END\n};\n// clang-format on" . PHP_EOL . PHP_EOL;
}
// minit begin // minit begin
$releaseAstConstantFns = array_unique($this->releaseAstConstantFns); $releaseAstConstantFns = array_unique($this->releaseAstConstantFns);
@ -1767,6 +1792,28 @@ CODE;
foreach ($this->nativeStaticInitializers as $name => $_) { foreach ($this->nativeStaticInitializers as $name => $_) {
$code .= $this->escapeGlobalVar($name) . ' = false;' . PHP_EOL; $code .= $this->escapeGlobalVar($name) . ' = false;' . PHP_EOL;
} }
if ($this->isBuildModeBin() && (!$this->isSapiBuild() || $this->hasSapi('embed'))) {
// Embed registers the generated module after request startup and
// unloads that temporary module before Zend tears down class
// statics. Release only static array caches which may retain
// Reflection objects pointing into the temporary module. This is
// deliberately skipped for ext/CLI/FPM request shutdown.
$code .= 'if (strcmp(sapi_module.name, "embed") == 0) {' . PHP_EOL;
foreach ($this->symbols->classes() as $classDef) {
if ($classDef->trait) {
continue;
}
foreach ($classDef->properties as $property) {
if (!$property->isStatic() || !$property->arrayInitPlan) {
continue;
}
$code .= $this->getIndent(2) . 'php::setStaticProperty('
. $this->genCharPtr($classDef->getNamespacedName(false), true) . ', '
. $this->genCharPtr($property->name) . ', php::Array{});' . PHP_EOL;
}
}
$code .= '}' . PHP_EOL;
}
$code .= $this->genRequestArrayDefaultCleanup(); $code .= $this->genRequestArrayDefaultCleanup();
foreach ($this->constants as $name => $const) { foreach ($this->constants as $name => $const) {
if ($const->type !== Type::VAR) { if ($const->type !== Type::VAR) {
@ -1811,7 +1858,10 @@ CODE;
} }
$code .= 'module_init();' . PHP_EOL; $code .= 'module_init();' . PHP_EOL;
if ($this->isBuildModeBin() && !$this->isNanoMode()) { if ($this->isBuildModeBin() && $this->hasSapi('embed') && !$this->isNanoMode()) {
if ($this->isSapiBuild()) {
$code .= 'if (strcmp(sapi_module.name, "embed") == 0) {' . PHP_EOL;
}
$entryFunction = $this->symbols->function(self::ENTRY_FUNCTION); $entryFunction = $this->symbols->function(self::ENTRY_FUNCTION);
if ($this->isNanoPolicyMode()) { if ($this->isNanoPolicyMode()) {
// Windows keeps the complete PHP/PHPX DLL runtime, but a Nano // Windows keeps the complete PHP/PHPX DLL runtime, but a Nano
@ -1849,6 +1899,9 @@ CODE;
$code .= 'php::eval(' . $entryScriptArg . ', ' . $entryFileArg . ');' . PHP_EOL; $code .= 'php::eval(' . $entryScriptArg . ', ' . $entryFileArg . ');' . PHP_EOL;
} }
if ($this->isSapiBuild()) {
$code .= '}' . PHP_EOL;
}
} }
if ($this->isSapiBuild()) { if ($this->isSapiBuild()) {
@ -1922,19 +1975,39 @@ zend_module_entry {$moduleName}_module_entry = {
}; };
CODE; CODE;
$code .= PHP_EOL . PHP_EOL; $code .= PHP_EOL . PHP_EOL;
if ($this->isSapiBuild() && $processTitleFunctions !== '') {
$code .= <<<CODE
zend_module_entry {$moduleName}_sapi_module_entry = {
{$moduleHeader}
"{$moduleName}",
sapi_ext_functions,
PHP_MINIT({$moduleName}),
PHP_MSHUTDOWN({$moduleName}),
PHP_RINIT({$moduleName}),
PHP_RSHUTDOWN({$moduleName}),
{$moduleInfoFunction},
{$moduleVersion},
STANDARD_MODULE_PROPERTIES,
};
CODE;
$code .= PHP_EOL . PHP_EOL;
}
if ($this->isBuildModeExt()) { if ($this->isBuildModeExt()) {
$code .= "ZEND_GET_MODULE({$moduleName});\n"; $code .= "ZEND_GET_MODULE({$moduleName});\n";
$code .= '} // namespace ' . $projectNamespace . PHP_EOL; $code .= '} // namespace ' . $projectNamespace . PHP_EOL;
} elseif ($this->isBuildModeSapi()) {
$code .= '} // namespace ' . $projectNamespace . PHP_EOL . PHP_EOL;
$code .= 'extern "C" zend_module_entry *' . $this->getSapiModulePointerName()
. ' = &' . $projectNamespace . '::' . $moduleName . '_module_entry;' . PHP_EOL;
} elseif ($this->isBuildModeEmbed() && !$this->isNanoMode()) { } elseif ($this->isBuildModeEmbed() && !$this->isNanoMode()) {
$code .= '} // namespace ' . $projectNamespace . PHP_EOL . PHP_EOL; $code .= '} // namespace ' . $projectNamespace . PHP_EOL . PHP_EOL;
if ($this->isSapiBuild()) {
$code .= 'extern "C" zend_module_entry *' . $this->getSapiModulePointerName()
. ' = &' . $projectNamespace . '::' . $moduleName
. ($processTitleFunctions !== '' ? '_sapi' : '') . '_module_entry;' . PHP_EOL;
}
if (!$this->isSapiBuild() || $this->hasSapi('embed')) {
$code .= 'TYPEPHP_EMBED_GET_MODULE_FUNCTION(' . $this->targetName . ') {' . PHP_EOL; $code .= 'TYPEPHP_EMBED_GET_MODULE_FUNCTION(' . $this->targetName . ') {' . PHP_EOL;
$code .= $this->getIndent() . 'return &' . $projectNamespace . '::' . $moduleName . '_module_entry;' . PHP_EOL; $code .= $this->getIndent() . 'return &' . $projectNamespace . '::' . $moduleName . '_module_entry;' . PHP_EOL;
$code .= '}' . PHP_EOL; $code .= '}' . PHP_EOL;
}
} else { } else {
$code .= '} // namespace ' . $projectNamespace . PHP_EOL; $code .= '} // namespace ' . $projectNamespace . PHP_EOL;
} }
@ -1960,7 +2033,7 @@ CODE;
{ {
$dependencies = $this->extensionDependencies; $dependencies = $this->extensionDependencies;
$seen = []; $seen = [];
$sourceIndex = $this->isSapiBuild() && $this->sapiPhpSourceDirectory !== null $sourceIndex = $this->isPhpBuilderBuild() && $this->sapiPhpSourceDirectory !== null
? PhpSourceExtensionIndex::forSource($this->sapiPhpSourceDirectory) ? PhpSourceExtensionIndex::forSource($this->sapiPhpSourceDirectory)
: null; : null;
foreach ($dependencies as $dependency) { foreach ($dependencies as $dependency) {
@ -2413,7 +2486,7 @@ CODE;
array_push($sourceFiles, ...$this->getEmbeddedRuntimeSources()); array_push($sourceFiles, ...$this->getEmbeddedRuntimeSources());
} }
if ($this->isBuildModeBin() if (($this->isBuildModeBin() || $this->hasSapi('embed'))
&& !$this->isWasiTarget() && !$this->isWasiTarget()
&& !$this->isIosTarget()) { && !$this->isIosTarget()) {
$sourceFiles[] = $this->getPhpxDir() . '/src/misc/php_cli_process_title.c'; $sourceFiles[] = $this->getPhpxDir() . '/src/misc/php_cli_process_title.c';
@ -2426,7 +2499,7 @@ CODE;
private function getEmbeddedRuntimeSources(): array private function getEmbeddedRuntimeSources(): array
{ {
$sources = []; $sources = [];
if (!$this->isSapiBuild()) { if (!$this->isSapiBuild() || $this->hasSapi('embed')) {
$runtimeSource = $this->getPhpxDir() . '/src/misc/typephp_runtime.cc'; $runtimeSource = $this->getPhpxDir() . '/src/misc/typephp_runtime.cc';
// PHPX 2.6.3 keeps the common runtime in typephp_main.cc. Newer PHPX // PHPX 2.6.3 keeps the common runtime in typephp_main.cc. Newer PHPX
// versions split it out so the object can be shared across projects. // versions split it out so the object can be shared across projects.
@ -2803,31 +2876,66 @@ CODE;
public function build(array $objectFiles): string public function build(array $objectFiles): string
{ {
if ($this->isSapiBuild()) { if ($this->isPhpBuilderBuild()) {
if ($this->sapiPhpSourceDirectory === null if ($this->sapiPhpSourceDirectory === null
|| $this->sapiPhpBuildDirectory === null || $this->sapiPhpBuildDirectory === null
|| $this->sapiPhpxArchive === null || $this->sapiPhpxArchive === null
|| $this->sapiRuntimeArchives === []) { || $this->sapiRuntimeArchives === []) {
throw new \LogicException('SAPI runtime was not prepared before linking'); throw new \LogicException('PHP builder runtime was not prepared before linking');
} }
$outputs = $this->getSapiOutputFiles();
$mainObject = $this->getObjectFile($this->getPhpxDir() . '/src/misc/typephp_main.cc');
$runtimeObject = $this->getObjectFile($this->getPhpxDir() . '/src/misc/typephp_runtime.cc');
$sapiObject = $this->getObjectFile($this->getPhpxDir() . '/src/misc/typephp_sapi.cc');
$processTitleObject = $this->getObjectFile(
$this->getPhpxDir() . '/src/misc/php_cli_process_title.c',
);
$psTitleObject = $this->getObjectFile($this->getPhpxDir() . '/src/misc/ps_title.c');
$internalFunctionsObject = $this->getObjectFile(
$this->getBuildDir() . '/sapi-internal-functions-' . $this->targetName . '.c',
);
if ($this->isSapiBuild()) {
$sapiTargets = array_values(array_intersect($this->sapiTargets, ['cli', 'fpm']));
$sapiObjects = array_values(array_diff(
$objectFiles,
[$mainObject, $runtimeObject, $processTitleObject, $psTitleObject],
));
try { try {
$target = (new SapiApplicationLinker( (new SapiApplicationLinker(
$this->sapiPhpSourceDirectory, $this->sapiPhpSourceDirectory,
$this->sapiPhpBuildDirectory, $this->sapiPhpBuildDirectory,
$this->sapiPhpxArchive, $this->sapiPhpxArchive,
$this->sapiRuntimeArchives, $this->sapiRuntimeArchives,
$this->getBuildDir(), $this->getBuildDir(),
$this->sapiTargets, $sapiTargets,
$this->sapiEntryFile, $this->sapiEntryFile,
fn (string $message) => $this->output($message, 'lightBlue'), fn (string $message) => $this->output($message, 'lightBlue'),
))->link($objectFiles, $this->getSapiOutputFiles()); ))->link($sapiObjects, $outputs);
} catch (\Throwable $exception) { } catch (\Throwable $exception) {
$this->error('SAPI link failed: ' . $exception->getMessage()); $this->error('PHP builder SAPI link failed: ' . $exception->getMessage());
}
} }
$this->climate->green('Build successful: ' . $target); if ($this->hasSapi('embed')) {
$embedObjects = array_values(array_diff(
$objectFiles,
[$sapiObject, $internalFunctionsObject],
));
$this->linkNativeTarget($embedObjects, $outputs['embed']);
}
$runnableTargets = array_values(array_intersect($this->sapiTargets, ['embed', 'cli']));
$primaryTarget = $runnableTargets[0] ?? $this->sapiTargets[0];
$target = $outputs[$primaryTarget];
$this->climate->green('Build successful: ' . implode(', ', $outputs));
return $target; return $target;
} }
$targetFile = $this->getTargetFileName(); return $this->linkNativeTarget($objectFiles, $this->getTargetFileName());
}
/** @param list<string> $objectFiles */
private function linkNativeTarget(array $objectFiles, string $targetFile): string
{
// Windows: add the .res resource file to the link // Windows: add the .res resource file to the link
if ($this->isWindows() && $this->hasResourceFile()) { if ($this->isWindows() && $this->hasResourceFile()) {
@ -2936,8 +3044,10 @@ CODE;
public function run(string $targetFile): never public function run(string $targetFile): never
{ {
if ($this->isSapiBuild() && !in_array('cli', $this->sapiTargets, true)) { if ($this->isPhpBuilderBuild()
$this->climate->error('--run requires the CLI target in SAPI mode'); && !$this->hasSapi('embed')
&& !$this->hasSapi('cli')) {
$this->climate->error('--run requires an embed or CLI target in `sapi`');
exit(1); exit(1);
} }
if ($this->buildMode !== self::BUILD_MODE_BIN && !$this->isSapiBuild()) { if ($this->buildMode !== self::BUILD_MODE_BIN && !$this->isSapiBuild()) {
@ -4177,6 +4287,13 @@ CODE;
if (array_key_exists('sapi', $cfg) && !$this->climate->arguments->defined('sapi')) { if (array_key_exists('sapi', $cfg) && !$this->climate->arguments->defined('sapi')) {
$this->configureSapiTargets($cfg['sapi']); $this->configureSapiTargets($cfg['sapi']);
} }
if (array_key_exists('php-builder', $cfg) && !$this->climate->arguments->defined('php-builder')) {
try {
$this->configurePhpBuilder(PhpBuilderConfiguration::fromYaml($cfg['php-builder']));
} catch (\InvalidArgumentException $exception) {
$this->error($exception->getMessage());
}
}
if (array_key_exists('entry', $cfg)) { if (array_key_exists('entry', $cfg)) {
if (!is_string($cfg['entry']) || trim($cfg['entry']) === '') { if (!is_string($cfg['entry']) || trim($cfg['entry']) === '') {
$this->error('`entry` must be a non-empty PHP file path'); $this->error('`entry` must be a non-empty PHP file path');
@ -4477,9 +4594,6 @@ CODE;
$buildMode = $cfg['mode'] ?? $cfg['build-mode'] ?? $cfg['type'] ?? null; $buildMode = $cfg['mode'] ?? $cfg['build-mode'] ?? $cfg['type'] ?? null;
if (!empty($buildMode)) { if (!empty($buildMode)) {
$normalizedMode = strtolower(trim((string) $buildMode)); $normalizedMode = strtolower(trim((string) $buildMode));
if ($this->isSapiBuild() && $normalizedMode !== self::BUILD_MODE_SAPI) {
$this->error('`sapi` is an independent build mode and cannot be combined with `mode`');
}
$this->setBuildMode($normalizedMode); $this->setBuildMode($normalizedMode);
} }
@ -4558,16 +4672,23 @@ CODE;
return $list; return $list;
} }
private function configurePhpBuilder(PhpBuilderConfiguration $configuration): void
{
$this->phpBuilderEnabled = true;
$this->phpBuilderZts = $configuration->zts;
$this->phpBuilderExtensions = $configuration->extensions;
// The SAPI executable owns main(). Generated TypePHP code is linked as
// an internal Zend module and therefore has no dynamic get_module().
}
private function configureSapiTargets(string|array $value): void private function configureSapiTargets(string|array $value): void
{ {
try { try {
$this->sapiTargets = SapiBuildConfiguration::parseTargets($value); $this->sapiTargets = SapiBuildConfiguration::parseTargets($value);
$this->sapiConfigured = true;
} catch (\InvalidArgumentException $exception) { } catch (\InvalidArgumentException $exception) {
$this->error($exception->getMessage()); $this->error($exception->getMessage());
} }
// The SAPI executable owns main(). Generated TypePHP code is linked as
// an internal Zend module and therefore has no dynamic get_module().
$this->buildMode = self::BUILD_MODE_SAPI;
} }
/** @return list<string> */ /** @return list<string> */

@ -27,6 +27,8 @@ function runCompiler(int $argc, array $argv, CompilerRuntime $runtime): void
require_once dirname(__DIR__) . '/vendor/autoload.php'; require_once dirname(__DIR__) . '/vendor/autoload.php';
} }
configureProxyEnvironment($argv);
$completionStatus = CompletionCommand::execute($argv); $completionStatus = CompletionCommand::execute($argv);
if ($completionStatus !== null) { if ($completionStatus !== null) {
if ($completionStatus !== 0) { if ($completionStatus !== 0) {
@ -68,6 +70,43 @@ function runCompiler(int $argc, array $argv, CompilerRuntime $runtime): void
(new ProjectBuildRunner(Translator::getInstance($runtime)))->run($argv); (new ProjectBuildRunner(Translator::getInstance($runtime)))->run($argv);
} }
/**
* Export an explicit CLI proxy for every network-capable child process. The
* downloader still receives the value directly so credentials can be redacted
* from its diagnostics, while tools such as package managers inherit the
* conventional proxy variables from the compiler process.
*/
function configureProxyEnvironment(array $argv): void
{
$proxy = null;
for ($i = 1, $count = count($argv); $i < $count; ++$i) {
$argument = $argv[$i];
if ($argument === '--proxy') {
if (!isset($argv[$i + 1]) || trim($argv[$i + 1]) === '') {
fwrite(STDERR, "Option --proxy requires a URL\n");
exit(1);
}
$proxy = trim($argv[++$i]);
continue;
}
if (str_starts_with($argument, '--proxy=')) {
$proxy = trim(substr($argument, strlen('--proxy=')));
if ($proxy === '') {
fwrite(STDERR, "Option --proxy requires a URL\n");
exit(1);
}
}
}
if ($proxy === null) {
return;
}
foreach (['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'http_proxy', 'https_proxy', 'all_proxy'] as $name) {
putenv($name . '=' . $proxy);
$_ENV[$name] = $proxy;
}
}
function shouldCompileNativeSourceProject(array $argv): bool function shouldCompileNativeSourceProject(array $argv): bool
{ {
$arguments = array_slice($argv, 1); $arguments = array_slice($argv, 1);

Loading…
Cancel
Save